From a088a5e1a39e8bc129cd764ba198073a30ef0c49 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 04:19:37 +0000 Subject: [PATCH 1/3] feat: interpret migrations with yaegi instead of compiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the build-and-fork approach with in-process yaegi interpretation: - cmd/migrate now loads migrations/*.go via yaegi and runs the embedded migrate.App directly — no `go build`, no temp binary, no GOWORK synthesis, no toolchain version dance. - cmd/go_migrations queryDAG calls BuildGraph + ToDAGOutput in-process on a freshly-loaded *migrate.Registry instead of round-tripping `dag --format json` through a child binary. - internal/interp/loader.go owns the loader: it rewrites each migration file's `package main` to a virtual package, places the files in an in-memory FS, and overrides migrate.Register with a per-call shim so multiple loads can coexist in one process. - migrate/symbols/ holds the yaegi symbol map (`yaegi extract`-generated for the migrate package) plus a Register() hook so users with third-party imports in their migrations can extend it. - cmd/drivers.go blank-imports github.com/mattn/go-sqlite3 in the CLI itself; previously users registered drivers via their own migrations/main.go, but with yaegi the host process is what runs SQL. The migrations/ directory remains a buildable Go module so IDE tooling keeps working and a standalone binary is still an option for users who want one. Removed the now-dead helpers buildMigrationsBinary, findLocalMakemigrations, upgradeMakemigrationsVersion, isFullGoVersion (~250 lines of build-toolchain plumbing). https://claude.ai/code/session_014Wn4chfaL2aTyjxMXeNBmm --- README.md | 23 ++- cmd/drivers.go | 34 +++++ cmd/go_init.go | 11 +- cmd/go_migrations.go | 251 ++------------------------------- cmd/migrate.go | 87 +++--------- cmd/root.go | 2 +- docs/commands/migrate.md | 41 ++---- go.mod | 1 + go.sum | 2 + internal/interp/loader.go | 155 ++++++++++++++++++++ internal/interp/loader_test.go | 155 ++++++++++++++++++++ migrate/symbols/migrate.go | 103 ++++++++++++++ migrate/symbols/symbols.go | 54 +++++++ 13 files changed, 573 insertions(+), 346 deletions(-) create mode 100644 cmd/drivers.go create mode 100644 internal/interp/loader.go create mode 100644 internal/interp/loader_test.go create mode 100644 migrate/symbols/migrate.go create mode 100644 migrate/symbols/symbols.go diff --git a/README.md b/README.md index a73a476..34942a0 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # makemigrations -A **Go-first** database migration tool with a Django-style workflow. Define your schema in YAML, generate type-safe Go migration files, and run them with a compiled binary that embeds every migration your project has ever had. +A **Go-first** database migration tool with a Django-style workflow. Define your schema in YAML, generate type-safe Go migration files, and run them in-process — no Go toolchain required at runtime, no compiled binary to ship. ## ✨ Why Go Migrations? -- 🔒 **Type-safe**: Migrations are Go code — caught by the compiler, not at runtime -- 📦 **Self-contained binary**: One compiled binary knows all migrations, their dependencies, and their SQL +- 🔒 **Type-safe at edit time**: Migrations are real Go files — caught by your IDE and `go vet` +- ⚡ **No build step**: Migrations are interpreted in-process via [yaegi](https://github.com/traefik/yaegi); no `go build`, no temporary binary, no GOWORK juggling - 🗄️ **Database-agnostic schema**: Write YAML once, deploy to PostgreSQL, MySQL, SQLite, or SQL Server - 🔀 **DAG-based ordering**: Migrations form a dependency graph so parallel branches merge cleanly - 🔄 **Auto change detection**: Diff YAML schemas, generate only what changed - ⚠️ **Safe destructive ops**: Field removals, table drops, and renames require explicit review -- 🔧 **Zero runtime dependency**: The compiled binary has no runtime dependency on makemigrations itself +- 🛠 **Optional fallback**: The generated `migrations/` directory is still a buildable Go module, so you can `go build` it for IDE checks or as an escape hatch --- @@ -34,8 +34,8 @@ This creates: ``` your-project/ └── migrations/ - ├── main.go ← compiled binary entry point - └── go.mod ← dedicated migrations module + ├── main.go ← optional fallback entry point (`go build` still works) + └── go.mod ← dedicated migrations module (used by your IDE / gopls) ``` ### 3. Define your schema @@ -99,7 +99,7 @@ export DATABASE_URL="postgresql://user:pass@localhost/mydb" makemigrations migrate up ``` -`makemigrations migrate` compiles the migration binary automatically and runs it with the correct Go workspace settings. No manual `go build` required. +`makemigrations migrate` interprets the migration files in-process using yaegi and runs the embedded migration App. No `go build`, no temporary binary. --- @@ -130,7 +130,7 @@ makemigrations migrate status ## 📋 migrate Subcommands -`makemigrations migrate` compiles and runs the migration binary. All arguments are forwarded: +`makemigrations migrate` interprets the migration files in-process via yaegi and runs the embedded App. All arguments are forwarded: ```bash makemigrations migrate up # apply all pending @@ -141,7 +141,6 @@ makemigrations migrate status # show applied / pending makemigrations migrate showsql # print SQL without running it makemigrations migrate fake 0001_initial # mark applied without running SQL makemigrations migrate dag # show migration dependency graph -makemigrations migrate --verbose up # show build output ``` --- @@ -242,14 +241,14 @@ makemigrations migrate-to-go --dir migrations/ ### Database connection -The compiled binary reads connection details from `migrations/main.go`. The generated file uses `DATABASE_URL` and `DB_TYPE`: +`makemigrations migrate` reads connection details from `DATABASE_URL` and `DB_TYPE`: ```bash export DATABASE_URL="postgresql://user:pass@localhost/mydb" export DB_TYPE=postgresql # optional, defaults to postgresql ``` -`DB_HOST`, `DB_PORT`, `DB_USER` etc. can be added by editing `migrations/main.go` — see the [Manual Build Guide](docs/manual-migration-build.md#running-the-binary). +If you prefer the optional fallback path of compiling `migrations/` into a standalone binary, edit `migrations/main.go` to read additional vars (`DB_HOST`, `DB_PORT`, `DB_USER`, …) — see the [Manual Build Guide](docs/manual-migration-build.md#running-the-binary). ### Configuration file @@ -286,7 +285,7 @@ See the [Configuration Guide](docs/configuration.md) for complete options. |---------|-------------| | **[init](docs/commands/init.md)** | Bootstrap the `migrations/` directory | | **[makemigrations](docs/commands/makemigrations.md)** | Generate `.go` migration files from YAML schema | -| **[migrate](docs/commands/migrate.md)** | Build and run the compiled migration binary | +| **[migrate](docs/commands/migrate.md)** | Run migrations in-process via the yaegi interpreter | | **[migrate-to-go](docs/commands/migrate_to_go.md)** | Convert existing Goose SQL migrations to Go | | [struct2schema](docs/commands/struct2schema.md) | Generate YAML schemas from Go structs | | [dump_sql](docs/commands/dump_sql.md) | Preview generated SQL from schemas | diff --git a/cmd/drivers.go b/cmd/drivers.go new file mode 100644 index 0000000..ff04fd1 --- /dev/null +++ b/cmd/drivers.go @@ -0,0 +1,34 @@ +/* +MIT License + +# Copyright (c) 2025 OcomSoft + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package cmd + +// Blank-imports for SQL drivers used by the migrate command. Under the +// previous compile-flow each user added the driver they needed to their +// own migrations/main.go; with yaegi the migrations run in the CLI's +// process, so the CLI itself must register the drivers. lib/pq is already +// registered transitively by internal/providers/postgresql. +import ( + _ "github.com/mattn/go-sqlite3" +) diff --git a/cmd/go_init.go b/cmd/go_init.go index 08c2734..1b32e28 100644 --- a/cmd/go_init.go +++ b/cmd/go_init.go @@ -112,10 +112,13 @@ Initialization complete. No existing schema found. To generate your first migration: makemigrations makemigrations --name "initial" -Then build and run: - cd %s && go mod tidy && go build -o migrate . - ./migrate up -`, migrationsDir) +Then run: + makemigrations migrate up + +Migrations are interpreted in-process — no Go toolchain required at runtime. +The generated %s/main.go and %s/go.mod remain available so you can still +'go build' the migrations module for IDE type-checking or as a fallback. +`, migrationsDir, migrationsDir) } return nil diff --git a/cmd/go_migrations.go b/cmd/go_migrations.go index 587b4e0..99619e7 100644 --- a/cmd/go_migrations.go +++ b/cmd/go_migrations.go @@ -26,12 +26,9 @@ SOFTWARE. package cmd import ( - "encoding/json" "fmt" "os" - "os/exec" "path/filepath" - "runtime" "sort" "strings" "time" @@ -41,8 +38,8 @@ import ( "github.com/ocomsoft/makemigrations/internal/codegen" "github.com/ocomsoft/makemigrations/internal/config" + "github.com/ocomsoft/makemigrations/internal/interp" "github.com/ocomsoft/makemigrations/internal/types" - "github.com/ocomsoft/makemigrations/internal/version" yamlpkg "github.com/ocomsoft/makemigrations/internal/yaml" "github.com/ocomsoft/makemigrations/migrate" ) @@ -298,204 +295,23 @@ func promptGoMigDecisions(diff *yamlpkg.SchemaDiff) (map[int]yamlpkg.PromptRespo return decisions, nil } -// buildMigrationsBinary compiles the migrations module in migrationsDir into a -// temporary binary. It returns the binary path and a cleanup function that -// removes the temporary directory. The caller must invoke cleanup() when done. +// queryDAG loads the migrations directory with the yaegi interpreter and +// returns the current migration graph state. No Go toolchain is invoked; the +// migration .go files are interpreted in-process and registered with a fresh +// *migrate.Registry, then BuildGraph + ToDAGOutput run directly. // -// When upgrade is true and no local replace directive is found, the function -// checks whether the migrations go.mod requires an older version of -// github.com/ocomsoft/makemigrations and runs go get to bring it up to the -// version currently running. Pass upgrade=false (--dont-upgrade flag) to skip -// this step, which is useful in CI environments where go.mod must not be -// modified at runtime. -func buildMigrationsBinary(migrationsDir string, verbose bool, upgrade bool) (binPath string, cleanup func(), err error) { - absMigrationsDir, err := filepath.Abs(migrationsDir) +// The verbose parameter is preserved for API compatibility with callers but +// has no effect now that no build step takes place. +func queryDAG(migrationsDir string, _ bool) (*migrate.DAGOutput, error) { + reg, err := interp.LoadRegistry(migrationsDir) if err != nil { - return "", nil, fmt.Errorf("resolving migrations dir: %w", err) + return nil, fmt.Errorf("loading migrations: %w", err) } - - tmpDir, err := os.MkdirTemp("", "makemigrations-*") - if err != nil { - return "", nil, fmt.Errorf("creating temp dir: %w", err) - } - cleanup = func() { _ = os.RemoveAll(tmpDir) } - - tmpBin := filepath.Join(tmpDir, "migrations-bin") - - if verbose { - fmt.Printf("Building migration binary from %s...\n", absMigrationsDir) - } - - // Determine the Go version for the temp workspace. - // Prefer the toolchain directive from the nearest parent go.work/go.mod - // (e.g. "go1.25.7" → "1.25.7"), which has an exact patch version and is - // already cached locally. Fall back to the version of the currently running - // binary (runtime.Version() strips the leading "go"). A plain major.minor - // like "1.25" from a go directive is not enough — Go would try to download - // a toolchain to satisfy it. - parentDir := filepath.Dir(absMigrationsDir) - goVersion := findParentGoVersion(parentDir) - if !isFullGoVersion(goVersion) { - // runtime.Version() returns e.g. "go1.25.7"; strip the "go" prefix. - goVersion = strings.TrimPrefix(runtime.Version(), "go") - } - - // Check parent go.mod files for a local replace of makemigrations. - // If found, include it in the workspace so the build resolves locally - // without any network access or go.sum concerns. - localMakemig := findLocalMakemigrations(parentDir) - - // When upgrade is requested and no local replace is active, ensure the - // migrations module depends on the same makemigrations version as the - // running CLI binary. This prevents runtime mismatches where operations - // added in a newer release are unavailable to the compiled binary. - if upgrade && localMakemig == "" { - upgradeMakemigrationsVersion(absMigrationsDir, verbose) - } - - // Build a temporary go.work that includes the migrations module (and - // optionally a local makemigrations). This solves two problems: - // 1. Parent workspace conflict: a go.work in a parent directory may not - // list the migrations sub-module, causing "main module does not contain - // package" errors when GOWORK is inherited. - // 2. Toolchain downloads: by declaring the same go version as the parent - // project, Go reuses the already-cached toolchain rather than fetching - // a different one. - var work strings.Builder - fmt.Fprintf(&work, "go %s\n\nuse %s\n", goVersion, absMigrationsDir) - if localMakemig != "" { - fmt.Fprintf(&work, "use %s\n", localMakemig) - if verbose { - fmt.Printf("Using local makemigrations: %s\n", localMakemig) - } - } - - var goEnv []string - tmpWork := filepath.Join(tmpDir, "go.work") - if werr := os.WriteFile(tmpWork, []byte(work.String()), 0o644); werr == nil { - goEnv = append(os.Environ(), "GOWORK="+tmpWork) - } else { - // Fallback: disable workspace entirely. - goEnv = append(os.Environ(), "GOWORK=off") - } - - // When no local makemigrations is available the migrations go.sum may be - // stale (e.g. a new subpackage was added to the published module). Run - // go mod download to sync go.sum without modifying go.mod. - if localMakemig == "" { - downloadCmd := exec.Command("go", "mod", "download") - downloadCmd.Dir = absMigrationsDir - downloadCmd.Env = goEnv - if out, dlErr := downloadCmd.CombinedOutput(); dlErr != nil { - if verbose { - fmt.Printf("go mod download warning: %s\n", string(out)) - } - } - } - - buildCmd := exec.Command("go", "build", "-o", tmpBin, ".") - buildCmd.Dir = absMigrationsDir - buildCmd.Env = goEnv - if out, buildErr := buildCmd.CombinedOutput(); buildErr != nil { - cleanup() - return "", nil, fmt.Errorf("building migration binary: %w\nOutput: %s", buildErr, string(out)) - } - - return tmpBin, cleanup, nil -} - -// stderrSupportsColor returns true when stderr is a real terminal and the -// NO_COLOR environment variable is not set. -func stderrSupportsColor() bool { - if os.Getenv("NO_COLOR") != "" { - return false - } - fi, err := os.Stderr.Stat() - if err != nil { - return false - } - return (fi.Mode() & os.ModeCharDevice) != 0 -} - -// warnf prints a warning to stderr. When the terminal supports colour the -// message is rendered in orange (ANSI 256-colour code 214). -func warnf(format string, args ...any) { - msg := fmt.Sprintf(format, args...) - if stderrSupportsColor() { - // \x1b[38;5;214m = orange (256-colour), \x1b[0m = reset - fmt.Fprintf(os.Stderr, "\x1b[38;5;214m%s\x1b[0m\n", msg) - } else { - fmt.Fprintln(os.Stderr, msg) - } -} - -// upgradeMakemigrationsVersion checks whether the migrations go.mod requires a -// different version of github.com/ocomsoft/makemigrations than the running CLI -// binary and runs go get to align them. It runs with GOWORK=off so it operates -// directly on the migrations module without workspace interference. Errors are -// non-fatal warnings printed to stderr. -func upgradeMakemigrationsVersion(migrationsDir string, verbose bool) { - const modPkg = "github.com/ocomsoft/makemigrations" - // Strip any leading "v" from the version string before prepending one so - // that both "1.4.1" and "v1.4.1" in the Version variable produce "v1.4.1". - target := "v" + strings.TrimPrefix(version.GetVersion(), "v") - - goModPath := filepath.Join(migrationsDir, "go.mod") - data, err := os.ReadFile(goModPath) - if err != nil { - // No go.mod found — nothing to upgrade. - return - } - - f, err := modfile.Parse(goModPath, data, nil) - if err != nil { - warnf("Warning: could not parse %s: %v", goModPath, err) - return - } - - for _, req := range f.Require { - if req.Mod.Path == modPkg { - if req.Mod.Version == target { - // Already at the correct version. - return - } - fmt.Printf("Upgrading %s %s → %s\n", modPkg, req.Mod.Version, target) - getCmd := exec.Command("go", "get", modPkg+"@"+target) - getCmd.Dir = migrationsDir - getCmd.Env = append(os.Environ(), "GOWORK=off") - if out, getErr := getCmd.CombinedOutput(); getErr != nil { - warnf("Warning: go get %s@%s failed: %v\n%s", modPkg, target, getErr, strings.TrimSpace(string(out))) - } - return - } - } -} - -// queryDAG builds the migrations binary in a temporary directory and runs -// `dag --format json` to retrieve the current migration graph state. The -// binary is cleaned up automatically when the function returns. -func queryDAG(migrationsDir string, verbose bool) (*migrate.DAGOutput, error) { - binPath, cleanup, err := buildMigrationsBinary(migrationsDir, verbose, true) + g, err := migrate.BuildGraph(reg) if err != nil { - return nil, err + return nil, fmt.Errorf("building migration graph: %w", err) } - defer cleanup() - - dagCmd := exec.Command(binPath, "dag", "--format", "json") - dagOutput, err := dagCmd.Output() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - fmt.Fprintf(os.Stderr, "DAG command stderr: %s\n", string(exitErr.Stderr)) - } - - return nil, fmt.Errorf("running dag command: %w", err) - } - - var result migrate.DAGOutput - if err := json.Unmarshal(dagOutput, &result); err != nil { - return nil, fmt.Errorf("parsing DAG output: %w", err) - } - return &result, nil + return g.ToDAGOutput() } // schemaStateToYAMLSchema converts a migrate.SchemaState (reconstructed from @@ -747,44 +563,3 @@ func findParentGoVersion(startDir string) string { } } -// isFullGoVersion reports whether v is a complete three-part Go version -// (e.g. "1.25.7") rather than a partial major.minor like "1.25". A partial -// version used in a go.work 'go' directive causes Go to attempt a toolchain -// download rather than reusing an already-installed binary. -func isFullGoVersion(v string) bool { - parts := strings.Split(v, ".") - return len(parts) >= 3 -} - -// findLocalMakemigrations walks up from startDir looking for a go.mod that -// has a replace directive for github.com/ocomsoft/makemigrations pointing to -// a local path. Returns the absolute path of the local module, or "" if none -// is found. -func findLocalMakemigrations(startDir string) string { - const modPkg = "github.com/ocomsoft/makemigrations" - dir := startDir - for { - modPath := filepath.Join(dir, "go.mod") - if data, err := os.ReadFile(modPath); err == nil { - if f, err := modfile.Parse(modPath, data, nil); err == nil { - for _, r := range f.Replace { - if r.Old.Path == modPkg && r.New.Path != "" { - p := r.New.Path - if !filepath.IsAbs(p) { - p = filepath.Join(dir, filepath.FromSlash(p)) - } - if abs, err := filepath.Abs(p); err == nil { - return abs - } - return p - } - } - } - } - parent := filepath.Dir(dir) - if parent == dir { - return "" - } - dir = parent - } -} diff --git a/cmd/migrate.go b/cmd/migrate.go index cc193f9..35ddb38 100644 --- a/cmd/migrate.go +++ b/cmd/migrate.go @@ -25,24 +25,24 @@ package cmd import ( "fmt" - "os" - "os/exec" "github.com/spf13/cobra" "github.com/ocomsoft/makemigrations/internal/config" + "github.com/ocomsoft/makemigrations/internal/interp" + "github.com/ocomsoft/makemigrations/migrate" ) -// migrateCmd compiles the migrations module and runs it with the given args. -// DisableFlagParsing passes all arguments — including any flags intended for -// the binary — through unchanged. --verbose / -v and --dont-upgrade are -// intercepted locally and stripped before forwarding. +// migrateCmd interprets the migrations module with yaegi and runs the embedded +// migrate.App in-process. DisableFlagParsing passes every argument straight to +// the App, so each of its subcommands works unchanged. var migrateCmd = &cobra.Command{ Use: "migrate [args...]", - Short: "Build and run the compiled migrations binary", - Long: `Build the compiled migrations binary for the configured migrations directory -and run it with the provided arguments. All arguments are forwarded to the -binary unchanged, so every subcommand the binary supports is available: + Short: "Run migrations in-process via the yaegi interpreter", + Long: `Load the migrations directory with the yaegi interpreter and run the embedded +migrate App with the provided arguments. No Go toolchain is invoked — the +migration .go files are interpreted in-process. All subcommands the App +supports are available: makemigrations migrate up makemigrations migrate up --to 0005_add_index @@ -50,39 +50,12 @@ binary unchanged, so every subcommand the binary supports is available: makemigrations migrate status makemigrations migrate showsql makemigrations migrate fake 0001_initial - makemigrations migrate dag - -Pass --verbose (or -v) to see build output: - - makemigrations migrate --verbose up - -By default the command upgrades the migrations go.mod to match the running CLI -version before building. Pass --dont-upgrade to skip this step (useful in CI -environments where go.mod must not be modified at runtime): - - makemigrations migrate --dont-upgrade up`, + makemigrations migrate dag`, DisableFlagParsing: true, SilenceErrors: true, RunE: func(_ *cobra.Command, args []string) error { cfg := config.LoadOrDefault(configFile) - - // Intercept --verbose / -v and --dont-upgrade so they control build - // behaviour only and are not forwarded to the migrations binary. - verbose := false - upgrade := true - var binaryArgs []string - for _, a := range args { - switch a { - case "--verbose", "-v": - verbose = true - case "--dont-upgrade": - upgrade = false - default: - binaryArgs = append(binaryArgs, a) - } - } - - return ExecuteMigrate(cfg.Migration.Directory, binaryArgs, verbose, upgrade) + return ExecuteMigrate(cfg.Migration.Directory, args) }, } @@ -90,32 +63,16 @@ func init() { rootCmd.AddCommand(migrateCmd) } -// ExecuteMigrate builds the migrations binary for migrationsDir and runs it -// with the provided args. stdin, stdout and stderr are inherited so interactive -// prompts and coloured output from the binary work correctly. -// -// When upgrade is true the migrations go.mod is brought up to the same -// makemigrations version as the running CLI before building. Pass false to -// skip this step (--dont-upgrade flag). -func ExecuteMigrate(migrationsDir string, args []string, verbose bool, upgrade bool) error { - binPath, cleanup, err := buildMigrationsBinary(migrationsDir, verbose, upgrade) +// ExecuteMigrate loads migrationsDir with the yaegi interpreter and runs the +// embedded migrate.App with the provided args. +func ExecuteMigrate(migrationsDir string, args []string) error { + reg, err := interp.LoadRegistry(migrationsDir) if err != nil { - return fmt.Errorf("building migrations binary: %w", err) - } - defer cleanup() - - cmd := exec.Command(binPath, args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if runErr := cmd.Run(); runErr != nil { - // The binary has already written its error to stderr; propagate the - // exit code without adding a second error message. - if exitErr, ok := runErr.(*exec.ExitError); ok { - os.Exit(exitErr.ExitCode()) - } - return runErr + return fmt.Errorf("loading migrations: %w", err) } - return nil + app := migrate.NewAppWithRegistry(migrate.Config{ + DatabaseType: migrate.EnvOr("DB_TYPE", "postgresql"), + DatabaseURL: migrate.EnvOr("DATABASE_URL", ""), + }, reg) + return app.Run(args) } diff --git a/cmd/root.go b/cmd/root.go index b51f97b..4119d37 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -48,7 +48,7 @@ var rootCmd = &cobra.Command{ Available commands: init Initialize migrations directory and create initial migration makemigrations Generate Go migration files from YAML schema changes - migrate Build and run the compiled migrations binary + migrate Run migrations in-process via the yaegi interpreter db2schema Extract database schema to YAML struct2schema Convert Go structs to YAML schema dump_sql Dump merged YAML schema as SQL diff --git a/docs/commands/migrate.md b/docs/commands/migrate.md index be5cd97..3354c78 100644 --- a/docs/commands/migrate.md +++ b/docs/commands/migrate.md @@ -1,26 +1,23 @@ -# migrate — Compiled Migration Binary +# migrate — In-Process Migration Runner -The `migrate` binary is the runtime component of the Go migration framework. It is compiled from the `migrations/` directory in your project and is the primary way to apply, rollback, and inspect database migrations. +`makemigrations migrate` is the runtime component of the Go migration framework. It loads the migration `.go` files in your project's `migrations/` directory using the [yaegi](https://github.com/traefik/yaegi) Go interpreter and runs the embedded migration App in-process — no `go build`, no temporary binary, no Go toolchain required at runtime. -> **This is the primary workflow.** Run `makemigrations init` to generate the `migrations/` directory, then use `makemigrations migrate` or compile the binary manually as shown below. +> **This is the primary workflow.** Run `makemigrations init` to generate the `migrations/` directory, then use `makemigrations migrate` to apply, roll back, and inspect migrations. ## Overview -Each project has its own compiled migration binary that embeds all registered migrations. The binary is built from Go source files in the `migrations/` directory and knows exactly which migrations exist, their dependencies, and their SQL. - ``` your-project/ └── migrations/ - ├── main.go ← binary entry point (generated by init) - ├── go.mod ← module file (generated by init) + ├── main.go ← optional fallback entry point (generated by init) + ├── go.mod ← module file for IDE / type-checking (generated by init) ├── 0001_initial.go ← migration files (generated by makemigrations) - ├── 0002_add_phone.go - └── migrate ← compiled binary (you build this) + └── 0002_add_phone.go ``` -## Running via `makemigrations migrate` (Recommended) +The `migrations/` directory remains a buildable Go module so that your IDE and `go vet` / `gopls` can type-check the migration files. At runtime, `makemigrations migrate` interprets those files directly — `main.go` and `go.mod` are not consulted. -`makemigrations migrate` automatically builds the migrations binary with the correct Go workspace and toolchain settings, then runs it. All arguments are forwarded unchanged. +## Running ```bash makemigrations migrate up @@ -31,25 +28,18 @@ makemigrations migrate fake 0001_initial makemigrations migrate dag ``` -Add `--verbose` to see the build step: +All arguments are forwarded unchanged to the embedded App. -```bash -makemigrations migrate --verbose up -``` +## Optional: Building the Migrations as a Standalone Binary -This is the recommended approach during development because it handles Go workspace conflicts and toolchain version selection automatically. See the [Manual Build Guide](../manual-migration-build.md) for how to build the binary yourself. - -## Building the Binary Manually +The `migrations/` directory is a real Go module, so you can still compile it into a self-contained binary if you want one (e.g., for shipping in a release artifact alongside a database): ```bash cd migrations && go mod tidy && go build -o migrate . +./migrate up ``` -Run this after: -- `makemigrations init` (first time setup) -- `makemigrations makemigrations` (after generating new migrations) - -> If your project uses a `go.work` file or a non-system Go toolchain, see the [Manual Build Guide](../manual-migration-build.md) for the correct environment flags. +This is purely optional — `makemigrations migrate up` produces the same result without the build step. ## Commands @@ -258,15 +248,14 @@ This is primarily used when setting up Go migrations on an existing database. Th ``` Your database already has these tables applied. Mark this migration as applied without re-running SQL: - cd migrations && go mod tidy && go build -o migrate . - ./migrate fake 0001_initial + makemigrations migrate fake 0001_initial ``` --- ## Database Configuration -The compiled binary connects to the database using the configuration embedded in `migrations/main.go`. The file generated by `makemigrations init` looks like this: +`makemigrations migrate` connects to the database using `DATABASE_URL` and `DB_TYPE` (resolved by `migrate.EnvOr`). The same defaults are used by the optional standalone binary; the `migrations/main.go` generated by `makemigrations init` mirrors them: ```go package main diff --git a/go.mod b/go.mod index 91c0a77..2e60609 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 + github.com/traefik/yaegi v0.16.1 golang.org/x/mod v0.27.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index f33e0e5..9eec09c 100644 --- a/go.sum +++ b/go.sum @@ -56,6 +56,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E= +github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= diff --git a/internal/interp/loader.go b/internal/interp/loader.go new file mode 100644 index 0000000..137015b --- /dev/null +++ b/internal/interp/loader.go @@ -0,0 +1,155 @@ +/* +MIT License + +# Copyright (c) 2025 OcomSoft + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +// Package interp loads makemigrations migration .go files into an in-process +// *migrate.Registry using the yaegi interpreter, removing the need to compile +// the migrations module with the Go toolchain. +package interp + +import ( + "bytes" + "fmt" + "go/ast" + "go/parser" + "go/printer" + "go/token" + "os" + "path/filepath" + "reflect" + "sort" + "testing/fstest" + + "github.com/traefik/yaegi/interp" + "github.com/traefik/yaegi/stdlib" + + "github.com/ocomsoft/makemigrations/migrate" + "github.com/ocomsoft/makemigrations/migrate/symbols" +) + +// virtualPkg is the package name used inside the in-memory filesystem when +// rewriting migration source files. The on-disk files declare `package main` +// (so that `go build ./migrations` still works), but yaegi cannot import a +// package named `main`, so we rename it before evaluation. +const virtualPkg = "migrations" + +// LoadRegistry reads every *.go file in migrationsDir (except main.go), +// interprets them with yaegi, and returns a freshly-populated *migrate.Registry. +// +// Each migration file's init() calls migrate.Register, which is intercepted +// here and routed into the returned registry rather than the package-level +// global. This lets the function be called multiple times in a single process +// without duplicate-registration panics. +func LoadRegistry(migrationsDir string) (*migrate.Registry, error) { + files, err := filepath.Glob(filepath.Join(migrationsDir, "*.go")) + if err != nil { + return nil, fmt.Errorf("scanning migrations directory: %w", err) + } + + // Filter out main.go and *_test.go; sort for deterministic eval order. + var migFiles []string + for _, f := range files { + base := filepath.Base(f) + if base == "main.go" { + continue + } + if len(base) > 8 && base[len(base)-8:] == "_test.go" { + continue + } + migFiles = append(migFiles, f) + } + sort.Strings(migFiles) + + if len(migFiles) == 0 { + return migrate.NewRegistry(), nil + } + + // Build an in-memory filesystem with each file rewritten to declare + // `package migrations`. yaegi treats the directory as a single multi-file + // package, dedupes imports, and runs all init() funcs in source order. + fsys := fstest.MapFS{} + for _, path := range migFiles { + data, readErr := os.ReadFile(path) + if readErr != nil { + return nil, fmt.Errorf("reading %s: %w", path, readErr) + } + rewritten, rewriteErr := rewritePackage(path, data, virtualPkg) + if rewriteErr != nil { + return nil, fmt.Errorf("rewriting %s: %w", path, rewriteErr) + } + fsys["src/"+virtualPkg+"/"+filepath.Base(path)] = &fstest.MapFile{Data: rewritten} + } + + reg := migrate.NewRegistry() + + i := interp.New(interp.Options{SourcecodeFilesystem: fsys}) + if err := i.Use(stdlib.Symbols); err != nil { + return nil, fmt.Errorf("registering stdlib symbols: %w", err) + } + if err := i.Use(perLoadSymbols(reg)); err != nil { + return nil, fmt.Errorf("registering migrate symbols: %w", err) + } + + if _, err := i.Eval(`import _ "` + virtualPkg + `"`); err != nil { + return nil, fmt.Errorf("loading migrations: %w", err) + } + return reg, nil +} + +// perLoadSymbols clones symbols.Symbols and overrides the migrate package's +// Register entry to write into reg instead of the global registry. This +// isolation lets multiple LoadRegistry calls coexist in one process. +func perLoadSymbols(reg *migrate.Registry) map[string]map[string]reflect.Value { + out := make(map[string]map[string]reflect.Value, len(symbols.Symbols)) + for pkg, syms := range symbols.Symbols { + copied := make(map[string]reflect.Value, len(syms)) + for name, val := range syms { + copied[name] = val + } + out[pkg] = copied + } + const migPkg = "github.com/ocomsoft/makemigrations/migrate/migrate" + if out[migPkg] == nil { + out[migPkg] = map[string]reflect.Value{} + } + out[migPkg]["Register"] = reflect.ValueOf(func(m *migrate.Migration) { + reg.Register(m) + }) + return out +} + +// rewritePackage parses src and returns it with the package name replaced by +// newName. Comments and formatting are preserved. +func rewritePackage(filename string, src []byte, newName string) ([]byte, error) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, filename, src, parser.ParseComments) + if err != nil { + return nil, err + } + file.Name = ast.NewIdent(newName) + var buf bytes.Buffer + if err := printer.Fprint(&buf, fset, file); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/interp/loader_test.go b/internal/interp/loader_test.go new file mode 100644 index 0000000..177ef1a --- /dev/null +++ b/internal/interp/loader_test.go @@ -0,0 +1,155 @@ +/* +MIT License + +# Copyright (c) 2025 OcomSoft + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package interp_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ocomsoft/makemigrations/internal/interp" +) + +const file0001 = `package main + +import ( + m "github.com/ocomsoft/makemigrations/migrate" +) + +func init() { + m.Register(&m.Migration{ + Name: "0001_initial", + Dependencies: []string{}, + Operations: []m.Operation{ + &m.CreateTable{ + Name: "users", + Fields: []m.Field{ + {Name: "id", Type: "uuid", PrimaryKey: true}, + {Name: "email", Type: "varchar", Length: 255}, + }, + }, + }, + }) +} +` + +const file0002 = `package main + +import ( + m "github.com/ocomsoft/makemigrations/migrate" +) + +func init() { + m.Register(&m.Migration{ + Name: "0002_add_index", + Dependencies: []string{"0001_initial"}, + Operations: []m.Operation{ + &m.AddIndex{ + Table: "users", + Index: m.Index{Name: "users_email_idx", Fields: []string{"email"}, Unique: true}, + }, + }, + }) +} +` + +const fileMain = `package main + +import ( + "fmt" + "os" + + m "github.com/ocomsoft/makemigrations/migrate" +) + +func main() { + app := m.NewApp(m.Config{}) + if err := app.Run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} +` + +func TestLoadRegistry(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "0001_initial.go"), file0001) + mustWrite(t, filepath.Join(dir, "0002_add_index.go"), file0002) + mustWrite(t, filepath.Join(dir, "main.go"), fileMain) + + reg, err := interp.LoadRegistry(dir) + if err != nil { + t.Fatalf("LoadRegistry: %v", err) + } + all := reg.All() + if len(all) != 2 { + t.Fatalf("expected 2 migrations, got %d", len(all)) + } + if all[0].Name != "0001_initial" { + t.Errorf("first migration name = %q, want %q", all[0].Name, "0001_initial") + } + if all[1].Name != "0002_add_index" { + t.Errorf("second migration name = %q, want %q", all[1].Name, "0002_add_index") + } + if len(all[1].Dependencies) != 1 || all[1].Dependencies[0] != "0001_initial" { + t.Errorf("0002 deps = %v, want [0001_initial]", all[1].Dependencies) + } + if len(all[0].Operations) != 1 { + t.Fatalf("0001 ops = %d, want 1", len(all[0].Operations)) + } + if all[0].Operations[0].TypeName() != "create_table" { + t.Errorf("0001 op type = %q, want create_table", all[0].Operations[0].TypeName()) + } +} + +func TestLoadRegistryEmpty(t *testing.T) { + dir := t.TempDir() + reg, err := interp.LoadRegistry(dir) + if err != nil { + t.Fatalf("LoadRegistry empty: %v", err) + } + if len(reg.All()) != 0 { + t.Errorf("expected empty registry, got %d", len(reg.All())) + } +} + +func TestLoadRegistryIsolated(t *testing.T) { + // Two consecutive loads must not produce duplicate-registration panics. + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "0001_initial.go"), file0001) + if _, err := interp.LoadRegistry(dir); err != nil { + t.Fatalf("first load: %v", err) + } + if _, err := interp.LoadRegistry(dir); err != nil { + t.Fatalf("second load: %v", err) + } +} + +func mustWrite(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/migrate/symbols/migrate.go b/migrate/symbols/migrate.go new file mode 100644 index 0000000..563cb2d --- /dev/null +++ b/migrate/symbols/migrate.go @@ -0,0 +1,103 @@ +// Code generated by 'yaegi extract github.com/ocomsoft/makemigrations/migrate'. DO NOT EDIT. + +package symbols + +import ( + "github.com/ocomsoft/makemigrations/internal/providers" + "github.com/ocomsoft/makemigrations/migrate" + "reflect" +) + +func init() { + Symbols["github.com/ocomsoft/makemigrations/migrate/migrate"] = map[string]reflect.Value{ + // function, constant and variable definitions + "BuildGraph": reflect.ValueOf(migrate.BuildGraph), + "BuildProviderFromType": reflect.ValueOf(migrate.BuildProviderFromType), + "EnvOr": reflect.ValueOf(migrate.EnvOr), + "FormatLiteral": reflect.ValueOf(migrate.FormatLiteral), + "GlobalRegistry": reflect.ValueOf(migrate.GlobalRegistry), + "NewApp": reflect.ValueOf(migrate.NewApp), + "NewAppWithRegistry": reflect.ValueOf(migrate.NewAppWithRegistry), + "NewMigrationRecorder": reflect.ValueOf(migrate.NewMigrationRecorder), + "NewRegistry": reflect.ValueOf(migrate.NewRegistry), + "NewRunner": reflect.ValueOf(migrate.NewRunner), + "NewSchemaState": reflect.ValueOf(migrate.NewSchemaState), + "Register": reflect.ValueOf(migrate.Register), + "RenderDAGASCII": reflect.ValueOf(migrate.RenderDAGASCII), + "SortedKeys": reflect.ValueOf(migrate.SortedKeys), + + // type definitions + "AddField": reflect.ValueOf((*migrate.AddField)(nil)), + "AddForeignKey": reflect.ValueOf((*migrate.AddForeignKey)(nil)), + "AddIndex": reflect.ValueOf((*migrate.AddIndex)(nil)), + "AlterField": reflect.ValueOf((*migrate.AlterField)(nil)), + "App": reflect.ValueOf((*migrate.App)(nil)), + "Config": reflect.ValueOf((*migrate.Config)(nil)), + "CreateTable": reflect.ValueOf((*migrate.CreateTable)(nil)), + "DAGOutput": reflect.ValueOf((*migrate.DAGOutput)(nil)), + "DefaultRef": reflect.ValueOf((*migrate.DefaultRef)(nil)), + "DropField": reflect.ValueOf((*migrate.DropField)(nil)), + "DropForeignKey": reflect.ValueOf((*migrate.DropForeignKey)(nil)), + "DropIndex": reflect.ValueOf((*migrate.DropIndex)(nil)), + "DropTable": reflect.ValueOf((*migrate.DropTable)(nil)), + "Field": reflect.ValueOf((*migrate.Field)(nil)), + "ForeignKey": reflect.ValueOf((*migrate.ForeignKey)(nil)), + "ForeignKeyConstraint": reflect.ValueOf((*migrate.ForeignKeyConstraint)(nil)), + "Graph": reflect.ValueOf((*migrate.Graph)(nil)), + "Index": reflect.ValueOf((*migrate.Index)(nil)), + "ManyToMany": reflect.ValueOf((*migrate.ManyToMany)(nil)), + "Migration": reflect.ValueOf((*migrate.Migration)(nil)), + "MigrationRecorder": reflect.ValueOf((*migrate.MigrationRecorder)(nil)), + "MigrationSummary": reflect.ValueOf((*migrate.MigrationSummary)(nil)), + "Operation": reflect.ValueOf((*migrate.Operation)(nil)), + "OperationSummary": reflect.ValueOf((*migrate.OperationSummary)(nil)), + "Registry": reflect.ValueOf((*migrate.Registry)(nil)), + "RenameField": reflect.ValueOf((*migrate.RenameField)(nil)), + "RenameTable": reflect.ValueOf((*migrate.RenameTable)(nil)), + "RunOptions": reflect.ValueOf((*migrate.RunOptions)(nil)), + "RunSQL": reflect.ValueOf((*migrate.RunSQL)(nil)), + "Runner": reflect.ValueOf((*migrate.Runner)(nil)), + "SchemaState": reflect.ValueOf((*migrate.SchemaState)(nil)), + "SetDefaults": reflect.ValueOf((*migrate.SetDefaults)(nil)), + "SetTypeMappings": reflect.ValueOf((*migrate.SetTypeMappings)(nil)), + "TableState": reflect.ValueOf((*migrate.TableState)(nil)), + "UpsertData": reflect.ValueOf((*migrate.UpsertData)(nil)), + + // interface wrapper definitions + "_Operation": reflect.ValueOf((*_github_com_ocomsoft_makemigrations_migrate_Operation)(nil)), + } +} + +// _github_com_ocomsoft_makemigrations_migrate_Operation is an interface wrapper for Operation type +type _github_com_ocomsoft_makemigrations_migrate_Operation struct { + IValue interface{} + WDescribe func() string + WDown func(p providers.Provider, state *migrate.SchemaState, defaults map[string]string) (string, error) + WIsDestructive func() bool + WMutate func(state *migrate.SchemaState) error + WTableName func() string + WTypeName func() string + WUp func(p providers.Provider, state *migrate.SchemaState, defaults map[string]string) (string, error) +} + +func (W _github_com_ocomsoft_makemigrations_migrate_Operation) Describe() string { + return W.WDescribe() +} +func (W _github_com_ocomsoft_makemigrations_migrate_Operation) Down(p providers.Provider, state *migrate.SchemaState, defaults map[string]string) (string, error) { + return W.WDown(p, state, defaults) +} +func (W _github_com_ocomsoft_makemigrations_migrate_Operation) IsDestructive() bool { + return W.WIsDestructive() +} +func (W _github_com_ocomsoft_makemigrations_migrate_Operation) Mutate(state *migrate.SchemaState) error { + return W.WMutate(state) +} +func (W _github_com_ocomsoft_makemigrations_migrate_Operation) TableName() string { + return W.WTableName() +} +func (W _github_com_ocomsoft_makemigrations_migrate_Operation) TypeName() string { + return W.WTypeName() +} +func (W _github_com_ocomsoft_makemigrations_migrate_Operation) Up(p providers.Provider, state *migrate.SchemaState, defaults map[string]string) (string, error) { + return W.WUp(p, state, defaults) +} diff --git a/migrate/symbols/symbols.go b/migrate/symbols/symbols.go new file mode 100644 index 0000000..be5ee51 --- /dev/null +++ b/migrate/symbols/symbols.go @@ -0,0 +1,54 @@ +/* +MIT License + +# Copyright (c) 2025 OcomSoft + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +// Package symbols holds the yaegi symbol map for makemigrations migrations. +// +// The map exposes the public API of github.com/ocomsoft/makemigrations/migrate +// to interpreted migration files so they can be loaded without invoking the Go +// toolchain. Users who write migrations whose RunSQL bodies (or other +// hand-written code) import third-party packages can call Register to add +// extra symbol maps before the CLI loads migrations. +package symbols + +import "reflect" + +// Symbols is the global yaegi symbol map. The package's own init() functions +// (in auto-generated files like migrate.go) populate it with entries for the +// migrate package. Third-party callers can add more entries via Register. +var Symbols = map[string]map[string]reflect.Value{} + +// Register merges extra symbol maps into Symbols. It is the public extension +// point for projects whose migration source files import packages outside of +// the migrate package's symbol map. Generate the input map with +// `yaegi extract ` and call Register from an init() in your project. +func Register(extra map[string]map[string]reflect.Value) { + for pkg, syms := range extra { + if Symbols[pkg] == nil { + Symbols[pkg] = map[string]reflect.Value{} + } + for name, val := range syms { + Symbols[pkg][name] = val + } + } +} From 2ad54db0301e7cd8d64d26c92b0b0eb2999fc5a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 04:25:19 +0000 Subject: [PATCH 2/3] feat: bundle mysql/sqlserver drivers and document symbol-map extension - cmd/drivers.go: blank-import github.com/go-sql-driver/mysql and github.com/microsoft/go-mssqldb so `makemigrations migrate` works out of the box for all four supported database types (postgres was already covered transitively via internal/providers/postgresql). - docs/extending-yaegi-symbols.md: explain the wrapper-CLI pattern for users whose hand-edited migrations import third-party packages not in the default symbol map. Covers both the recommended path (mmsymbols.Register from a generated `yaegi extract` file in a thin main wrapper) and the standalone-binary fallback. - README: link the new guide. https://claude.ai/code/session_014Wn4chfaL2aTyjxMXeNBmm --- README.md | 1 + cmd/drivers.go | 6 ++ docs/extending-yaegi-symbols.md | 186 ++++++++++++++++++++++++++++++++ go.mod | 16 ++- go.sum | 52 +++++++-- 5 files changed, 249 insertions(+), 12 deletions(-) create mode 100644 docs/extending-yaegi-symbols.md diff --git a/README.md b/README.md index 34942a0..cacc3d4 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,7 @@ See the [Configuration Guide](docs/configuration.md) for complete options. - **[Schema Format Guide](docs/schema-format.md)** — complete YAML schema reference - **[Configuration Guide](docs/configuration.md)** - **[Manual Build Guide](docs/manual-migration-build.md)** — GOWORK/GOTOOLCHAIN details for CI/CD +- **[Extending the yaegi Symbol Map](docs/extending-yaegi-symbols.md)** — let interpreted migrations import third-party packages ### Command Reference diff --git a/cmd/drivers.go b/cmd/drivers.go index ff04fd1..ef1eef6 100644 --- a/cmd/drivers.go +++ b/cmd/drivers.go @@ -29,6 +29,12 @@ package cmd // own migrations/main.go; with yaegi the migrations run in the CLI's // process, so the CLI itself must register the drivers. lib/pq is already // registered transitively by internal/providers/postgresql. +// +// The mapping from migrate.Config.DatabaseType to driver name is in +// migrate/app.go (driverName): "mysql"/"tidb" → mysql, "sqlserver" → +// sqlserver, "sqlite" → sqlite3, anything else → postgres. import ( + _ "github.com/go-sql-driver/mysql" _ "github.com/mattn/go-sqlite3" + _ "github.com/microsoft/go-mssqldb" ) diff --git a/docs/extending-yaegi-symbols.md b/docs/extending-yaegi-symbols.md new file mode 100644 index 0000000..ccb9424 --- /dev/null +++ b/docs/extending-yaegi-symbols.md @@ -0,0 +1,186 @@ +# Extending the yaegi Symbol Map + +`makemigrations migrate` runs your migration files in-process with the +[yaegi](https://github.com/traefik/yaegi) Go interpreter. Yaegi can only +resolve `import` statements that have an entry in its **symbol map** — a +`map[string]map[string]reflect.Value` that exposes a host package's +identifiers (functions, types, vars) to interpreted code. + +The CLI ships with a symbol map for `github.com/ocomsoft/makemigrations/migrate` +plus the entire Go standard library (via `yaegi/stdlib`). This is enough for +**every migration file generated by `makemigrations makemigrations`** — the +codegen only emits identifiers from the `migrate` package and stdlib. + +You only need this guide if you **hand-edit a migration** to import a +third-party package, e.g. inside a `RunSQL` body that builds SQL using a +library: + +```go +// migrations/0007_seed_uuids.go +package main + +import ( + m "github.com/ocomsoft/makemigrations/migrate" + + "github.com/google/uuid" // <-- not in the default symbol map +) + +func init() { + id := uuid.New().String() + m.Register(&m.Migration{ + Name: "0007_seed_uuids", + Operations: []m.Operation{ + &m.RunSQL{Up: "INSERT INTO tokens(id) VALUES('" + id + "')"}, + }, + }) +} +``` + +Running `makemigrations migrate up` against the above produces: + +``` +loading migrations: ... import "github.com/google/uuid" error: not found +``` + +You have two options. + +--- + +## Option 1 (recommended): Wrap the CLI + +Build a thin wrapper that registers extra symbols, then invokes the +makemigrations cobra root. This produces a single binary that behaves +exactly like `makemigrations` but with the extra packages available +inside interpreted migrations. + +### 1. Generate a symbol map for your package + +```bash +go install github.com/traefik/yaegi/cmd/yaegi@latest + +mkdir -p internal/yaegi +cd internal/yaegi +yaegi extract -name yaegi github.com/google/uuid +# writes github_com-google-uuid.go +``` + +### 2. Declare the `Symbols` map and merge into makemigrations + +```go +// internal/yaegi/yaegi.go +package yaegi + +import ( + "reflect" + + mmsymbols "github.com/ocomsoft/makemigrations/migrate/symbols" +) + +// Symbols is the map populated by the auto-generated init() in +// github_com-google-uuid.go and any other `yaegi extract` files +// you place in this package. +var Symbols = map[string]map[string]reflect.Value{} + +func init() { + // Forward everything we extracted into the makemigrations symbol map + // so the CLI's interpreter can resolve our imports. + mmsymbols.Register(Symbols) +} +``` + +### 3. Build a wrapper main + +```go +// cmd/mymigrate/main.go +package main + +import ( + "github.com/ocomsoft/makemigrations/cmd" + + _ "myproject/internal/yaegi" // registers extra symbols via init() +) + +func main() { + cmd.Execute() +} +``` + +```bash +go build -o mymigrate ./cmd/mymigrate +./mymigrate migrate up +``` + +`mymigrate` is a drop-in replacement for `makemigrations`: same subcommands, +same flags, same behaviour — but interpreted migrations can now import any +package whose symbols you registered. + +### Adding more packages later + +Just `yaegi extract ` into `internal/yaegi/` and rebuild. Each generated +file's `init()` adds entries to `Symbols`, which is forwarded into the +makemigrations symbol map at startup. + +> **Tip:** keep `yaegi extract` pinned in a `go:generate` directive next to +> the package import, e.g. `//go:generate yaegi extract github.com/google/uuid`, +> so re-running `go generate ./...` regenerates symbol files cleanly. + +--- + +## Option 2: Compile the migrations directory standalone + +The generated `migrations/` directory is still a real Go module. If you +prefer to skip the wrapper entirely, build it directly: + +```bash +cd migrations && go mod tidy && go build -o migrate . +./migrate up +``` + +The standalone binary uses the Go compiler, so any `import` resolves +normally — no symbol map required. You give up the no-toolchain-at-runtime +property of `makemigrations migrate`, but for projects with heavy +hand-written migration logic this is sometimes simpler. + +--- + +## How the symbol map works (background) + +Inside `migrate/symbols/migrate.go` (auto-generated by +`yaegi extract github.com/ocomsoft/makemigrations/migrate`): + +```go +func init() { + Symbols["github.com/ocomsoft/makemigrations/migrate/migrate"] = map[string]reflect.Value{ + "Register": reflect.ValueOf(migrate.Register), + "Migration": reflect.ValueOf((*migrate.Migration)(nil)), + "CreateTable": reflect.ValueOf((*migrate.CreateTable)(nil)), + // ... one entry per exported identifier + } +} +``` + +The map key is `/`. When yaegi parses +`import m "github.com/ocomsoft/makemigrations/migrate"` it looks up +`github.com/ocomsoft/makemigrations/migrate/migrate` in the map; the inner +map gives it the live host values to bind `m.Register`, `m.Migration{}`, +etc. to. + +When you call `mmsymbols.Register(extra)` you're merging another +`map[string]map[string]reflect.Value` (typically produced by +`yaegi extract`) into that lookup table, so additional imports become +resolvable. + +--- + +## Limitations + +- **Generics**: yaegi has historically had rough edges with generic types. + If a third-party package you extract uses generics, test it before + shipping; consider using Option 2 if interpretation fails. +- **C bindings (cgo)**: `yaegi extract` extracts the Go-visible API, but + any value whose underlying implementation depends on `unsafe` or `cgo` + can behave differently under interpretation than compilation. Drivers + like `mattn/go-sqlite3` are loaded into the host CLI process directly + (see `cmd/drivers.go`) rather than via yaegi for this reason. +- **`internal/` packages**: `yaegi extract` cannot extract symbols from + internal packages of modules you don't own. Stick to public APIs. diff --git a/go.mod b/go.mod index 2e60609..ee4a00c 100644 --- a/go.mod +++ b/go.mod @@ -1,34 +1,42 @@ module github.com/ocomsoft/makemigrations -go 1.24 +go 1.25.7 require ( github.com/fatih/color v1.18.0 + github.com/go-sql-driver/mysql v1.10.0 github.com/lib/pq v1.10.9 github.com/mattn/go-sqlite3 v1.14.32 + github.com/microsoft/go-mssqldb v1.10.0 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 github.com/traefik/yaegi v0.16.1 - golang.org/x/mod v0.27.0 + golang.org/x/mod v0.34.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + filippo.io/edwards25519 v1.2.0 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.25.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index 9eec09c..659229b 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,17 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -8,16 +22,28 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -27,8 +53,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY= +github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -38,6 +68,8 @@ github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDj github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= @@ -52,22 +84,26 @@ github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E= github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 871db4733f78520259424281f2152f7db9ac3298 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 04:41:01 +0000 Subject: [PATCH 3/3] docs: explain main.go is optional in the generated migrations template makemigrations migrate runs migration files in-process via yaegi and never invokes main(). The generated main.go remains as an optional escape hatch for users who want to `go build` the migrations directory into a standalone binary. The new file-level comment makes that explicit so users know the file is safe to delete (without losing IDE type-checking, which goes through go.mod). https://claude.ai/code/session_014Wn4chfaL2aTyjxMXeNBmm --- internal/codegen/go_generator.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/internal/codegen/go_generator.go b/internal/codegen/go_generator.go index 4ad6d30..d1f1ac8 100644 --- a/internal/codegen/go_generator.go +++ b/internal/codegen/go_generator.go @@ -519,11 +519,20 @@ func generateIndexLiteral(idx yaml.Index) string { return fmt.Sprintf("m.Index{%s}", strings.Join(parts, ", ")) } -// GenerateMainGo returns the source for a migrations/main.go file that serves as -// the entry point for running migrations. It references m.NewApp and m.Config which -// are provided by the migrate package (implemented in Task 6). +// GenerateMainGo returns the source for a migrations/main.go file. The file +// is **optional at runtime** — `makemigrations migrate` interprets the +// migration .go files in-process via yaegi and never invokes main(). It is +// generated so users can still `go build` the migrations directory into a +// standalone binary if they want one. func (g *GoGenerator) GenerateMainGo() string { - return `package main + return `// Optional standalone-binary entry point for the migrations module. +// +// makemigrations migrate runs the migration files in-process via yaegi and +// does NOT invoke this main(). Keep this file if you want to `+"`go build`"+` the +// migrations directory into a self-contained binary as a fallback (or to +// ship in a release artifact). Otherwise it is safe to delete — the +// migrations directory will still type-check in your IDE via go.mod. +package main import ( "fmt"