Skip to content

Commit

Permalink
squibble: support Apply on an unmanaged database
Browse files Browse the repository at this point in the history
Previously Apply would report an error if the database contained any schema
that wasn't empty (apart from the history table). Now, if the schema in the
database is compatible with the proposal, it updates the history table without
error.
  • Loading branch information
creachadair committed Feb 15, 2024
1 parent 05cfb92 commit c12c418
Show file tree
Hide file tree
Showing 3 changed files with 83 additions and 27 deletions.
24 changes: 15 additions & 9 deletions squibble.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ func Logf(ctx context.Context, msg string, args ...any) {
// to db within it. If this succeeds and the transaction commits successfully,
// then Apply succeeds. Otherwise, the transaction is rolled back and Apply
// reports the reason wny.
//
// When applying a schema to an existing unmanaged database, Apply reports an
// error if the current schema is not compatible with the existing schema;
// otherwise it applies the current schema and updates the history.
func (s *Schema) Apply(ctx context.Context, db *sql.DB) error {
if err := s.Check(); err != nil {
return err
Expand Down Expand Up @@ -151,15 +155,17 @@ func (s *Schema) Apply(ctx context.Context, db *sql.DB) error {
if err != nil {
return fmt.Errorf("reading update history: %w", err)
} else if len(hr) == 0 {
// Case 1: There is no schema present in the history table. If the
// database is otherwise empty, apply the current one.
if !schemaIsEssentiallyEmpty(ctx, tx, "main") {
return errors.New("database has an unmanaged schema already")
}

s.logf("No schema is defined, applying initial schema %s", curHash)
if _, err := tx.ExecContext(ctx, s.Current); err != nil {
return fmt.Errorf("apply current schema: %w", err)
// Case 1: There is no schema present in the history table.
if err := checkSchema(ctx, db, "main", s.Current); err == nil {
s.logf("No schema is defined, applying initial schema %s", curHash)
if _, err := tx.ExecContext(ctx, s.Current); err != nil {
return fmt.Errorf("apply current schema: %w", err)
}
} else if errors.Is(err, errSchemaExists) {
s.logf("Schema %s is already current; updating history", curHash)
// fall through
} else {
return fmt.Errorf("unmanaged schema already present (%w)", err)
}
if err := s.addVersion(ctx, tx, HistoryRow{
Timestamp: time.Now().UnixMicro(),
Expand Down
31 changes: 31 additions & 0 deletions squibble_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,34 @@ func TestInconsistent(t *testing.T) {
})
}
}

func TestCompatible(t *testing.T) {
const schema = `create table t (a text); create table u (b integer)`

t.Run("Empty", func(t *testing.T) {
db := mustOpenDB(t)

s := &squibble.Schema{Current: schema}
if err := s.Apply(context.Background(), db); err != nil {
t.Errorf("Apply: unexpected error: %v", err)
}
if err := squibble.Validate(context.Background(), db, schema); err != nil {
t.Errorf("Validate: unexpected error: %v", err)
}
})
t.Run("NonEmpty", func(t *testing.T) {
db := mustOpenDB(t)

if _, err := db.Exec(schema); err != nil {
t.Fatalf("Initializing schema: %v", err)
}

s := &squibble.Schema{Current: "-- compatible schema\n" + schema}
if err := s.Apply(context.Background(), db); err != nil {
t.Errorf("Apply: unexpected error: %v", err)
}
if err := squibble.Validate(context.Background(), db, schema); err != nil {
t.Errorf("Validate: unexpected error: %v", err)
}
})
}
55 changes: 37 additions & 18 deletions validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"cmp"
"context"
"database/sql"
"errors"
"fmt"
"slices"

Expand All @@ -17,32 +18,32 @@ import (
// specified schema, and reports an error if there are discrepancies.
// An error reported by Validate has concrete type ValidationError.
func Validate(ctx context.Context, db DBConn, schema string) error {
vdb, err := sql.Open("sqlite", ":memory:")
if err != nil {
return fmt.Errorf("create validation db: %w", err)
}
defer vdb.Close()
if _, err := vdb.ExecContext(ctx, schema); err != nil {
return fmt.Errorf("init schema: %w", err)
}

main, err := readSchema(ctx, db, "main")
comp, err := schemaTextToRows(ctx, db, schema)
if err != nil {
return err
}
slices.SortFunc(main, compareSchemaRows)
comp, err := readSchema(ctx, vdb, "main")
main, err := readSchema(ctx, db, "main")
if err != nil {
return err
}
slices.SortFunc(comp, compareSchemaRows)

if diff := gocmp.Diff(main, comp); diff != "" {
return ValidationError{Diff: diff}
}
return nil
}

func schemaTextToRows(ctx context.Context, db DBConn, schema string) ([]schemaRow, error) {
vdb, err := sql.Open("sqlite", ":memory:")
if err != nil {
return nil, fmt.Errorf("create validation db: %w", err)
}
defer vdb.Close()
if _, err := vdb.ExecContext(ctx, schema); err != nil {
return nil, fmt.Errorf("init schema: %w", err)
}
return readSchema(ctx, vdb, "main")
}

// ValidationError is the concrete type of errors reported by the Validate
// function.
type ValidationError struct {
Expand Down Expand Up @@ -98,13 +99,31 @@ func readSchema(ctx context.Context, db DBConn, root string) ([]schemaRow, error
}
out = append(out, cur)
}
slices.SortFunc(out, compareSchemaRows)
return out, nil
}

func schemaIsEssentiallyEmpty(ctx context.Context, db DBConn, root string) bool {
sr, err := readSchema(ctx, db, root)
var errSchemaExists = errors.New("schema is already applied")

// checkSchema reports whether the schema for the root database is compatible
// with the given schema text. It returns nil if the root schema is essentially
// empty (possibly but for a history table); it returns errSchemaExists if the
// root schema exists and is equivalent. Any other error means the schemata are
// either incompatible, or unreadable.
func checkSchema(ctx context.Context, db DBConn, root, schema string) error {
main, err := readSchema(ctx, db, root)
if err != nil {
return false
return err
} else if len(main) == 0 || main[0].Name == historyTableName {
return nil
}

comp, err := schemaTextToRows(ctx, db, schema)
if err != nil {
return err
}
if diff := gocmp.Diff(main, comp); diff != "" {
return ValidationError{Diff: diff}
}
return len(sr) == 0 || (len(sr) == 1 && sr[0].Name == historyTableName)
return errSchemaExists
}

0 comments on commit c12c418

Please sign in to comment.