Documentation
¶
Overview ¶
Package migrate turns a schema declaration into CREATE TABLE statements.
It is the first consumer of github.com/ubgo/sqlb/schema, and it exists as much to prove that layer carries its weight as to be used. A schema is only worth describing if something can act on the description without knowing who wrote it, and this package acts on a Set assembled from packages that never imported each other.
Deliberately narrow. It renders the DDL to create a schema that does not yet exist, in an order that satisfies foreign keys, and it stops there. It does not diff against a live database, because that needs introspection and introspection is engine-specific in a way DDL generation is not; it does not apply anything, because deciding when to run DDL is an operational question and not a library's. Both are reasonable things to build on top, and both would be worse hidden inside this.
It lives under contrib because it targets specific engines. The core stays free of DDL for the same reason it stays free of drivers.
Index ¶
- Constants
- Variables
- func Checksum(s ddl.Script) string
- func CreateIndexes(d render.Dialect, t schema.Table) []string
- func CreateTable(d render.Dialect, t schema.Table) (string, error)
- func Engines() []string
- func Execute(ctx context.Context, conn *sql.Conn, s ddl.Script) error
- func ExecuteDB(ctx context.Context, db *sql.DB, s ddl.Script) error
- func Install(contribs ...Contribution) (ddl.Script, error)
- func Plan(d render.Dialect, s schema.Set) ([]string, error)
- func SQLType(d render.Dialect, c schema.Column) (string, error)
- type AppliedMigration
- type Contribution
- type Ledger
- func (l Ledger) Applied(ctx context.Context, conn *sql.Conn) ([]AppliedMigration, error)
- func (l Ledger) AppliedDB(ctx context.Context, db *sql.DB) ([]AppliedMigration, error)
- func (l Ledger) Apply(ctx context.Context, conn *sql.Conn, ms ...Migration) (applied []string, err error)
- func (l Ledger) ApplyDB(ctx context.Context, db *sql.DB, ms ...Migration) ([]string, error)
- func (l Ledger) RefuseUnknown() Ledger
- type Locker
- type Migration
- type MySQLNamedLock
- type PostgresAdvisoryLock
- type Table
- type TableLock
Examples ¶
Constants ¶
const DefaultLedgerTable = "sqlb_migrations"
DefaultLedgerTable is the ledger's table unless NewLedger is told another.
const DefaultLockWait = 60 * time.Second
DefaultLockWait is how long a lock waits when ctx has no deadline.
const DefaultStaleAfter = 15 * time.Minute
DefaultStaleAfter is how old a TableLock row must be to be taken over.
Variables ¶
var ( // ErrChecksumMismatch is a migration whose script changed after the // ledger recorded it: the database holds what the old script did, and // the code now describes something else. ErrChecksumMismatch = errors.New("migrate: a migration changed after it was applied") // ErrLocked is a lock another process holds past the context's deadline: // the migration lock, or on SQLite the database's own write lock. ErrLocked = errors.New("migrate: another process holds the migration lock") // ErrBadMigration is a migration with no owner, no name, no statements, // or a name used twice in one Apply. ErrBadMigration = errors.New("migrate: a migration needs an owner, a name and a script, and a name used once") // ErrNoLedgerDialect is a Ledger for a dialect it has no table for. ErrNoLedgerDialect = errors.New("migrate: the ledger supports postgres, mysql and sqlite") // ErrUnknownMigration is a ledger row Apply was not given, for an owner it // was given, from a Ledger made with RefuseUnknown: the database has been // migrated by newer code than the code now running, typically after a // rollback, and nothing is applied to a schema this code never knew. ErrUnknownMigration = errors.New("migrate: the database has a migration this code does not know") )
The ledger's errors.
var ErrCheckFailed = errors.New("migrate: a check statement returned rows")
ErrCheckFailed is returned by Execute when a MustReturnNoRows statement returned a row: the check found a problem, SQLite's foreign_key_check a broken reference.
var ErrNilTable = errors.New("migrate: a contribution holds a nil table")
ErrNilTable is returned by Install for a nil entry in a Contribution's Tables, whether the interface itself is nil or holds a nil pointer: a (*pg.CreateTableReady)(nil) is not == nil as a Table, and calling Describe on it panicked.
var ErrNoTypeMapping = errors.New("migrate: engine has no mapping for this column type")
ErrNoTypeMapping reports that an engine has no spelling for a portable type.
It is an error rather than a silent fallback to TEXT. A column that was meant to be a timestamp and silently became text is the kind of defect that is found months later in the data, not at migration time.
Functions ¶
func Checksum ¶
Checksum is a script's identity for the ledger: SHA-256 over each statement's text, flags and SkipIf guard, so a change to any statement, or to whether it runs in a transaction, is a different migration.
func CreateIndexes ¶
CreateIndexes renders one CREATE INDEX statement per declared index.
They are separate from CreateTable because every engine here accepts indexes only after the table exists, and because an index is the part of a schema a caller most often wants to create later or not at all.
Example ¶
ExampleCreateIndexes shows indexes rendered separately, because every engine here accepts them only after the table exists.
package main
import (
"fmt"
"github.com/ubgo/sqlb/contrib/migrate"
"github.com/ubgo/sqlb/dialect/postgres"
"github.com/ubgo/sqlb/schema"
)
func users() schema.Table {
return schema.Table{
Name: "app_user",
PrimaryKey: []string{"id"},
Columns: []schema.Column{
{Name: "id", Type: schema.TypeUUID},
{Name: "email", Type: schema.TypeText},
{Name: "age", Type: schema.TypeInt, Nullable: true},
{Name: "created_at", Type: schema.TypeTimestamp, Default: "CURRENT_TIMESTAMP"},
},
Indexes: []schema.Index{{Name: "app_user_email_key", Columns: []string{"email"}, Unique: true}},
}
}
func main() {
for _, s := range migrate.CreateIndexes(postgres.Dialect{}, users()) {
fmt.Println(s)
}
}
Output: CREATE UNIQUE INDEX "app_user_email_key" ON "app_user" ("email")
func CreateTable ¶
CreateTable renders one CREATE TABLE statement.
Foreign keys are rendered inline rather than as separate ALTER statements, which is why the creation order from Set.Sorted matters: an inline reference to a table that does not exist yet is rejected by every engine here.
Example ¶
ExampleCreateTable shows DDL generated from a declaration.
package main
import (
"fmt"
"github.com/ubgo/sqlb/contrib/migrate"
"github.com/ubgo/sqlb/dialect/postgres"
"github.com/ubgo/sqlb/schema"
)
func users() schema.Table {
return schema.Table{
Name: "app_user",
PrimaryKey: []string{"id"},
Columns: []schema.Column{
{Name: "id", Type: schema.TypeUUID},
{Name: "email", Type: schema.TypeText},
{Name: "age", Type: schema.TypeInt, Nullable: true},
{Name: "created_at", Type: schema.TypeTimestamp, Default: "CURRENT_TIMESTAMP"},
},
Indexes: []schema.Index{{Name: "app_user_email_key", Columns: []string{"email"}, Unique: true}},
}
}
func main() {
ddl, err := migrate.CreateTable(postgres.Dialect{}, users())
if err != nil {
panic(err)
}
fmt.Println(ddl)
}
Output: CREATE TABLE "app_user" ( "id" UUID NOT NULL, "email" TEXT NOT NULL, "age" INTEGER, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY ("id") )
Example (PerEngine) ¶
ExampleCreateTable_perEngine shows one declaration serving two engines. The difference is the type mapping and the quoting, and nothing in the schema itself changes.
package main
import (
"fmt"
"github.com/ubgo/sqlb/contrib/migrate"
"github.com/ubgo/sqlb/dialect/sqlite"
"github.com/ubgo/sqlb/schema"
)
func users() schema.Table {
return schema.Table{
Name: "app_user",
PrimaryKey: []string{"id"},
Columns: []schema.Column{
{Name: "id", Type: schema.TypeUUID},
{Name: "email", Type: schema.TypeText},
{Name: "age", Type: schema.TypeInt, Nullable: true},
{Name: "created_at", Type: schema.TypeTimestamp, Default: "CURRENT_TIMESTAMP"},
},
Indexes: []schema.Index{{Name: "app_user_email_key", Columns: []string{"email"}, Unique: true}},
}
}
func main() {
ddl, err := migrate.CreateTable(sqlite.Dialect{}, users())
if err != nil {
panic(err)
}
fmt.Println(ddl)
}
Output: CREATE TABLE "app_user" ( "id" TEXT NOT NULL, "email" TEXT NOT NULL, "age" INTEGER, "created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY ("id") )
func Engines ¶
func Engines() []string
Engines reports which engines this package can render DDL for, in a stable order, so a caller can check before it starts rather than after.
func Execute ¶
Execute runs a Script on one connection, honouring the three flags a Statement carries, and is the reference runner for them:
- OutsideTransaction statements run on their own, before any transaction is opened and after the one before them commits, because SQLite's PRAGMA foreign_keys is a silent no-op inside one;
- statements that are not Transactional, Postgres's CREATE INDEX CONCURRENTLY among them, run alone and outside a transaction;
- consecutive transactional statements run in one transaction, committed before the next statement that must stand alone, and rolled back on any failure. On Postgres and SQLite that undoes every statement of the transaction. MySQL commits each DDL statement implicitly, so there a failure leaves the statements before it applied. Running the script again recovers only if each statement can run twice: a CREATE TABLE written with IfNotExists can, a MySQL CREATE INDEX cannot, since MySQL has no IF NOT EXISTS for it (docs/ddl/not-built.md has the check);
- a MustReturnNoRows statement is queried, and a row fails the run with ErrCheckFailed, because executing it and ignoring the result reports success whatever it found;
- a statement with a SkipIf query is skipped when that query returns a row, which is how MySQL's CREATE INDEX IF NOT EXISTS is written.
It takes a *sql.Conn rather than a pool on purpose. A connection-scoped setting such as PRAGMA foreign_keys applies to one connection, and a pool is free to run the next statement on another, so a rebuild through a *sql.DB could turn enforcement off on one connection and copy the table on a different one that still enforces it. ExecuteDB pins a connection for a caller holding a pool.
It holds no ledger and takes no lock: which scripts run, once, and in what order across deploys is the application's decision, per ADR-0003.
func Install ¶
func Install(contribs ...Contribution) (ddl.Script, error)
Install orders every contributed CREATE TABLE so each table is created after the tables its foreign keys reference, across contributors that never imported each other, and returns the statements as one Script.
The ordering is schema.Set.Sorted over each statement's Describe, so it is the same deterministic, cycle-checked order Plan computes from a declared Set. A table two contributors both create is refused with schema.ErrDuplicateTable naming both owners, and a foreign key to a table no contribution creates with schema.ErrMissingTable: Install is for a whole database, installed together. A nil table is ErrNilTable rather than a panic, since a Tables slice built conditionally is where one comes from. No contributions give an empty Script.
func Plan ¶
Plan renders every statement needed to create a whole schema, in an order that can be executed top to bottom.
This is the function the schema layer was built for. The ordering comes from Set.Sorted, which resolves foreign keys across contributors, so a library and an application are migrated together by code that imported neither.
Example ¶
ExamplePlan shows the whole thing: two packages that never imported each other, ordered and rendered by a third that imported neither.
package main
import (
"fmt"
"strings"
"github.com/ubgo/sqlb/contrib/migrate"
"github.com/ubgo/sqlb/dialect/postgres"
"github.com/ubgo/sqlb/schema"
)
func users() schema.Table {
return schema.Table{
Name: "app_user",
PrimaryKey: []string{"id"},
Columns: []schema.Column{
{Name: "id", Type: schema.TypeUUID},
{Name: "email", Type: schema.TypeText},
{Name: "age", Type: schema.TypeInt, Nullable: true},
{Name: "created_at", Type: schema.TypeTimestamp, Default: "CURRENT_TIMESTAMP"},
},
Indexes: []schema.Index{{Name: "app_user_email_key", Columns: []string{"email"}, Unique: true}},
}
}
func main() {
set, err := schema.New("ubgo/auth", users())
if err != nil {
panic(err)
}
err = set.Add("the application", schema.Table{
Name: "invoice",
PrimaryKey: []string{"id"},
Columns: []schema.Column{
{Name: "id", Type: schema.TypeUUID},
{Name: "user_id", Type: schema.TypeUUID},
{Name: "total", Type: schema.TypeDecimal},
},
ForeignKeys: []schema.ForeignKey{{
Name: "invoice_user_fk", Columns: []string{"user_id"},
RefTable: "app_user", RefColumns: []string{"id"}, OnDelete: schema.Cascade,
}},
})
if err != nil {
panic(err)
}
plan, err := migrate.Plan(postgres.Dialect{}, *set)
if err != nil {
panic(err)
}
for i, s := range plan {
first, _, _ := strings.Cut(s, "\n")
fmt.Printf("%d. %s\n", i+1, first)
}
}
Output: 1. CREATE TABLE "app_user" ( 2. CREATE TABLE "invoice" ( 3. CREATE UNIQUE INDEX "app_user_email_key" ON "app_user" ("email")
func SQLType ¶
SQLType returns the engine's spelling for a column.
A column with Raw set is returned verbatim, which is the documented escape hatch: the schema layer cannot express every engine's type, and pretending otherwise would be worse than a visible per-column override.
Example ¶
ExampleSQLType shows the one genuinely engine-specific part of DDL.
package main
import (
"fmt"
"github.com/ubgo/sqlb/contrib/migrate"
"github.com/ubgo/sqlb/dialect/postgres"
"github.com/ubgo/sqlb/dialect/sqlite"
"github.com/ubgo/sqlb/schema"
)
func main() {
c := schema.Column{Name: "at", Type: schema.TypeTimestamp}
pgType, _ := migrate.SQLType(postgres.Dialect{}, c)
liteType, _ := migrate.SQLType(sqlite.Dialect{}, c)
fmt.Println("postgres:", pgType)
fmt.Println("sqlite: ", liteType)
fmt.Println("engines: ", migrate.Engines())
}
Output: postgres: TIMESTAMPTZ sqlite: TEXT engines: [clickhouse mysql postgres sqlite]
Types ¶
type AppliedMigration ¶
type AppliedMigration struct {
Owner, Name, Checksum string
// AppliedAt is when Apply recorded the migration, in UTC, to the
// nanosecond where the clock had it.
AppliedAt time.Time
}
AppliedMigration is one row of the ledger: which migration ran, the checksum of the script that ran, and when.
type Contribution ¶
Contribution is one package's tables, under the owner name a duplicate is reported against.
type Ledger ¶
type Ledger struct {
// contains filtered or unexported fields
}
Ledger applies migrations once each, recording them in a table of its own.
func NewLedger ¶
NewLedger makes a ledger for d, keeping its records in table (empty means DefaultLedgerTable), serialized by lock. EngineLock(d) is the usual lock: Postgres's advisory lock, MySQL's named lock, a lock row on SQLite.
func (Ledger) Applied ¶
Applied returns every migration the ledger records, oldest first; two recorded at the same instant come in owner, then name, order, so the result is deterministic. A database with no ledger table has applied none, and that is an empty list rather than an error, because a database nobody has migrated yet is an ordinary thing to ask about. The table's existence is read from the engine's catalogue rather than inferred from an error, so a failure that is not a missing table is still returned.
It takes no lock: a row commits with its migration, so a concurrent Apply shows each migration as either applied or not, never half.
func (Ledger) Apply ¶
func (l Ledger) Apply(ctx context.Context, conn *sql.Conn, ms ...Migration) (applied []string, err error)
Apply runs each migration the ledger has not recorded, in the order given, and records it; it returns the names it applied as "owner/name". It takes the lock first and holds it throughout, so a second process running Apply at the same time waits and then finds the work done.
A migration already recorded with the same checksum is skipped; one recorded with another fails with ErrChecksumMismatch and nothing after it runs. The ledger row is the last statement of the migration's script, so on Postgres and SQLite, where DDL is transactional, the row and the change commit together when the script's last statements are transactional; on MySQL, which commits every DDL statement implicitly, a failure part-way leaves the change without its row, and the next Apply runs the migration again, which is why a MySQL migration should be safe to repeat.
From a Ledger made with RefuseUnknown, a recorded migration it was not given, under an owner it was given, fails with ErrUnknownMigration before anything runs.
On SQLite every transaction begins IMMEDIATE, so two deploys on one file wait for each other instead of one failing with SQLITE_BUSY; see ledger_sqlite.go for the two refusals that used to escape.
func (Ledger) ApplyDB ¶
ApplyDB pins one connection from db and runs Apply on it. On SQLite an open refused because another connection is converting the file to WAL is waited out, as Apply waits out the write lock.
func (Ledger) RefuseUnknown ¶
RefuseUnknown returns a copy of l whose Apply fails with ErrUnknownMigration, before applying anything, when the ledger records a migration it was not given under an owner it was given. It is how a library refuses to run against a database a newer release of itself migrated: after a rollback the ledger may hold 003 while the code knows only 001 and 002, and plain Apply would skip what it recognises and start the old code on a schema it was never written for. Owners Apply was not given are not judged, because one ledger table can serve several packages, each knowing only its own migrations. l itself is unchanged.
type Locker ¶
type Locker interface {
Lock(ctx context.Context, conn *sql.Conn) error
Unlock(ctx context.Context, conn *sql.Conn) error
}
Locker serializes migration runs across processes. Lock blocks until the lock is held or ctx ends, and Unlock releases it; both run on the pinned connection Apply is given, since a session lock belongs to one connection.
func EngineLock ¶
EngineLock is the lock each engine has: a session advisory lock on Postgres, a named lock on MySQL, and TableLock on SQLite, which has neither. key names the lock; two ledgers with the same key serialize, and ones with different keys do not.
type Migration ¶
Migration is one named change, owned by a package: its Script runs once, and the ledger remembers it by Owner and Name. Name is the migration's identity for ever: renaming one makes it new, and editing its Script after it ran fails Apply with ErrChecksumMismatch rather than being silently skipped or silently re-run.
type MySQLNamedLock ¶
type MySQLNamedLock struct{ Name string }
MySQLNamedLock is GET_LOCK: a named lock the server releases if the session ends. MySQL takes its wait in whole seconds, so the wait is ctx's remaining time rounded up, or DefaultLockWait without a deadline.
type PostgresAdvisoryLock ¶
type PostgresAdvisoryLock struct{ Key int64 }
PostgresAdvisoryLock is pg_advisory_lock: a session lock the server releases if the connection drops, so a crashed deploy never leaves it held.
type Table ¶
Table is a library's CREATE TABLE, as the ddl package and an engine package build it: pg.CreateTable(t)... satisfies it. It is what Install takes, so a table is declared once, in the DDL a library already writes, and its description for ordering is derived from that statement rather than written beside it.
type TableLock ¶
type TableLock struct {
Dialect render.Dialect
Table string
StaleAfter time.Duration
// Holder names this process in the row; empty uses a random value.
Holder string
}
TableLock is a lock held as the one row of a table, for an engine with no session lock, SQLite. While another connection holds SQLite's write lock the row's INSERT is refused outright, SQLITE_BUSY rather than a duplicate key, and that is treated as the lock being held: the wait continues until ctx ends or, with no deadline, until StaleAfter has passed. A row older than StaleAfter is taken to be a crashed holder's and is replaced, which a session lock never needs, so pick StaleAfter longer than any migration takes. It works on any engine; the session locks are better where they exist.
func (TableLock) Lock ¶
Lock inserts the lock row, waiting while another holder's fresh row is there, and fails with ErrLocked when ctx ends first.
func (TableLock) Unlock ¶
Unlock deletes the lock row. A waiting TableLock writes to the same table while it polls, and SQLite refuses a write while another connection's is in progress, so the delete is retried through that contention, for as long as StaleAfter at most; a failed first attempt was what the race detector showed, leaving the lock held until it went stale.