mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
BASE_DB_TIER=sql opened PostgreSQL and then spoke to it in SQLite: strftime, sqlite_master, json_extract, json_each, an INTEGER column holding an int64, an outer join with no ON, backticked identifiers, and a rowid that only one engine has. The tier picked a backend the queries could not reach. Every one of those now comes from hanzoai/orm/dialect, resolved once from the driver the data DB was opened with and reachable as app.Dialect(). Base holds no switch on the engine — it asks for the JSON accessors, the schema catalog, the generated instant and identifier, and the column types, and it asks whether a feature exists at all where one engine has nothing to offer: an insertion-order column, a write-ahead log to fold, a function that renders an instant. Empty means the caller falls back or refuses rather than emitting something the engine will reject. Some differences turned out not to need a dialect at all. An instant is stored as text in a fixed layout, so bucketing the log rollup to the hour is a substring rather than a calendar function, which every engine spells the same way and an index can be built on. Null-safe equality is IS NOT DISTINCT FROM. An int64 column is BIGINT. A join with nothing of its own to match still states a condition. Case-insensitive comparison stays COLLATE NOCASE, because the schema now asks the engine for the collation that name refers to. The proof is a Postgres 18 running the data plane: migrations apply, a collection created over /v1 lands as a table with text, numeric and jsonb columns and its declared index, and records insert, page, sort, filter across scalars, multi-value elements and JSON paths, update and delete. A unique index declared COLLATE NOCASE rejects the second spelling of a handle. Comparing a JSON sub-path to a number is refused there: the extraction yields text, the literal binds as a number, and the filter language does not say which the comparison meant. Compared as text it works.
318 lines
7.4 KiB
Go
318 lines
7.4 KiB
Go
package core
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/fatih/color"
|
|
"github.com/hanzoai/base/tools/osutils"
|
|
"github.com/hanzoai/dbx"
|
|
"github.com/spf13/cast"
|
|
)
|
|
|
|
var AppMigrations MigrationsList
|
|
var SystemMigrations MigrationsList
|
|
|
|
const DefaultMigrationsTable = "_migrations"
|
|
|
|
// MigrationsRunner defines a simple struct for managing the execution of db migrations.
|
|
type MigrationsRunner struct {
|
|
app App
|
|
tableName string
|
|
migrationsList MigrationsList
|
|
inited bool
|
|
}
|
|
|
|
// NewMigrationsRunner creates and initializes a new db migrations MigrationsRunner instance.
|
|
func NewMigrationsRunner(app App, migrationsList MigrationsList) *MigrationsRunner {
|
|
return &MigrationsRunner{
|
|
app: app,
|
|
migrationsList: migrationsList,
|
|
tableName: DefaultMigrationsTable,
|
|
}
|
|
}
|
|
|
|
// Run interactively executes the current runner with the provided args.
|
|
//
|
|
// The following commands are supported:
|
|
// - up - applies all migrations
|
|
// - down [n] - reverts the last n (default 1) applied migrations
|
|
// - history-sync - syncs the migrations table with the runner's migrations list
|
|
func (r *MigrationsRunner) Run(args ...string) error {
|
|
if err := r.initMigrationsTable(); err != nil {
|
|
return err
|
|
}
|
|
|
|
cmd := "up"
|
|
if len(args) > 0 {
|
|
cmd = args[0]
|
|
}
|
|
|
|
switch cmd {
|
|
case "up":
|
|
applied, err := r.Up()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(applied) == 0 {
|
|
color.Green("No new migrations to apply.")
|
|
} else {
|
|
for _, file := range applied {
|
|
color.Green("Applied %s", file)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
case "down":
|
|
toRevertCount := 1
|
|
if len(args) > 1 {
|
|
toRevertCount = cast.ToInt(args[1])
|
|
if toRevertCount < 0 {
|
|
// revert all applied migrations
|
|
toRevertCount = len(r.migrationsList.Items())
|
|
}
|
|
}
|
|
|
|
names, err := r.lastAppliedMigrations(toRevertCount)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
confirm := osutils.YesNoPrompt(fmt.Sprintf(
|
|
"\n%v\nDo you really want to revert the last %d applied migration(s)?",
|
|
strings.Join(names, "\n"),
|
|
toRevertCount,
|
|
), false)
|
|
if !confirm {
|
|
fmt.Println("The command has been cancelled")
|
|
return nil
|
|
}
|
|
|
|
reverted, err := r.Down(toRevertCount)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(reverted) == 0 {
|
|
color.Green("No migrations to revert.")
|
|
} else {
|
|
for _, file := range reverted {
|
|
color.Green("Reverted %s", file)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
case "history-sync":
|
|
if err := r.RemoveMissingAppliedMigrations(); err != nil {
|
|
return err
|
|
}
|
|
|
|
color.Green("The %s table was synced with the available migrations.", r.tableName)
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unsupported command: %q", cmd)
|
|
}
|
|
}
|
|
|
|
// Up executes all unapplied migrations for the provided runner.
|
|
//
|
|
// On success returns list with the applied migrations file names.
|
|
func (r *MigrationsRunner) Up() ([]string, error) {
|
|
if err := r.initMigrationsTable(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
applied := []string{}
|
|
|
|
err := r.app.AuxRunInTransaction(func(txApp App) error {
|
|
return txApp.RunInTransaction(func(txApp App) error {
|
|
for _, m := range r.migrationsList.Items() {
|
|
// applied migrations check
|
|
if r.isMigrationApplied(txApp, m.File) {
|
|
if m.ReapplyCondition == nil {
|
|
continue // no need to reapply
|
|
}
|
|
|
|
shouldReapply, err := m.ReapplyCondition(txApp, r, m.File)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !shouldReapply {
|
|
continue
|
|
}
|
|
|
|
// clear previous history stored entry
|
|
// (it will be recreated after successful execution)
|
|
r.saveRevertedMigration(txApp, m.File)
|
|
}
|
|
|
|
// ignore empty Up action
|
|
if m.Up != nil {
|
|
if err := m.Up(txApp); err != nil {
|
|
return fmt.Errorf("failed to apply migration %s: %w", m.File, err)
|
|
}
|
|
}
|
|
|
|
if err := r.saveAppliedMigration(txApp, m.File); err != nil {
|
|
return fmt.Errorf("failed to save applied migration info for %s: %w", m.File, err)
|
|
}
|
|
|
|
applied = append(applied, m.File)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return applied, nil
|
|
}
|
|
|
|
// Down reverts the last `toRevertCount` applied migrations
|
|
// (in the order they were applied).
|
|
//
|
|
// On success returns list with the reverted migrations file names.
|
|
func (r *MigrationsRunner) Down(toRevertCount int) ([]string, error) {
|
|
if err := r.initMigrationsTable(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
reverted := make([]string, 0, toRevertCount)
|
|
|
|
names, appliedErr := r.lastAppliedMigrations(toRevertCount)
|
|
if appliedErr != nil {
|
|
return nil, appliedErr
|
|
}
|
|
|
|
err := r.app.AuxRunInTransaction(func(txApp App) error {
|
|
return txApp.RunInTransaction(func(txApp App) error {
|
|
for _, name := range names {
|
|
for _, m := range r.migrationsList.Items() {
|
|
if m.File != name {
|
|
continue
|
|
}
|
|
|
|
// revert limit reached
|
|
if toRevertCount-len(reverted) <= 0 {
|
|
return nil
|
|
}
|
|
|
|
// ignore empty Down action
|
|
if m.Down != nil {
|
|
if err := m.Down(txApp); err != nil {
|
|
return fmt.Errorf("failed to revert migration %s: %w", m.File, err)
|
|
}
|
|
}
|
|
|
|
if err := r.saveRevertedMigration(txApp, m.File); err != nil {
|
|
return fmt.Errorf("failed to save reverted migration info for %s: %w", m.File, err)
|
|
}
|
|
|
|
reverted = append(reverted, m.File)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return reverted, nil
|
|
}
|
|
|
|
// RemoveMissingAppliedMigrations removes the db entries of all applied migrations
|
|
// that are not listed in the runner's migrations list.
|
|
func (r *MigrationsRunner) RemoveMissingAppliedMigrations() error {
|
|
loadedMigrations := r.migrationsList.Items()
|
|
|
|
names := make([]any, len(loadedMigrations))
|
|
for i, migration := range loadedMigrations {
|
|
names[i] = migration.File
|
|
}
|
|
|
|
_, err := r.app.DB().Delete(r.tableName, dbx.Not(dbx.HashExp{
|
|
"file": names,
|
|
})).Execute()
|
|
|
|
return err
|
|
}
|
|
|
|
func (r *MigrationsRunner) initMigrationsTable() error {
|
|
if r.inited {
|
|
return nil // already inited
|
|
}
|
|
|
|
rawQuery := fmt.Sprintf(
|
|
"CREATE TABLE IF NOT EXISTS {{%s}} (file VARCHAR(255) PRIMARY KEY NOT NULL, applied BIGINT NOT NULL)",
|
|
r.tableName,
|
|
)
|
|
|
|
_, err := r.app.DB().NewQuery(rawQuery).Execute()
|
|
|
|
if err == nil {
|
|
r.inited = true
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (r *MigrationsRunner) isMigrationApplied(txApp App, file string) bool {
|
|
var exists int
|
|
|
|
err := txApp.DB().Select("(1)").
|
|
From(r.tableName).
|
|
Where(dbx.HashExp{"file": file}).
|
|
Limit(1).
|
|
Row(&exists)
|
|
|
|
return err == nil && exists > 0
|
|
}
|
|
|
|
func (r *MigrationsRunner) saveAppliedMigration(txApp App, file string) error {
|
|
_, err := txApp.DB().Insert(r.tableName, dbx.Params{
|
|
"file": file,
|
|
"applied": time.Now().UnixMicro(),
|
|
}).Execute()
|
|
|
|
return err
|
|
}
|
|
|
|
func (r *MigrationsRunner) saveRevertedMigration(txApp App, file string) error {
|
|
_, err := txApp.DB().Delete(r.tableName, dbx.HashExp{"file": file}).Execute()
|
|
|
|
return err
|
|
}
|
|
|
|
func (r *MigrationsRunner) lastAppliedMigrations(limit int) ([]string, error) {
|
|
var files = make([]string, 0, limit)
|
|
|
|
loadedMigrations := r.migrationsList.Items()
|
|
|
|
names := make([]any, len(loadedMigrations))
|
|
for i, migration := range loadedMigrations {
|
|
names[i] = migration.File
|
|
}
|
|
|
|
err := r.app.DB().Select("file").
|
|
From(r.tableName).
|
|
Where(dbx.Not(dbx.HashExp{"applied": nil})).
|
|
AndWhere(dbx.HashExp{"file": names}).
|
|
// unify microseconds and seconds applied time for backward compatibility
|
|
OrderBy("substr(applied||'0000000000000000', 0, 17) DESC").
|
|
AndOrderBy("file DESC").
|
|
Limit(int64(limit)).
|
|
Column(&files)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return files, nil
|
|
}
|