bunmigrator

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 7, 2026 License: MIT Imports: 17 Imported by: 0

README

bun-migrator

Build a Postgres schema from your bun models and apply it with bun's migrator, from your own CLI or from an installed binary.

You describe your schema once, as a list of targets in Go: an enum type, a table built from a bun model, an optional trigger function, or anything you write yourself. bun-migrator turns that list into ordinary bun SQL migration files, in memory or on disk, and runs them. Because every target renders to text first, a dry run prints the exact SQL a real run executes, on a machine with no database at all.

Requirements: Go 1.27+, Postgres 14+ (CREATE OR REPLACE TRIGGER). Postgres only; the generated SQL uses DO blocks, plpgsql and partial indexes.

go get github.com/jalavosus/bun-migrator

Contents

Five-minute start

1. Have bun models. Any struct with bun tags works. Two of them:

type User struct {
    bun.BaseModel `bun:"table:users"`

    ID        int64     `bun:"id,pk,autoincrement"`
    Email     string    `bun:"email,notnull"`
    CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"`
    UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp"`
    DeletedAt time.Time `bun:"deleted_at,soft_delete,nullzero"`
}

type Post struct {
    bun.BaseModel `bun:"table:posts"`

    ID     int64  `bun:"id,pk,autoincrement"`
    UserID int64  `bun:"user_id,notnull"`
    Status string `bun:"status,type:post_status,notnull"` // a Postgres enum
    Title  string `bun:"title,notnull"`
}

2. Build a registry. One entry per thing you want in the database, in the order they must be created:

import bunmigrator "github.com/jalavosus/bun-migrator"

reg, err := bunmigrator.NewRegistry(
    // Enums first: a column cannot use a type that does not exist yet.
    bunmigrator.NewEnum(1, "PostStatus", "post_status", []string{"draft", "published"}),

    // Optional: the function behind auto-maintained updated_at columns.
    bunmigrator.NewAutoUpdatedAt(2),

    // Tables. Leave gaps in the numbers so you can insert later.
    bunmigrator.NewTable(10, "User", "users", (*User)(nil),
        bunmigrator.WithAutoUpdatedAt(),          // stamp updated_at on every real change
        bunmigrator.LiveUniqueIndex("email"),     // unique among non-deleted rows
    ),
    bunmigrator.NewTable(11, "Post", "posts", (*Post)(nil),
        bunmigrator.DependsOn("PostStatus", "User"),
        bunmigrator.ForeignKey(`("user_id") REFERENCES "users" ("id")`),
        bunmigrator.Index("user_id"),
    ),
)
if err != nil {
    log.Fatal(err) // a registry mistake is a programmer error; fail loudly
}

Each NewTable takes four things: a sequence number (its fixed position; see rules), a selector (the name you type on the command line, like User), the Postgres table name, and a typed nil pointer to the model. Options follow.

3. Give it a command. Drop this into your main, or into an existing urfave/cli app as a subcommand:

import (
    "github.com/jalavosus/bun-migrator/migratecmd"
    "github.com/uptrace/bun"
)

cmd := migratecmd.Models(reg, migratecmd.Options{
    // Reuse your app's own *bun.DB. Leave DB nil to read DATABASE_URL / PG_* instead.
    DB: func(ctx context.Context) (*bun.DB, error) { return myapp.DB(), nil },
})
if err := cmd.Run(ctx, os.Args); err != nil {
    log.Fatal(err)
}

4. Run it.

myapp migrate --dry-run     # print every migration file; needs no database
myapp migrate               # apply everything that is not applied yet

You should see:

migrated to group #1:
- ADDED TYPE post_status
- ADDED FUNCTION auto_update_updated_at
- ADDED TABLE users
- ADDED TABLE posts

Run it again and it says the database is up to date. That is the whole loop.

Targets

A target is one object in the database. Three are built in.

NewEnum(seq, selector, typeName, members)

Creates a Postgres enum type. members is any []string, so the output of an enumer-generated XxxStrings() works directly. The SQL is guarded so re-running it is harmless. Members may not contain quotes or dollar signs.

bunmigrator.NewEnum(1, "PostStatus", "post_status", []string{"draft", "published"})
NewTable(seq, selector, tableName, (*Model)(nil), opts...)

Creates a table from the model's bun tags, exactly as bun's own CreateTable would, plus whatever options you add. Nothing about the columns is written by hand, so the table cannot drift from the struct.

Option What it adds
DependsOn("Sel", ...) Selectors that must exist first: the enums its columns use, the tables its foreign keys point at.
ForeignKey(clause) A FOREIGN KEY constraint, spelled as bun wants: ("user_id") REFERENCES "users" ("id").
ColumnExpr(expr) A raw entry in the column list, which is where a table-level CHECK goes.
Index("a", "b") A B-tree index over those columns in that order, named idx_<table>_a_b.
LiveUniqueIndex("a") A unique index restricted to rows where deleted_at IS NULL, so a soft-deleted row does not keep its email or slug forever. The model must have a deleted_at column.
WithAutoUpdatedAt() A BEFORE UPDATE trigger that sets updated_at whenever a row actually changes. The model must have an updated_at column and NewAutoUpdatedAt must be registered.

Column order in Index matters: Postgres can use the leading columns of an index on their own but never the trailing ones, so Index("user_id", "symbol") also serves lookups by user_id alone, and replaces a separate Index("user_id") rather than adding to it. Don't declare an index on a primary key or a unique column; they already have one.

Dropping the table (on rollback) drops its indexes and trigger with it.

NewAutoUpdatedAt(seq) — optional

Postgres has no ON UPDATE; a column default runs on INSERT and never again. This target creates one small plpgsql function, auto_update_updated_at(), and each table that opts in with WithAutoUpdatedAt() gets a trigger calling it. The trigger only fires when the row really changed, so writing the same values back does not move the stamp.

If you maintain updated_at in application code, or don't have the column, leave this target out and don't pass the option. Nothing else references it.

The registry

NewRegistry is where mistakes are caught, before any SQL exists. It returns an error when:

  • sequence numbers are not positive, unique and ascending;
  • two selectors are the same ignoring case;
  • a DependsOn names a selector that isn't registered, or one whose sequence number is higher than the dependent's.

That last rule is what lets the planner sort by sequence number instead of computing a dependency graph: if every dependency has a lower number, sorting is a valid creation order.

reg.Plan(selectors...) turns command-line names into the ordered list of steps to run. Name nothing and it plans everything. Name Post and it plans PostStatus, AutoUpdatedAt, User and Post, because creating posts alone would fail on the missing type, the missing function and the missing foreign key target. Pulling extra targets in costs nothing against a database that already has them: bun skips what is already applied.

The migrate command

migratecmd.Models(reg, opts) returns a *cli.Command named migrate. Rename it if you like. Its options:

Options{
    Out: os.Stdout,                 // where it prints; default stdout
    DB:  func(ctx) (*bun.DB, error) // how it opens the database; default: env, see Connecting
}
myapp migrate [flags] [TARGET...]

  TARGET...          selectors to migrate, with their dependencies; none means all
  --dry-run, -d      print the migration files that would run; touches nothing
  --out DIR, -o DIR  write those files to DIR instead (for the binary); touches nothing
  --name NAME, -n    record the one selected target under a different slug
  --rollback, -r     roll back the last applied group of the planned targets

Notes:

  • --dry-run and --out never open a database and never read the environment, so they work in CI and on a laptop with nothing installed.
  • --dry-run --rollback prints only the down files.
  • --name needs exactly one target. The number is kept, only the description changes, so --name add_users User records 0010_add_users. Dependencies keep their normal names.
  • The help text lists every selector in the registry.

The bun-migrator binary

The binary cannot see your Go models. It works on a directory of SQL files: the one migrate --out writes, or any bun-style directory of NNNN_name.up.sql / .down.sql files you wrote yourself.

go install github.com/jalavosus/bun-migrator/cmd/bun-migrator@latest

bun-migrator [--dsn URL] apply DIR       # run everything unapplied   (alias: up)
bun-migrator [--dsn URL] rollback DIR    # undo the last group         (alias: down)
bun-migrator [--dsn URL] status DIR      # list files and whether each is applied
bun-migrator [--dsn URL] unlock          # clear a lock left by a killed run

A typical split: CI runs myapp migrate --out ./migrations and commits or ships the directory; the deploy step runs bun-migrator apply ./migrations with nothing but the binary and a connection string.

Connecting to Postgres

Both commands find the database in this order:

  1. --dsn URL (binary only), or Options.DB (library).
  2. DATABASE_URL, a postgres://user:pass@host:port/db?sslmode=... URL.
  3. PG_HOST, PG_PORT, PG_USER, PG_PASSWORD, PG_DBNAME, PG_SSLMODE, assembled into a URL. User and password are required; the rest default to localhost, 5432 and postgres.

sslmode is passed to bun's pgdriver, which accepts disable, allow, prefer, require, verify-ca and verify-full and refuses anything else. Left unset, pgdriver's default is TLS without certificate verification; set sslmode=verify-full for anything that matters.

The same logic is exported as pgconn.FromEnv().Open() and pgconn.Config{...}.Open() if you want it elsewhere.

Using the library without the CLI

Everything the commands do is four calls:

steps, err := reg.Plan()                      // or reg.Plan("Post")
ms, err := bunmigrator.Discover(db, steps)     // render to in-memory files, hand to bun
group, err := bunmigrator.Apply(ctx, db, ms)   // or bunmigrator.Rollback
for _, c := range bunmigrator.Changes(group.Migrations, false) {
    fmt.Println("-", c)                        // ADDED TABLE users
}

Apply and Rollback take bun's migration lock, mark a migration applied only after it succeeds, and release the lock even if ctx was cancelled mid-run, so a Ctrl-C does not strand the lock. Every built-in target is idempotent, so re-running one after a crash is safe.

To render without a database, bunmigrator.RenderDB() returns a *bun.DB that can build SQL but cannot connect; bunmigrator.FS(db, steps) gives you the files as an fstest.MapFS; bunmigrator.WriteDir(dir, fsys) puts them on disk.

Writing your own target

Anything implementing this interface can be registered next to the built-ins:

type Target interface {
    Seq() int             // fixed position in the order; a literal
    Selector() string     // the name typed on the command line
    Slug() string         // descriptive half of the filename, e.g. create_view_active_users
    DependsOn() []string  // selectors that must exist first
    Up(db *bun.DB) ([]string, error)   // statements, in order; must not be empty
    Down(db *bun.DB) ([]string, error) // ditto
}

Return SQL text; never execute anything. Make Up idempotent (IF NOT EXISTS, CREATE OR REPLACE) so a re-run after a crash is harmless. Name the slug verb_kind_object (create_view_active_users, alter_table_workers) and the change summary will read ADDED VIEW active_users or UPDATED TABLE workers without any extra work. AutoUpdatedAtTrigger(table) is exported if a custom table-like target wants the same trigger the built-in table gets.

Rules that will save you a bad day

  • Sequence numbers are identity. bun records each migration by its number. Renumber a target and bun sees a brand-new migration and runs it again. Write numbers as literals, never derive them from slice position, and leave gaps (enums 1-9, tables from 10, say) so you can insert without renumbering.
  • Dependencies must have lower numbers. NewRegistry enforces it, so you will find out at startup, not at deploy.
  • Enums before the tables that use them; tables before the tables that reference them; the AutoUpdatedAt function before the tables that opt in. Same rule, three forms.
  • Changing a table is not what this does. Built-in targets only create. Adding a column to an existing table is a new target you write, with an alter_table_<name> slug, or a hand-written SQL file in the directory the binary applies.
  • LiveUniqueIndex needs deleted_at; WithAutoUpdatedAt needs updated_at. Both are checked when the target renders, and --dry-run renders, so it catches them.

Troubleshooting

locking migrations: ... bun_migration_locks — a previous run was killed while holding the lock. Make sure nothing is actually running, then bun-migrator unlock, or:

DELETE FROM bun_migration_locks WHERE table_name = 'bun_migrations';

unknown target "Foo"; known targets are ... — selectors are matched case-insensitively against what NewTable/NewEnum were given, not against Go type names or table names.

depends on unregistered target — the selector in DependsOn is misspelled, or a table used WithAutoUpdatedAt() without NewAutoUpdatedAt in the registry.

is not a migration name bun can parse--name and slugs allow only lower-case letters, digits, _ and -.

A new table's trigger fails with function auto_update_updated_at() does not exist — the function target was never applied. Usually its sequence number collides with a migration already recorded in bun_migrations; give it a fresh number.

--dry-run shows migrations you know are applied — expected. A dry run has no database to ask, so it lists the whole plan and says so.

Testing

go test ./... needs no database. Set TEST_DATABASE_URL to also run the integration tests, which apply a schema, exercise the trigger and the partial index, drive the binary, and roll back:

docker run -d --rm -e POSTGRES_PASSWORD=pg -p 5439:5432 postgres:16
TEST_DATABASE_URL='postgres://postgres:pg@localhost:5439/postgres?sslmode=disable' go test ./...

Examples

Runnable programs live in examples/: a minimal CLI, adding migrate to an existing bun app, migrating at startup without a CLI, the --out plus binary workflow, and a custom target.

License

MIT.

Documentation

Overview

Package bunmigrator builds a Postgres schema out of bun models and applies it through bun's migrator.

A Target is one migratable object -- an enum type (Enum), the shared updated_at trigger function (AutoUpdatedAt), one model's table (Table), or anything a caller implements itself -- and renders itself to SQL text rather than executing it. A Registry is the ordered list of them; Registry.Plan resolves selectors into the subset that has to run, pulling in dependencies; FS turns that into an in-memory migration directory, which Discover hands to bun and Apply runs.

Rendering to text rather than to database calls is what lets a dry run print the exact files a real run would execute, on a machine with no Postgres at all. WriteDir puts those same files on disk for the bun-migrator binary to apply later.

The rendered SQL is Postgres-specific and needs Postgres 14 or later.

Index

Constants

View Source
const (
	TxUpSuffix   = ".tx.up.sql"
	TxDownSuffix = ".tx.down.sql"
)

TxUpSuffix and TxDownSuffix name the two files a target becomes.

The .tx. infix asks bun to run the file's statements inside a transaction. Postgres supports transactional DDL, so a CREATE TABLE that fails partway leaves nothing behind.

View Source
const AutoUpdatedAtSelector = "AutoUpdatedAt"

AutoUpdatedAtSelector is the selector of the AutoUpdatedAt target, and what every table opting in with WithAutoUpdatedAt reports as a dependency.

View Source
const SplitDirective = "--bun:split"

SplitDirective separates statements within one migration file. bun splits on this and never on semicolons, which is what lets an enum target emit a DO block whose body is full of them.

Variables

View Source
var ErrNoConnection = errors.New("bunmigrator: the rendering DB cannot connect")

ErrNoConnection is what the rendering DB's driver returns in place of dialing. Seeing it means something tried to execute a statement against a handle whose only job is turning models into SQL text.

Functions

func Apply

Apply runs every unapplied migration in ms against db, under bun's migration lock, and returns the group it applied. A zero group means the database was already up to date.

Bookkeeping is written only after each migration succeeds (migrate.WithMarkAppliedOnSuccess), so a failed Up never leaves a row claiming success. The trade is that a crash between a migration's commit and its bookkeeping write re-runs the migration, which is why every built-in target renders idempotent SQL.

The lock is released even when ctx is already cancelled: the unlock runs on a context derived with context.WithoutCancel, so a Ctrl-C mid-run does not strand the lock row and break every later run.

func AutoUpdatedAtTrigger

func AutoUpdatedAtTrigger(table string) string

AutoUpdatedAtTrigger renders the BEFORE UPDATE trigger that keeps the updated_at column of the named Postgres table current by calling the function AutoUpdatedAt creates. Table emits it for models that opt in with WithAutoUpdatedAt; it is exported so a custom Target can emit the same trigger.

The WHEN clause is what keeps the stamp meaningful: an UPDATE that writes the same values back is not a change, and without the guard it would still move updated_at. CREATE OR REPLACE TRIGGER (Postgres 14+) is the trigger spelling of IF NOT EXISTS, which every other target here uses.

func Discover

func Discover(db *bun.DB, steps []Step) (*migrate.Migrations, error)

Discover renders steps and registers them with bun.

func FS

func FS(db *bun.DB, steps []Step) (fstest.MapFS, error)

FS renders steps into an in-memory migration directory.

bun's Migration type keeps its up and down functions in an unexported field type, so a struct literal cannot carry them and Register takes its name from the calling file. Discover, which reads .up.sql/.down.sql out of any fs.FS, is the only exported way to hand bun both a name and a body -- so the generated SQL is written to a filesystem that exists only in memory.

The upshot is better than a workaround: --dry-run prints these exact files, so what it shows is not a description of what would run, it is what runs.

func RenderDB

func RenderDB() *bun.DB

RenderDB returns a bun.DB that can build Postgres SQL but cannot run it.

Rendering needs a *bun.DB only for its dialect -- building a query never touches the connection -- but the obvious handle to reach for, pgdb.DB(), goes through env.Must("PG_USER") and panics when it is unset. A dry run on a machine with no Postgres is precisely the case this command exists to serve, so it gets a handle that provably cannot reach a database rather than one that merely happens not to.

func Rollback

func Rollback(ctx context.Context, db *bun.DB, ms *migrate.Migrations) (*migrate.MigrationGroup, error)

Rollback rolls back the most recently applied group among the migrations in ms, under the same lock and bookkeeping rules as Apply, and returns the group it rolled back. A zero group means nothing was applied.

func Stem

func Stem(seq int, slug string) string

Stem returns the filename stem bun parses a migration's identity out of: the sequence number in four fixed digits, then the slug.

The padding is not cosmetic. bun keeps the leading digits as the migration's Name and orders migrations by comparing those strings, so unpadded numbers would run 10 before 2.

func WriteDir

func WriteDir(dir string, fsys fs.FS) error

WriteDir writes every file in fsys into dir, creating dir if needed and overwriting files of the same name. It is what a command's --out flag does, and produces exactly the directory the bun-migrator binary applies.

Overwriting is deliberate, and why this is not os.CopyFS: a generator that fails on its second run into the same directory is useless.

Types

type AutoUpdatedAt

type AutoUpdatedAt struct {
	// contains filtered or unexported fields
}

AutoUpdatedAt is an optional Target that creates the one plpgsql function behind automatically maintained updated_at columns. Register it once, then pass WithAutoUpdatedAt to each table that should get a BEFORE UPDATE trigger calling it; tables that do not opt in are untouched.

It exists because Postgres has no ON UPDATE clause -- a column DEFAULT is evaluated on INSERT and never again -- so keeping the stamp current is a trigger or nothing. One function serves every table because it names the column rather than the table.

func NewAutoUpdatedAt

func NewAutoUpdatedAt(seq int) AutoUpdatedAt

NewAutoUpdatedAt returns the target that creates the shared trigger function. Give it a sequence number lower than any table that opts in.

func (AutoUpdatedAt) DependsOn

func (f AutoUpdatedAt) DependsOn() []string

func (AutoUpdatedAt) Down

func (f AutoUpdatedAt) Down(*bun.DB) ([]string, error)

Down drops the function. Bun rolls a group back in reverse name order, so every table whose trigger calls it has already been dropped -- and with it the trigger -- by the time this runs.

func (AutoUpdatedAt) Selector

func (f AutoUpdatedAt) Selector() string

func (AutoUpdatedAt) Seq

func (f AutoUpdatedAt) Seq() int

func (AutoUpdatedAt) Slug

func (f AutoUpdatedAt) Slug() string

func (AutoUpdatedAt) Up

func (f AutoUpdatedAt) Up(*bun.DB) ([]string, error)

Up renders the function. CREATE OR REPLACE makes it idempotent, and current_timestamp rather than a clock read per row keeps the stamp on the same clock as a created_at default.

type Change

type Change struct {
	Action string
	Kind   string
	Name   string
}

A Change is what one migration did to the schema, in words: an Action such as ADDED, a Kind such as TABLE, and the object's Name. It is derived from the migration's name alone, so it is available to the bun-migrator binary, which never sees a Target.

func Changes

func Changes(ms migrate.MigrationSlice, rollback bool) []Change

Changes describes what applying (or, with rollback set, rolling back) each migration in ms did, in the order they ran. Bun hands back a rolled-back group in ascending name order even though it ran the downs in reverse, so with rollback set the result is ms reversed.

A migration named verb_kind_object -- which is what every built-in target produces, e.g. create_table_users or create_type_post_status -- becomes "ADDED TABLE users" on apply and "DROPPED TABLE users" on rollback; alter becomes UPDATED and REVERTED, drop becomes DROPPED and RESTORED. A name that does not follow that shape, such as one supplied through --name, is reported as "APPLIED <name>" or "ROLLED BACK <name>" rather than guessed at.

func (Change) String

func (c Change) String() string

String renders the change as "ADDED TABLE users".

type Enum

type Enum struct {
	// contains filtered or unexported fields
}

Enum is a Target that creates a Postgres enum type. Bun has no support for enum types at all, so unlike Table this renders its SQL by hand.

func NewEnum

func NewEnum(seq int, selector, typeName string, members []string) Enum

NewEnum returns the target that creates typeName as an enum of members.

func (Enum) DependsOn

func (e Enum) DependsOn() []string

func (Enum) Down

func (e Enum) Down(*bun.DB) ([]string, error)

Down drops the type. Bun rolls a group back in reverse name order, so every table using this type has already been dropped by the time this runs.

func (Enum) Selector

func (e Enum) Selector() string

func (Enum) Seq

func (e Enum) Seq() int

func (Enum) Slug

func (e Enum) Slug() string

func (Enum) Up

func (e Enum) Up(*bun.DB) ([]string, error)

Up renders the guarded CREATE TYPE.

The guard is a DO block rather than IF NOT EXISTS because Postgres has no IF NOT EXISTS for CREATE TYPE. bun splits a migration file on --bun:split alone, never on semicolons, so the block's internal semicolons are safe.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

A Registry is every migratable target, in the order they run.

Build one with NewRegistry, which checks the invariants the rest of the package relies on. Sequence numbers should be literals, never derived from position: renumbering a target changes the name bun recorded it under, which would make an applied migration look unapplied and run it again. Leave gaps so a new enum can go in front of the tables that use it.

func NewRegistry

func NewRegistry(targets ...Target) (Registry, error)

NewRegistry returns a registry of targets, or an error when they break an invariant the planner relies on: sequence numbers must be unique and strictly ascending in declaration order, selectors must be distinct case-insensitively, and every dependency must be registered with a lower sequence number than its dependent.

That last rule is what makes sort-by-sequence a valid topological order, so Registry.Plan never has to compute one.

func (Registry) Lookup

func (r Registry) Lookup(selector string) (Target, bool)

Lookup returns the target selector names, matched case-insensitively.

func (Registry) Plan

func (r Registry) Plan(selectors ...string) ([]Step, error)

Plan resolves selectors into the ordered steps a run applies.

With no selectors every registered target is planned. Named targets pull in their dependencies transitively, which is required rather than helpful: planning a table alone would produce a CREATE TABLE that fails on the enum type a column uses or on a table its foreign key references. Pulling extra targets in costs nothing against a database that already has them, since bun skips every migration it has already applied.

The result is sorted by sequence number, which puts enum types before the tables whose columns use them and each table after the ones it references. That sort is a valid topological order because NewRegistry rejects any dependency whose sequence number is not lower than its dependent's.

func (Registry) Selectors

func (r Registry) Selectors() []string

Selectors returns every selector in migration order, for error messages and for a command's help text.

func (Registry) Targets

func (r Registry) Targets() []Target

Targets returns every registered target, in migration order.

type Step

type Step struct {
	Stem   string
	Target Target
}

Step is one migration in a plan: a target, and the filename stem it will be recorded under. The stem is a field rather than a method call so a caller can replace it -- the way the command's --name flag does -- without touching the registry.

type Table

type Table struct {
	// contains filtered or unexported fields
}

Table is a Target that creates one model's table. Unlike Enum it renders nothing itself: the column list is whatever the model's bun tags say, so the schema cannot drift from the struct it is derived from.

func NewTable

func NewTable(seq int, selector, tableName string, mdl any, opts ...TableOption) Table

NewTable returns the target that creates tableName from mdl, which must be a typed nil pointer to the model, e.g. (*User)(nil). selector is the name a user types to pick this target, and what other targets name in DependsOn.

func (Table) DependsOn

func (t Table) DependsOn() []string

DependsOn lists the declared dependencies, plus the AutoUpdatedAt function when the table opted in with WithAutoUpdatedAt -- the trigger Up renders cannot be created before the function it calls.

func (Table) Down

func (t Table) Down(*bun.DB) ([]string, error)

Down drops the table, and with it every index on it -- Postgres does not need those dropped separately.

Bun rolls a group back in reverse name order, so a referencing table is always dropped before the one it references and no CASCADE is needed.

func (Table) Selector

func (t Table) Selector() string

func (Table) Seq

func (t Table) Seq() int

func (Table) Slug

func (t Table) Slug() string

func (Table) Up

func (t Table) Up(db *bun.DB) ([]string, error)

Up renders the CREATE TABLE, its indexes, and -- with WithAutoUpdatedAt -- the trigger that keeps the updated_at column current.

It goes through AppendQuery rather than the query's String method, which panics on a render error -- a malformed foreign key clause should be an error the CLI reports, not a stack trace.

type TableOption

type TableOption func(*Table)

TableOption tunes a Table at construction.

func ColumnExpr

func ColumnExpr(expr string) TableOption

ColumnExpr adds a raw entry to the column list, which is where a table-level CHECK goes. Putting it here rather than in a following ALTER TABLE keeps it inside the same CREATE TABLE IF NOT EXISTS, and so keeps the target idempotent.

func DependsOn

func DependsOn(selectors ...string) TableOption

DependsOn names the selectors whose objects must exist first -- the enum types this table's columns use, and the tables its foreign keys reference.

func ForeignKey

func ForeignKey(clause string) TableOption

ForeignKey adds a FOREIGN KEY constraint, spelled as bun wants it: `("user_id") REFERENCES "users" ("id")`.

func Index

func Index(columns ...string) TableOption

Index adds a B-tree index over columns, in the order given.

Order is the whole content of a composite index: Postgres can use a leading subset of an index's columns and not a trailing one, so (portfolio_id, symbol) serves a lookup by portfolio_id alone -- replacing the single-column index rather than supplementing it -- while a lookup by symbol alone needs its own.

Primary keys and unique constraints are already indexes, so do not declare one over a column that has either.

func LiveUniqueIndex

func LiveUniqueIndex(columns ...string) TableOption

LiveUniqueIndex adds a unique index over columns, restricted to rows that have not been soft-deleted:

CREATE UNIQUE INDEX ... ON "table" (...) WHERE ("deleted_at" IS NULL)

It is deliberately narrower than a general unique index plus a free-text predicate: every use means the same thing -- "unique among rows that have not been soft-deleted" -- and a name that says so cannot be misapplied to a table with no deleted_at column. Table.Up refuses to render one for a model without that column.

The predicate is the entire reason this exists, not an optimization on top of a plain unique index: a soft-deleted row that kept its slot in an ordinary UNIQUE constraint would make the value it held -- an email, a (portfolio_id, symbol) pair -- unreclaimable forever. Restricting the index to live rows lets a deleted row's value be claimed again by a live one.

func WithAutoUpdatedAt

func WithAutoUpdatedAt() TableOption

WithAutoUpdatedAt gives the table a BEFORE UPDATE trigger that stamps its updated_at column, and makes it depend on the AutoUpdatedAt target, which must therefore be registered. The model must have an updated_at column; Table.Up refuses to render otherwise.

type Target

type Target interface {
	// Seq is the target's fixed position in the migration order. It is a
	// literal in the registry, never derived from position in a slice, so
	// inserting a target cannot renumber -- and thereby re-run -- an existing
	// one.
	Seq() int

	// Slug is the descriptive half of the migration's filename, which bun
	// keeps as the migration's comment.
	Slug() string

	// Selector is the name a user types on the command line.
	Selector() string

	// DependsOn lists the selectors whose objects must exist before this
	// target's SQL will run: the enum types its columns use, and the tables
	// its foreign keys point at.
	DependsOn() []string

	Up(db *bun.DB) ([]string, error)
	Down(db *bun.DB) ([]string, error)
}

Target is one migratable object: a Postgres enum type, the shared updated_at trigger function, or one model's table.

Up and Down return SQL text rather than running it. That is the whole design in one line: `migrate --dry-run` prints what they return and a real run executes it, so the two cannot describe different schemas.

Directories

Path Synopsis
cmd
bun-migrator command
Command bun-migrator applies, rolls back and reports on a directory of bun SQL migrations.
Command bun-migrator applies, rolls back and reports on a directory of bun SQL migrations.
examples
basic command
Command basic is the smallest complete bun-migrator program: two models, a registry, and migratecmd.Models as the whole CLI.
Command basic is the smallest complete bun-migrator program: two models, a registry, and migratecmd.Models as the whole CLI.
custom-target command
Command custom-target shows implementing bunmigrator.Target yourself: Extension (extension.go) and View (view.go), registered alongside the built-in Enum and Table targets.
Command custom-target shows implementing bunmigrator.Target yourself: Extension (extension.go) and View (view.go), registered alongside the built-in Enum and Table targets.
existing-app command
Command existing-app simulates bolting bun-migrator onto an app that already has models, a database handle and a CLI.
Command existing-app simulates bolting bun-migrator onto an app that already has models, a database handle and a CLI.
files-workflow command
Command files-workflow is a Models command, meant to be run with --out to generate a migrations directory for the bun-migrator binary to apply later.
Command files-workflow is a Models command, meant to be run with --out to generate a migrations directory for the bun-migrator binary to apply later.
startup-migrate command
Command startup-migrate is a service that migrates its own schema before it starts serving, using only the bunmigrator library -- no urfave/cli, no migratecmd, because a service's own entry point rarely wants a sub-command tree just to run a migration at boot.
Command startup-migrate is a service that migrates its own schema before it starts serving, using only the bunmigrator library -- no urfave/cli, no migratecmd, because a service's own entry point rarely wants a sub-command tree just to run a migration at boot.
Package migratecmd provides two urfave/cli commands over bunmigrator.
Package migratecmd provides two urfave/cli commands over bunmigrator.
Package pgconn opens a Postgres bun.DB from a DSN or from the PG_* environment variables, for callers that do not already have a handle.
Package pgconn opens a Postgres bun.DB from a DSN or from the PG_* environment variables, for callers that do not already have a handle.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL