Repository: pressly/goose Branch: main Commit: e7bd535b62f2 Files: 268 Total size: 642.6 KB Directory structure: gitextract_en9629pt/ ├── .github/ │ ├── dependabot.yaml │ └── workflows/ │ ├── ci.yaml │ ├── integration.yaml │ ├── lint.yaml │ └── release.yaml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yaml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── cmd/ │ └── goose/ │ ├── driver_clickhouse.go │ ├── driver_mssql.go │ ├── driver_mysql.go │ ├── driver_no_mysql.go │ ├── driver_postgres.go │ ├── driver_sqlite3.go │ ├── driver_turso.go │ ├── driver_vertica.go │ ├── driver_ydb.go │ ├── main.go │ └── main_test.go ├── create.go ├── create_test.go ├── database/ │ ├── dialect/ │ │ ├── querier.go │ │ └── querier_extended.go │ ├── dialects.go │ ├── doc.go │ ├── sql_extended.go │ ├── store.go │ ├── store_extended.go │ └── store_test.go ├── db.go ├── dialect.go ├── down.go ├── examples/ │ ├── README.md │ ├── go-migrations/ │ │ ├── 00001_create_users_table.sql │ │ ├── 00002_rename_root.go │ │ ├── 00003_add_user_no_tx.go │ │ ├── README.md │ │ └── main.go │ └── sql-migrations/ │ ├── 00001_create_users_table.sql │ ├── 00002_rename_root.sql │ ├── 00003_no_transaction.sql │ └── README.md ├── fix.go ├── fix_test.go ├── globals.go ├── globals_test.go ├── go.mod ├── go.sum ├── goose.go ├── goose_cli_test.go ├── goose_embed_test.go ├── helpers.go ├── helpers_test.go ├── install.sh ├── internal/ │ ├── controller/ │ │ └── store.go │ ├── dialects/ │ │ ├── clickhouse.go │ │ ├── dsql.go │ │ ├── mysql.go │ │ ├── postgres.go │ │ ├── redshift.go │ │ ├── spanner.go │ │ ├── sqlite3.go │ │ ├── sqlserver.go │ │ ├── starrocks.go │ │ ├── tidb.go │ │ ├── turso.go │ │ ├── vertica.go │ │ └── ydb.go │ ├── gooseutil/ │ │ ├── resolve.go │ │ └── resolve_test.go │ ├── legacystore/ │ │ └── legacystore.go │ ├── migrationstats/ │ │ ├── migration_go.go │ │ ├── migration_sql.go │ │ ├── migrationstats.go │ │ ├── migrationstats_test.go │ │ └── migrationstats_walker.go │ ├── sqlparser/ │ │ ├── parse.go │ │ ├── parse_test.go │ │ ├── parser.go │ │ ├── parser_test.go │ │ └── testdata/ │ │ ├── envsub/ │ │ │ ├── test01/ │ │ │ │ ├── 01.down.golden.sql │ │ │ │ ├── 01.up.golden.sql │ │ │ │ ├── 02.up.golden.sql │ │ │ │ ├── 03.up.golden.sql │ │ │ │ ├── 04.up.golden.sql │ │ │ │ └── input.sql │ │ │ ├── test02/ │ │ │ │ ├── 01.up.golden.sql │ │ │ │ ├── 02.up.golden.sql │ │ │ │ ├── 03.up.golden.sql │ │ │ │ └── input.sql │ │ │ └── test03/ │ │ │ ├── 01.up.golden.sql │ │ │ └── input.sql │ │ ├── invalid/ │ │ │ └── up/ │ │ │ ├── a.sql │ │ │ ├── b.sql │ │ │ ├── c.sql │ │ │ └── d.sql │ │ ├── valid-txn/ │ │ │ ├── 00001_create_users_table.sql │ │ │ ├── 00002_rename_root.sql │ │ │ └── 00003_no_transaction.sql │ │ └── valid-up/ │ │ ├── test01/ │ │ │ ├── 01.up.golden.sql │ │ │ ├── 02.up.golden.sql │ │ │ ├── 03.up.golden.sql │ │ │ └── input.sql │ │ ├── test02/ │ │ │ ├── 01.up.golden.sql │ │ │ └── input.sql │ │ ├── test03/ │ │ │ ├── 01.up.golden.sql │ │ │ └── input.sql │ │ ├── test04/ │ │ │ ├── 01.up.golden.sql │ │ │ ├── 02.up.golden.sql │ │ │ ├── 03.up.golden.sql │ │ │ └── input.sql │ │ ├── test05/ │ │ │ ├── 01.up.golden.sql │ │ │ ├── 02.up.golden.sql │ │ │ └── input.sql │ │ ├── test06/ │ │ │ ├── 01.up.golden.sql │ │ │ ├── 02.up.golden.sql │ │ │ ├── 03.up.golden.sql │ │ │ ├── 04.up.golden.sql │ │ │ ├── 05.up.golden.sql │ │ │ └── input.sql │ │ ├── test07/ │ │ │ ├── 01.up.golden.sql │ │ │ └── input.sql │ │ ├── test08/ │ │ │ ├── 01.up.golden.sql │ │ │ ├── 02.up.golden.sql │ │ │ ├── 03.up.golden.sql │ │ │ ├── 04.up.golden.sql │ │ │ ├── 05.up.golden.sql │ │ │ ├── 06.up.golden.sql │ │ │ └── input.sql │ │ └── test09/ │ │ ├── 01.up.golden.sql │ │ └── input.sql │ └── testing/ │ ├── go.mod │ ├── go.sum │ ├── integration/ │ │ ├── README.md │ │ ├── database_test.go │ │ ├── integration.go │ │ ├── locking/ │ │ │ ├── postgres_locking_test.go │ │ │ └── postgres_table_locking_test.go │ │ └── testdata/ │ │ └── migrations/ │ │ ├── clickhouse/ │ │ │ ├── 00001_a.sql │ │ │ ├── 00002_b.sql │ │ │ └── 00003_c.sql │ │ ├── clickhouse-remote/ │ │ │ └── 00001_a.sql │ │ ├── clickhouse-remote-backup/ │ │ │ └── taxi_zone_lookup.csv │ │ ├── mysql/ │ │ │ ├── 00001_table.sql │ │ │ ├── 00002_insert.sql │ │ │ ├── 00003_alter.sql │ │ │ ├── 00004_empty.sql │ │ │ ├── 00005_no_tx.sql │ │ │ └── 00006_complex.sql │ │ ├── postgres/ │ │ │ ├── 00001_table.sql │ │ │ ├── 00002_insert.sql │ │ │ ├── 00003_alter.sql │ │ │ ├── 00004_empty.sql │ │ │ ├── 00005_no_tx.sql │ │ │ └── 00006_complex.sql │ │ ├── spanner/ │ │ │ ├── 00001_table.sql │ │ │ ├── 00002_insert.sql │ │ │ ├── 00003_alter.sql │ │ │ ├── 00004_empty.sql │ │ │ ├── 00005_no_tx.sql │ │ │ └── 00006_view.sql │ │ ├── starrocks/ │ │ │ ├── 00001_a.sql │ │ │ ├── 00002_b.sql │ │ │ └── 00003_c.sql │ │ ├── turso/ │ │ │ ├── 00001_table.sql │ │ │ ├── 00002_insert.sql │ │ │ ├── 00003_alter.sql │ │ │ ├── 00004_empty.sql │ │ │ └── 00005_no_tx.sql │ │ └── ydb/ │ │ ├── 00001_a.sql │ │ ├── 00002_b.sql │ │ ├── 00003_c.sql │ │ ├── 00004_d.sql │ │ ├── 00005_e.sql │ │ ├── 00006_f.sql │ │ ├── 00007_g.sql │ │ └── 00008_h.sql │ └── testdb/ │ ├── clickhouse.go │ ├── container_healthcheck.go │ ├── mariadb.go │ ├── options.go │ ├── postgres.go │ ├── spanner.go │ ├── starrocks.go │ ├── testdb.go │ ├── turso.go │ └── ydb.go ├── lock/ │ ├── internal/ │ │ ├── store/ │ │ │ ├── postgres.go │ │ │ └── store.go │ │ └── table/ │ │ ├── config.go │ │ └── locker.go │ ├── locker.go │ ├── locktesting/ │ │ └── locktesting.go │ ├── postgres.go │ ├── session_locker_options.go │ ├── table_locker_options.go │ └── table_locker_options_test.go ├── log.go ├── migrate.go ├── migrate_test.go ├── migration.go ├── migration_sql.go ├── osfs.go ├── pkg/ │ └── dockermanage/ │ ├── doc.go │ ├── dockerpostgres/ │ │ ├── postgres.go │ │ └── postgres_test.go │ ├── manager.go │ └── options.go ├── provider.go ├── provider_collect.go ├── provider_collect_test.go ├── provider_errors.go ├── provider_options.go ├── provider_options_test.go ├── provider_run.go ├── provider_run_test.go ├── provider_test.go ├── provider_types.go ├── redo.go ├── register.go ├── reset.go ├── scripts/ │ └── release-notes.sh ├── status.go ├── testdata/ │ ├── migrations/ │ │ ├── 00001_users_table.sql │ │ ├── 00002_posts_table.sql │ │ ├── 00003_comments_table.sql │ │ ├── 00004_insert_data.sql │ │ └── 00005_posts_view.sql │ ├── no-versioning/ │ │ ├── migrations/ │ │ │ ├── 00001_a.sql │ │ │ ├── 00002_b.sql │ │ │ └── 00003_c.sql │ │ └── seed/ │ │ ├── 00001_a.sql │ │ └── 00002_b.sql │ └── testdata.go ├── tests/ │ └── gomigrations/ │ ├── error/ │ │ ├── gomigrations_error_test.go │ │ └── testdata/ │ │ ├── 001_up_no_tx.go │ │ ├── 002_ERROR_insert_no_tx.go │ │ ├── 003_truncate.go │ │ └── 004_ERROR_insert.go │ ├── register/ │ │ ├── register_test.go │ │ └── testdata/ │ │ ├── 001_addmigration.go │ │ ├── 002_addmigrationnotx.go │ │ ├── 003_addmigrationcontext.go │ │ └── 004_addmigrationnotxcontext.go │ └── success/ │ ├── gomigrations_success_test.go │ └── testdata/ │ ├── 001_up_down.go │ ├── 002_up_only.go │ ├── 003_down_only.go │ ├── 004_empty.go │ ├── 005_up_down_no_tx.go │ ├── 006_up_only_no_tx.go │ ├── 007_down_only_no_tx.go │ ├── 008_empty_no_tx.go │ ├── 009_up_down_ctx.go │ ├── 010_up_only_ctx.go │ ├── 011_down_only_ctx.go │ ├── 012_empty_ctx.go │ ├── 013_up_down_no_tx_ctx.go │ ├── 014_up_only_no_tx_ctx.go │ ├── 015_down_only_no_tx_ctx.go │ └── 016_empty_no_tx_ctx.go ├── up.go ├── up_test.go └── version.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/dependabot.yaml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" day: "saturday" assignees: - "mfridman" - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" day: "saturday" groups: gomod: patterns: - "*" assignees: - "mfridman" ignore: - dependency-name: "*" update-types: ["version-update:semver-patch"] ================================================ FILE: .github/workflows/ci.yaml ================================================ name: Goose CI on: push: branches: - main pull_request: workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: test: name: Run unit tests timeout-minutes: 10 strategy: matrix: go-version: [oldstable, stable] os: [ubuntu-latest] runs-on: ${{ matrix.os }} steps: - name: Checkout code uses: actions/checkout@v6 - name: Install Go uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} - name: Check Go code formatting run: | if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then gofmt -s -l . echo "Please format Go code by running: go fmt ./..." exit 1 fi - name: Install tparse run: | mkdir -p $HOME/.local/bin curl -L -o $HOME/.local/bin/tparse https://github.com/mfridman/tparse/releases/latest/download/tparse_linux_x86_64 chmod +x $HOME/.local/bin/tparse echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Run tests run: | make add-gowork mkdir -p bin go vet ./... go build ./... make test-packages - name: Install GoReleaser if: github.event_name == 'push' && github.ref == 'refs/heads/main' && matrix.go-version == 'stable' uses: goreleaser/goreleaser-action@v7 with: install-only: true distribution: goreleaser version: "~> v2" - name: Gorelease dry-run if: github.event_name == 'push' && github.ref == 'refs/heads/main' && matrix.go-version == 'stable' run: | goreleaser release --skip=publish --snapshot --fail-fast --clean ================================================ FILE: .github/workflows/integration.yaml ================================================ name: Goose integration tests on: push: branches: - main pull_request: workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: test: name: Run integration tests timeout-minutes: 10 runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Install Go uses: actions/setup-go@v6 with: go-version: "stable" - name: Install tparse run: | mkdir -p $HOME/.local/bin curl -L -o $HOME/.local/bin/tparse https://github.com/mfridman/tparse/releases/latest/download/tparse_linux_x86_64 chmod +x $HOME/.local/bin/tparse echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Run full integration tests run: | make test-integration ================================================ FILE: .github/workflows/lint.yaml ================================================ name: golangci on: push: branches: - main pull_request: workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: golangci: name: lint runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version: "stable" - run: make add-gowork - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version version: latest github-token: ${{ secrets.GITHUB_TOKEN }} args: --timeout=2m --verbose # Optional: working directory, useful for monorepos # working-directory: somedir # Optional: golangci-lint command line arguments. # args: --issues-exit-code=0 # Optional: show only new issues if it's a pull request. The default value is `false`. # only-new-issues: true # Optional: if set to true then the all caching functionality will be complete disabled, # takes precedence over all other caching options. # skip-cache: true # Optional: if set to true then the action don't cache or restore ~/go/pkg. # skip-pkg-cache: true # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. # skip-build-cache: true ================================================ FILE: .github/workflows/release.yaml ================================================ name: goreleaser on: push: tags: - '*' permissions: contents: write jobs: goreleaser: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - run: git fetch --force --tags - uses: actions/setup-go@v6 with: go-version: stable - name: Generate release notes continue-on-error: true run: ./scripts/release-notes.sh ${{github.ref_name}} > ${{runner.temp}}/release_notes.txt - run: make add-gowork - name: Run GoReleaser uses: goreleaser/goreleaser-action@v7 with: distribution: goreleaser version: "~> v2" args: release --clean --release-notes=${{runner.temp}}/release_notes.txt env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ .idea .vscode .DS_Store *.swp *.test # Files output by tests /bin # Coverage files coverage.out coverage.html # Local testing .envrc *.FAIL dist/ release_notes.txt go.work go.work.sum ================================================ FILE: .golangci.yaml ================================================ version: "2" linters: default: none enable: - errcheck - govet - ineffassign - misspell - staticcheck - testifylint - unused exclusions: generated: lax presets: - comments - common-false-positives - legacy - std-error-handling paths: - third_party$ - builtin$ - examples$ formatters: enable: - gofmt exclusions: generated: lax paths: - third_party$ - builtin$ - examples$ ================================================ FILE: .goreleaser.yaml ================================================ # yaml-language-server: $schema=https://goreleaser.com/static/schema.json # # See https://goreleaser.com/customization/ for more information. version: 2 project_name: goose before: hooks: - go mod tidy builds: - env: - CGO_ENABLED=0 binary: goose main: ./cmd/goose goos: - linux - windows - darwin goarch: - amd64 - arm64 ldflags: # The v prefix is stripped by goreleaser, so we need to add it back. # https://goreleaser.com/customization/templates/#fnref:version-prefix - "-s -w -X main.version=v{{ .Version }}" archives: - formats: - binary name_template: >- {{ .ProjectName }}_{{- tolower .Os }}_{{- if eq .Arch "amd64" }}x86_64{{- else }}{{ .Arch }}{{ end }} checksum: name_template: "checksums.txt" snapshot: version_template: "{{ incpatch .Version }}-next" changelog: use: github-native ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [v3.27.0] - 2026-02-22 ### Added - Preliminary Spanner dialect support (#966) ### Changed - **Minimum Go version is now 1.25** - SQL migration templates no longer include `StatementBegin` and `StatementEnd` annotations. These are only needed for complex statements containing semicolons (e.g., stored procedures). See [docs](https://pressly.github.io/goose/documentation/annotations/#complex-statements) for details. - Various dependency upgrades ## [v3.26.0] - 2025-10-03 - Add `*slog.Logger` support to goose provider via option `WithSlog` (#989) - Add convenience `WithTableName` provider option (#985) - Minor bug fixes and dependency upgrades - Add general purpose `Locker` interface to support DB locking with a table-based Postgres implementation via `lock.NewPostgresTableLocker` (#993 for more details) - Unlike `SessionLocker`, this uses the `*sql.DB` connection pool - Add `WithLocker` option to goose provider ## [v3.25.0] - 2025-08-24 - Upgrade go deps (#976) - Remove references/tests for vertica and add deprecation warnings (#978) - Add Aurora DSQL as a new database dialect to goose `Provider` (#971) - Add DDL isolation support for Aurora DSQL compatibility (#970) - Update Apply to respect no versioning option (#950) - Expose dialect `Querier` (#939) ## [v3.24.3] - Add `GOOSE_TABLE` environment variable -- lower priority than `-table` flag, but higher than the default table name. (#932) - Dependency updates ## [v3.24.2] - Add `TableExists` table existence check for the mysql dialect (#895) - Upgrade **minimum Go version to 1.23** - Various dependency updates ## [v3.24.1] - Fix regression (`v3.23.1` and `v3.24.0`) in postgres migration table existence check for non-default schema. (#882, #883, #884). ## [v3.24.0] - Add support for loading environment variables from `.env` files, enabled by default. - The default file name is `.env`, but can be changed with the `-env=` flag. - To disable this feature, set `-env=none`. ## [v3.23.1] - Store implementations can **optionally** implement the `TableExists` method to provide optimized table existence checks (#860) - Default postgres Store implementation updated to use `pg_tables` system catalog, more to follow - Backward compatible change - existing implementations will continue to work without modification ```go TableExists(ctx context.Context, db database.DBTxConn) (bool, error) ``` ## [v3.23.0] - Add `WithLogger` to `NewProvider` to allow custom loggers (#833) - Update Provider `WithVerbose` behavior to log all SQL statements (#851) - Upgrade dependencies and rebuild binaries with latest Go version (`go1.23.3`) ## [v3.22.1] - Upgrade dependencies and rebuild binaries with latest Go version (`go1.23.1`) ## [v3.22.0] - Minimum Go version is now 1.21 - Add Unwrap to PartialError (#815) - Allow flags anywhere on the CLI (#814) `goose` uses the default Go `flag` parsing library, which means flags **must** be defined before the first positional argument. We've updated this behavior to allow flags to be defined anywhere. For more details, see [blog post](https://mfridman.com/blog/2024/allowing-flags-anywhere-on-the-cli/). - Update `WithDisableGlobalRegistry` behavior (#783). When set, this will ignore globally-registered migrationse entirely instead of the previous behavior of raising an error. Specifically, the following check is removed: ```go if len(global) > 0 { return nil, errors.New("global registry disabled, but provider has registered go migrations") } ``` This enables creating isolated goose provider(s) in legacy environments where global migrations may be registered. Without updating this behavior, it would be impossible to use `WithDisableGlobalRegistry` in combination with provider-scoped `WithGoMigrations`. - Postgres, updated schema to use identity instead of serial and make `tstamp` not nullable (#556) ```diff - id serial NOT NULL, + id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, - tstamp timestamp NULL default now(), + tstamp timestamp NOT NULL DEFAULT now() ``` - MySQL, updated schema to not use SERIAL alias (#816) ```diff - id serial NOT NULL, + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, ``` ## [v3.21.1] - Add `GetVersions` method to `goose.Provider`, returns the current (max db) version and the latest (max filesystem) version. (#756) - Clarify `GetLatestVersion` method MUST return `ErrVersionNotFound` if no latest migration is found. Previously it was returning a -1 and nil error, which was inconsistent with the rest of the API surface. - Add `GetLatestVersion` implementations to all existing dialects. This is an optimization to avoid loading all migrations when only the latest version is needed. This uses the `max` function in SQL to get the latest version_id irrespective of the order of applied migrations. - Refactor existing portions of the code to use the new `GetLatestVersion` method. ## [v3.21.0] - Retracted. Broken release, please use v3.21.1 instead. ## [v3.20.0] - Expand the `Store` interface by adding a `GetLatestVersion` method and make the interface public. - Add a (non-blocking) method to check if there are pending migrations to the `goose.Provider` (#751): ```go func (p *Provider) HasPending(context.Context) (bool, error) {} ``` The underlying implementation **does not respect the `SessionLocker`** (if one is enabled) and can be used to check for pending migrations without blocking or being blocked by other operations. - The methods `.Up`, `.UpByOne`, and `.UpTo` from `goose.Provider` will invoke `.HasPending` before acquiring a lock with `SessionLocker` (if enabled). This addresses an edge case in Kubernetes-style deployments where newer pods with long-running migrations prevent older pods - which have all known migrations applied - from starting up due to an advisory lock. For more details, refer to https://github.com/pressly/goose/pull/507#discussion_r1266498077 and #751. - Move integration tests to `./internal/testing` and make it a separate Go module. This will allow us to have a cleaner top-level go.mod file and avoid imports unrelated to the goose project. See [integration/README.md](https://github.com/pressly/goose/blob/d0641b5bfb3bd5d38d95fe7a63d7ddf2d282234d/internal/testing/integration/README.md) for more details. This shouldn't affect users of the goose library. ## [v3.19.2] - 2024-03-13 - Remove duckdb support. The driver uses Cgo and we've decided to remove it until we can find a better solution. If you were using duckdb with goose, please let us know by opening an issue. ## [v3.19.1] - 2024-03-11 - Fix selecting dialect for `redshift` - Add `GOOSE_MIGRATION_DIR` documentation - Bump github.com/opencontainers/runc to `v1.1.12` (security fix) - Update CI tests for go1.22 - Make goose annotations case-insensitive - All `-- +goose` annotations are now case-insensitive. This means that `-- +goose Up` and `-- +goose up` are now equivalent. This change was made to improve the user experience and to make the annotations more consistent. ## [v3.19.0] - 2024-03-11 - Use [v3.19.1] instead. This was tagged but not released and does not contain release binaries. ## [v3.18.0] - 2024-01-31 - Add environment variable substitution for SQL migrations. (#604) - This feature is **disabled by default**, and can be enabled by adding an annotation to the migration file: ```sql -- +goose ENVSUB ON ``` - When enabled, goose will attempt to substitute environment variables in the SQL migration queries until the end of the file, or until the annotation `-- +goose ENVSUB OFF` is found. For example, if the environment variable `REGION` is set to `us_east_1`, the following SQL migration will be substituted to `SELECT * FROM regions WHERE name = 'us_east_1';` ```sql -- +goose ENVSUB ON -- +goose Up SELECT * FROM regions WHERE name = '${REGION}'; ``` - Add native [Turso](https://turso.tech/) support with libsql driver. (#658) - Fixed query for list migrations in YDB (#684) ## [v3.17.0] - 2023-12-15 - Standardised the MIT license (#647) - Improve provider `Apply()` errors, add `ErrNotApplied` when attempting to rollback a migration that has not been previously applied. (#660) - Add `WithDisableGlobalRegistry` option to `NewProvider` to disable the global registry. (#645) - Add `-timeout` flag to CLI to set the maximum allowed duration for queries to run. Default remains no timeout. (#627) - Add optional logging in `Provider` when `WithVerbose` option is supplied. (#668) ⚠️ Potential Breaking Change ⚠️ - Update `goose create` to use UTC time instead of local time. (#242) ## [v3.16.0] - 2023-11-12 - Added YDB support. (#592) - Fix sqlserver query to ensure DB version. (#601) - Allow setting / resetting the global Go migration registry. (#602) - `SetGlobalMigrations` and `ResetGlobalMigrations` functions have been added. - Introduce `NewGoMigration` for constructing Go migrations. - Add initial implementation of `goose.NewProvider`. 🎉 Read more about this new feature here: https://pressly.github.io/goose/blog/2023/goose-provider/ The motivation behind the Provider was simple - to reduce global state and make goose easier to consume as an imported package. Here's a quick summary: - Avoid global state - Make Provider safe to use concurrently - Unlock (no pun intended) new features, such as database locking - Make logging configurable - Better error handling with proper return values - Double down on Go migrations - ... and more! ## [v3.15.1] - 2023-10-10 - Fix regression that prevented registering Go migrations that didn't have the corresponding files available in the filesystem. (#588) - If Go migrations have been registered globally, but there are no .go files in the filesystem, **always include** them. - If Go migrations have been registered, and there are .go files in the filesystem, **only include** those migrations. This was the original motivation behind #553. - If there are .go files in the filesystem but not registered, **raise an error**. This is to prevent accidentally adding valid looking Go migration files without explicitly registering them. ## [v3.15.0] - 2023-08-12 - Fix `sqlparser` to avoid skipping the last statement when it's not terminated with a semicolon within a StatementBegin/End block. (#580) - Add `**go1.21**` to the CI matrix. - Bump minimum version of module in go.mod to `go1.19`. - Fix version output when installing pre-built binaries (#585). ## [v3.14.0] - 2023-07-26 - Filter registered Go migrations from the global map with corresponding .go files from the filesystem. - The code previously assumed all .go migrations would be in the same folder, so this should not be a breaking change. - See #553 for more details - Improve output log message for applied up migrations. #562 - Fix an issue where `AddMigrationNoTxContext` was registering the wrong source because it skipped too many frames. #572 - Improve binary version output when using go install. ## [v3.13.4] - 2023-07-07 - Fix pre-built binary versioning and make small improvements to GoReleaser config. - Fix an edge case in the `sqlparser` where the last up statement may be ignored if it's unterminated with a semicolon and followed by a `-- +goose Down` annotation. - Trim `Logger` interface to `Printf` and `Fatalf` methods only. Projects that have previously implemented the `Logger` interface should not be affected, and can remove unused methods. ## [v3.13.1] - 2023-07-03 - Add pre-built binaries with GoReleaser and update the build process. ## [v3.13.0] - 2023-06-29 - Add a changelog to the project, based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Update go.mod and retract all `v3.12.X` tags. They were accidentally pushed and contain a reference to the wrong Go module. - Fix `up` and `up -allowing-missing` behavior. - Fix empty version in log output. - Add new `context.Context`-aware functions and methods, for both sql and go migrations. - Return error when no migration files found or dir is not a directory. [Unreleased]: https://github.com/pressly/goose/compare/v3.27.0...HEAD [v3.27.0]: https://github.com/pressly/goose/compare/v3.26.0...v3.27.0 [v3.26.0]: https://github.com/pressly/goose/compare/v3.25.0...v3.26.0 [v3.25.0]: https://github.com/pressly/goose/compare/v3.24.3...v3.25.0 [v3.24.3]: https://github.com/pressly/goose/compare/v3.24.2...v3.24.3 [v3.24.2]: https://github.com/pressly/goose/compare/v3.24.1...v3.24.2 [v3.24.1]: https://github.com/pressly/goose/compare/v3.24.0...v3.24.1 [v3.24.0]: https://github.com/pressly/goose/compare/v3.23.1...v3.24.0 [v3.23.1]: https://github.com/pressly/goose/compare/v3.23.0...v3.23.1 [v3.23.0]: https://github.com/pressly/goose/compare/v3.22.1...v3.23.0 [v3.22.1]: https://github.com/pressly/goose/compare/v3.22.0...v3.22.1 [v3.22.0]: https://github.com/pressly/goose/compare/v3.21.1...v3.22.0 [v3.21.1]: https://github.com/pressly/goose/compare/v3.20.0...v3.21.1 [v3.21.0]: https://github.com/pressly/goose/compare/v3.20.0...v3.21.0 [v3.20.0]: https://github.com/pressly/goose/compare/v3.19.2...v3.20.0 [v3.19.2]: https://github.com/pressly/goose/compare/v3.19.1...v3.19.2 [v3.19.1]: https://github.com/pressly/goose/compare/v3.19.0...v3.19.1 [v3.19.0]: https://github.com/pressly/goose/compare/v3.18.0...v3.19.0 [v3.18.0]: https://github.com/pressly/goose/compare/v3.17.0...v3.18.0 [v3.17.0]: https://github.com/pressly/goose/compare/v3.16.0...v3.17.0 [v3.16.0]: https://github.com/pressly/goose/compare/v3.15.1...v3.16.0 [v3.15.1]: https://github.com/pressly/goose/compare/v3.15.0...v3.15.1 [v3.15.0]: https://github.com/pressly/goose/compare/v3.14.0...v3.15.0 [v3.14.0]: https://github.com/pressly/goose/compare/v3.13.4...v3.14.0 [v3.13.4]: https://github.com/pressly/goose/compare/v3.13.1...v3.13.4 [v3.13.1]: https://github.com/pressly/goose/compare/v3.13.0...v3.13.1 [v3.13.0]: https://github.com/pressly/goose/releases/tag/v3.13.0 ================================================ FILE: LICENSE ================================================ MIT License Original work Copyright (c) 2012 Liam Staskawicz Modified work Copyright (c) 2016 Vojtech Vitek Modified work Copyright (c) 2021 Michael Fridman, Vojtech Vitek 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. ================================================ FILE: Makefile ================================================ GO_TEST_FLAGS ?= -race -count=1 -v -timeout=5m -json # These are the default values for the test database. They can be overridden DB_USER ?= dbuser DB_PASSWORD ?= password1 DB_NAME ?= testdb DB_POSTGRES_PORT ?= 5433 DB_MYSQL_PORT ?= 3307 DB_CLICKHOUSE_PORT ?= 9001 DB_YDB_PORT ?= 2136 DB_TURSO_PORT ?= 8080 DB_STARROCKS_PORT ?= 9030 list-build-tags: @echo "Available build tags:" @echo "$$(rg -o --trim 'no_[a-zA-Z0-9_]+' ./cmd/goose \ --no-line-number --no-filename | sort | uniq | \ xargs -n 4 | column -t | sed 's/^/ /')" .PHONY: dist dist: @mkdir -p ./bin @rm -f ./bin/* GOOS=darwin GOARCH=amd64 go build -o ./bin/goose-darwin64 ./cmd/goose GOOS=linux GOARCH=amd64 go build -o ./bin/goose-linux64 ./cmd/goose GOOS=linux GOARCH=386 go build -o ./bin/goose-linux386 ./cmd/goose GOOS=windows GOARCH=amd64 go build -o ./bin/goose-windows64.exe ./cmd/goose GOOS=windows GOARCH=386 go build -o ./bin/goose-windows386.exe ./cmd/goose .PHONY: clean clean: @find . -type f -name '*.FAIL' -delete .PHONY: lint lint: tools @golangci-lint run ./... --fix .PHONY: tools tools: @go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest @go install github.com/mfridman/tparse@main test-packages: go test $(GO_TEST_FLAGS) $$(go list ./... | grep -v -e /bin -e /cmd -e /examples) |\ tparse --follow -sort=elapsed -trimpath=auto -all test-packages-short: go test -test.short $(GO_TEST_FLAGS) $$(go list ./... | grep -v -e /bin -e /cmd -e /examples) |\ tparse --follow -sort=elapsed coverage-short: go test ./ -test.short $(GO_TEST_FLAGS) -cover -coverprofile=coverage.out | tparse --follow -sort=elapsed go tool cover -html=coverage.out coverage: go test ./ $(GO_TEST_FLAGS) -cover -coverprofile=coverage.out | tparse --follow -sort=elapsed go tool cover -html=coverage.out test-lock-coverage: go test ./internal/testing/integration/locking ./lock/internal/... -cover -coverpkg=./lock/internal/... -coverprofile=coverage.out go tool cover -html=coverage.out -o coverage.html @echo "Lock package coverage: $$(go tool cover -func=coverage.out | tail -1 | awk '{print $$3}')" open coverage.html # # Integration-related targets # add-gowork: @[ -f go.work ] || go work init @[ -f go.work.sum ] || go work use -r . remove-gowork: rm -rf go.work go.work.sum upgrade-integration-deps: cd ./internal/testing && go get -u ./... && go mod tidy test-postgres-long: add-gowork test-postgres go test $(GO_TEST_FLAGS) ./internal/testing/integration -run='(TestPostgresProviderLocking|TestPostgresSessionLocker)' |\ tparse --follow -sort=elapsed test-postgres: add-gowork go test $(GO_TEST_FLAGS) ./internal/testing/integration -run="^TestPostgres$$" | tparse --follow -sort=elapsed test-spanner: add-gowork go test $(GO_TEST_FLAGS) ./internal/testing/integration -run='TestSpanner' | tparse --follow -sort=elapsed test-clickhouse: add-gowork go test $(GO_TEST_FLAGS) ./internal/testing/integration -run='(TestClickhouse|TestClickhouseRemote)' |\ tparse --follow -sort=elapsed test-mysql: add-gowork go test $(GO_TEST_FLAGS) ./internal/testing/integration -run='TestMySQL' | tparse --follow -sort=elapsed test-turso: add-gowork go test $(GO_TEST_FLAGS) ./internal/testing/integration -run='TestTurso' | tparse --follow -sort=elapsed test-ydb: add-gowork go test $(GO_TEST_FLAGS) ./internal/testing/integration -run='TestYDB' | tparse --follow -sort=elapsed test-starrocks: add-gowork go test $(GO_TEST_FLAGS) ./internal/testing/integration -run='TestStarrocks' | tparse --follow -sort=elapsed test-integration: add-gowork go test $(GO_TEST_FLAGS) ./internal/testing/integration/... | tparse --follow -sort=elapsed -trimpath=auto -all # # Docker-related targets # docker-cleanup: docker stop -t=0 $$(docker ps --filter="label=goose_test" -aq) docker-postgres: docker run --rm -d \ -e POSTGRES_USER=$(DB_USER) \ -e POSTGRES_PASSWORD=$(DB_PASSWORD) \ -e POSTGRES_DB=$(DB_NAME) \ -p $(DB_POSTGRES_PORT):5432 \ -l goose_test \ postgres:14-alpine -c log_statement=all echo "postgres://$(DB_USER):$(DB_PASSWORD)@localhost:$(DB_POSTGRES_PORT)/$(DB_NAME)?sslmode=disable" docker-mysql: docker run --rm -d \ -e MYSQL_ROOT_PASSWORD=rootpassword1 \ -e MYSQL_DATABASE=$(DB_NAME) \ -e MYSQL_USER=$(DB_USER) \ -e MYSQL_PASSWORD=$(DB_PASSWORD) \ -p $(DB_MYSQL_PORT):3306 \ -l goose_test \ mysql:8.0.31 echo "mysql://$(DB_USER):$(DB_PASSWORD)@localhost:$(DB_MYSQL_PORT)/$(DB_NAME)?parseTime=true" docker-clickhouse: docker run --rm -d \ -e CLICKHOUSE_DB=$(DB_NAME) \ -e CLICKHOUSE_USER=$(DB_USER) \ -e CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 \ -e CLICKHOUSE_PASSWORD=$(DB_PASSWORD) \ -p $(DB_CLICKHOUSE_PORT):9000/tcp \ -l goose_test \ clickhouse/clickhouse-server:23-alpine echo "clickhouse://$(DB_USER):$(DB_PASSWORD)@localhost:$(DB_CLICKHOUSE_PORT)/$(DB_NAME)" docker-turso: docker run --rm -d \ -p $(DB_TURSO_PORT):8080 \ -l goose_test \ ghcr.io/tursodatabase/libsql-server:v0.22.10 ================================================ FILE: README.md ================================================ # goose [![Goose CI](https://github.com/pressly/goose/actions/workflows/ci.yaml/badge.svg)](https://github.com/pressly/goose/actions/workflows/ci.yaml) [![Go Reference](https://pkg.go.dev/badge/github.com/pressly/goose/v3.svg)](https://pkg.go.dev/github.com/pressly/goose/v3) [![Go Report Card](https://goreportcard.com/badge/github.com/pressly/goose/v3)](https://goreportcard.com/report/github.com/pressly/goose/v3) Goose is a database migration tool. Both a CLI and a library. Manage your **database schema** by creating incremental SQL changes or Go functions. #### Features - Works against multiple databases: - Postgres, MySQL, Spanner, SQLite, YDB, ClickHouse, MSSQL, Vertica, and more. - Supports Go migrations written as plain functions. - Supports [embedded](https://pkg.go.dev/embed/) migrations. - Out-of-order migrations. - Seeding data. - Environment variable substitution in SQL migrations. - ... and more. # Install ```shell go install github.com/pressly/goose/v3/cmd/goose@latest ``` This will install the `goose` binary to your `$GOPATH/bin` directory. Binary too big? Build a lite version by excluding the drivers you don't need: ```shell go build -tags='no_postgres no_mysql no_sqlite3 no_ydb' -o goose ./cmd/goose # Available build tags: # no_clickhouse no_libsql no_mssql no_mysql # no_postgres no_sqlite3 no_vertica no_ydb ``` For macOS users `goose` is available as a [Homebrew Formulae](https://formulae.brew.sh/formula/goose#default): ```shell brew install goose ``` See [installation documentation](https://pressly.github.io/goose/installation/) for more details. # Usage
Click to show goose help output ``` Usage: goose DRIVER DBSTRING [OPTIONS] COMMAND or Set environment key GOOSE_DRIVER=DRIVER GOOSE_DBSTRING=DBSTRING GOOSE_MIGRATION_DIR=MIGRATION_DIR Usage: goose [OPTIONS] COMMAND Drivers: postgres mysql sqlite3 spanner mssql redshift tidb clickhouse ydb starrocks turso Examples: goose sqlite3 ./foo.db status goose sqlite3 ./foo.db create init sql goose sqlite3 ./foo.db create add_some_column sql goose sqlite3 ./foo.db create fetch_user_data go goose sqlite3 ./foo.db up goose postgres "user=postgres dbname=postgres sslmode=disable" status goose mysql "user:password@/dbname?parseTime=true" status goose spanner "projects/project/instances/instance/databases/database" status goose redshift "postgres://user:password@qwerty.us-east-1.redshift.amazonaws.com:5439/db" status goose tidb "user:password@/dbname?parseTime=true" status goose mssql "sqlserver://user:password@hostname:1433?database=master" status goose clickhouse "tcp://127.0.0.1:9000" status goose ydb "grpcs://localhost:2135/local?go_query_mode=scripting&go_fake_tx=scripting&go_query_bind=declare,numeric" status goose starrocks "user:password@/dbname?parseTime=true&interpolateParams=true" status GOOSE_DRIVER=sqlite3 GOOSE_DBSTRING=./foo.db goose status GOOSE_DRIVER=sqlite3 GOOSE_DBSTRING=./foo.db goose create init sql GOOSE_DRIVER=postgres GOOSE_DBSTRING="user=postgres dbname=postgres sslmode=disable" goose status GOOSE_DRIVER=mysql GOOSE_DBSTRING="user:password@/dbname" goose status GOOSE_DRIVER=redshift GOOSE_DBSTRING="postgres://user:password@qwerty.us-east-1.redshift.amazonaws.com:5439/db" goose status GOOSE_DRIVER=clickhouse GOOSE_DBSTRING="clickhouse://user:password@qwerty.clickhouse.cloud:9440/dbname?secure=true&skip_verify=false" goose status Options: -allow-missing applies missing (out-of-order) migrations -certfile string file path to root CA's certificates in pem format (only support on mysql) -dir string directory with migration files (default ".", can be set via the GOOSE_MIGRATION_DIR env variable). -h print help -no-color disable color output (NO_COLOR env variable supported) -no-versioning apply migration commands with no versioning, in file order, from directory pointed to -s use sequential numbering for new migrations -ssl-cert string file path to SSL certificates in pem format (only support on mysql) -ssl-key string file path to SSL key in pem format (only support on mysql) -table string migrations table name (default "goose_db_version"). If you use a schema that is not `public`, you should set `schemaname.goose_db_version` when running commands. -timeout duration maximum allowed duration for queries to run; e.g., 1h13m -v enable verbose mode -version print version Commands: up Migrate the DB to the most recent version available up-by-one Migrate the DB up by 1 up-to VERSION Migrate the DB to a specific VERSION down Roll back the version by 1 down-to VERSION Roll back to a specific VERSION redo Re-run the latest migration reset Roll back all migrations status Dump the migration status for the current DB version Print the current version of the database create NAME [sql|go] Creates new migration file with the current timestamp fix Apply sequential ordering to migrations validate Check migration files without running them ```
Commonly used commands: [create](#create) •  [up](#up) •  [up-to](#up-to) •  [down](#down) •  [down-to](#down-to) •  [status](#status) •  [version](#version) ## create Create a new SQL migration. $ goose create add_some_column sql $ Created new file: 20170506082420_add_some_column.sql $ goose -s create add_some_column sql $ Created new file: 00001_add_some_column.sql Edit the newly created file to define the behavior of your migration. You can also create a Go migration, if you then invoke it with [your own goose binary](#go-migrations): $ goose create fetch_user_data go $ Created new file: 20170506082421_fetch_user_data.go ## up Apply all available migrations. $ goose up $ OK 001_basics.sql $ OK 002_next.sql $ OK 003_and_again.go ## up-to Migrate up to a specific version. $ goose up-to 20170506082420 $ OK 20170506082420_create_table.sql ## up-by-one Migrate up a single migration from the current version $ goose up-by-one $ OK 20170614145246_change_type.sql ## down Roll back a single migration from the current version. $ goose down $ OK 003_and_again.go ## down-to Roll back migrations to a specific version. $ goose down-to 20170506082527 $ OK 20170506082527_alter_column.sql Or, roll back all migrations (careful!): $ goose down-to 0 ## status Print the status of all migrations: $ goose status $ Applied At Migration $ ======================================= $ Sun Jan 6 11:25:03 2013 -- 001_basics.sql $ Sun Jan 6 11:25:03 2013 -- 002_next.sql $ Pending -- 003_and_again.go Note: for MySQL [parseTime flag](https://github.com/go-sql-driver/mysql#parsetime) must be enabled. Note: for MySQL [`multiStatements`](https://github.com/go-sql-driver/mysql?tab=readme-ov-file#multistatements) must be enabled. This is required when writing multiple queries separated by ';' characters in a single sql file. ## version Print the current version of the database: $ goose version $ goose: version 002 # Environment Variables If you prefer to use environment variables, instead of passing the driver and database string as arguments, you can set the following environment variables: **1. Via environment variables:** ```shell export GOOSE_DRIVER=DRIVER export GOOSE_DBSTRING=DBSTRING export GOOSE_MIGRATION_DIR=MIGRATION_DIR export GOOSE_TABLE=TABLENAME ``` **2. Via `.env` files with corresponding variables. `.env` file example**: ```env GOOSE_DRIVER=postgres GOOSE_DBSTRING=postgres://admin:admin@localhost:5432/admin_db GOOSE_MIGRATION_DIR=./migrations GOOSE_TABLE=custom.goose_migrations ``` Loading from `.env` files is enabled by default. To disable this feature, set the `-env=none` flag. If you want to load from a specific file, set the `-env` flag to the file path. For more details about environment variables, see the [official documentation on environment variables](https://pressly.github.io/goose/documentation/environment-variables/). # Migrations goose supports migrations written in SQL or in Go. ## SQL Migrations A sample SQL migration looks like: ```sql -- +goose Up CREATE TABLE post ( id int NOT NULL, title text, body text, PRIMARY KEY(id) ); -- +goose Down DROP TABLE post; ``` Each migration file must have exactly one `-- +goose Up` annotation. The `-- +goose Down` annotation is optional. If the file has both annotations, then the `-- +goose Up` annotation **must** come first. Notice the annotations in the comments. Any statements following `-- +goose Up` will be executed as part of a forward migration, and any statements following `-- +goose Down` will be executed as part of a rollback. By default, all migrations are run within a transaction. Some statements like `CREATE DATABASE`, however, cannot be run within a transaction. You may optionally add `-- +goose NO TRANSACTION` to the top of your migration file in order to skip transactions within that specific migration file. Both Up and Down migrations within this file will be run without transactions. By default, SQL statements are delimited by semicolons - in fact, query statements must end with a semicolon to be properly recognized by goose. By default, all migrations are run on the public schema. If you want to use a different schema, specify the schema name using the table option like `-table='schemaname.goose_db_version`. More complex statements (PL/pgSQL) that have semicolons within them must be annotated with `-- +goose StatementBegin` and `-- +goose StatementEnd` to be properly recognized. For example: ```sql -- +goose Up -- +goose StatementBegin CREATE OR REPLACE FUNCTION histories_partition_creation( DATE, DATE ) returns void AS $$ DECLARE create_query text; BEGIN FOR create_query IN SELECT 'CREATE TABLE IF NOT EXISTS histories_' || TO_CHAR( d, 'YYYY_MM' ) || ' ( CHECK( created_at >= timestamp ''' || TO_CHAR( d, 'YYYY-MM-DD 00:00:00' ) || ''' AND created_at < timestamp ''' || TO_CHAR( d + INTERVAL '1 month', 'YYYY-MM-DD 00:00:00' ) || ''' ) ) inherits ( histories );' FROM generate_series( $1, $2, '1 month' ) AS d LOOP EXECUTE create_query; END LOOP; -- LOOP END END; -- FUNCTION END $$ language plpgsql; -- +goose StatementEnd ``` Goose supports environment variable substitution in SQL migrations through annotations. To enable this feature, use the `-- +goose ENVSUB ON` annotation before the queries where you want substitution applied. It stays active until the `-- +goose ENVSUB OFF` annotation is encountered. You can use these annotations multiple times within a file. This feature is disabled by default for backward compatibility with existing scripts. For `PL/pgSQL` functions or other statements where substitution is not desired, wrap the annotations explicitly around the relevant parts. For example, to exclude escaping the `$$` characters: ```sql -- +goose StatementBegin CREATE OR REPLACE FUNCTION test_func() RETURNS void AS $$ -- +goose ENVSUB ON BEGIN RAISE NOTICE '${SOME_ENV_VAR}'; END; -- +goose ENVSUB OFF $$ LANGUAGE plpgsql; -- +goose StatementEnd ```
Supported expansions (click here to expand): - `${VAR}` or $VAR - expands to the value of the environment variable `VAR` - `${VAR:-default}` - expands to the value of the environment variable `VAR`, or `default` if `VAR` is unset or null - `${VAR-default}` - expands to the value of the environment variable `VAR`, or `default` if `VAR` is unset - `${VAR?err_msg}` - expands to the value of the environment variable `VAR`, or prints `err_msg` and error if `VAR` unset - ~~`${VAR:?err_msg}` - expands to the value of the environment variable `VAR`, or prints `err_msg` and error if `VAR` unset or null.~~ **THIS IS NOT SUPPORTED** See [mfridman/interpolate](https://github.com/mfridman/interpolate?tab=readme-ov-file#supported-expansions) for more details on supported expansions.
## Embedded sql migrations Go 1.16 introduced new feature: [compile-time embedding](https://pkg.go.dev/embed/) files into binary and corresponding [filesystem abstraction](https://pkg.go.dev/io/fs/). This feature can be used only for applying existing migrations. Modifying operations such as `fix` and `create` will continue to operate on OS filesystem even if using embedded files. This is expected behaviour because `io/fs` interfaces allows read-only access. Make sure to configure the correct SQL dialect, see [dialect.go](./dialect.go) for supported SQL dialects. Example usage, assuming that SQL migrations are placed in the `migrations` directory: ```go package main import ( "database/sql" "embed" "github.com/pressly/goose/v3" ) //go:embed migrations/*.sql var embedMigrations embed.FS func main() { var db *sql.DB // setup database goose.SetBaseFS(embedMigrations) if err := goose.SetDialect("postgres"); err != nil { panic(err) } if err := goose.Up(db, "migrations"); err != nil { panic(err) } // run app } ``` Note that we pass `"migrations"` as directory argument in `Up` because embedding saves directory structure. ## Go Migrations 1. Create your own goose binary, see [example](./examples/go-migrations) 2. Import `github.com/pressly/goose` 3. Register your migration functions 4. Include your `migrations` package into Go build: in `main.go`, `import _ "github.com/me/myapp/migrations"` 5. Run goose command, ie. `goose.Up(db *sql.DB, dir string)` A [sample Go migration 00002_users_add_email.go file](./examples/go-migrations/00002_rename_root.go) looks like: ```go package migrations import ( "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigration(Up, Down) } func Up(tx *sql.Tx) error { _, err := tx.Exec("UPDATE users SET username='admin' WHERE username='root';") if err != nil { return err } return nil } func Down(tx *sql.Tx) error { _, err := tx.Exec("UPDATE users SET username='root' WHERE username='admin';") if err != nil { return err } return nil } ``` Note that Go migration files must begin with a numeric value, followed by an underscore, and must not end with `*_test.go`. # Hybrid Versioning Please, read the [versioning problem](https://github.com/pressly/goose/issues/63#issuecomment-428681694) first. By default, if you attempt to apply missing (out-of-order) migrations `goose` will raise an error. However, If you want to apply these missing migrations pass goose the `-allow-missing` flag, or if using as a library supply the functional option `goose.WithAllowMissing()` to Up, UpTo or UpByOne. However, we strongly recommend adopting a hybrid versioning approach, using both timestamps and sequential numbers. Migrations created during the development process are timestamped and sequential versions are ran on production. We believe this method will prevent the problem of conflicting versions when writing software in a team environment. To help you adopt this approach, `create` will use the current timestamp as the migration version. When you're ready to deploy your migrations in a production environment, we also provide a helpful `fix` command to convert your migrations into sequential order, while preserving the timestamp ordering. We recommend running `fix` in the CI pipeline, and only when the migrations are ready for production. ## Credit The gopher mascot was designed by [Renée French](https://reneefrench.blogspot.com/) / [CC 3.0.](https://creativecommons.org/licenses/by/3.0/) For more info check out the [Go Blog](https://go.dev/blog/gopher). Adapted by Ellen. ## License Licensed under [MIT License](./LICENSE) ================================================ FILE: cmd/goose/driver_clickhouse.go ================================================ //go:build !no_clickhouse package main import ( _ "github.com/ClickHouse/clickhouse-go/v2" ) ================================================ FILE: cmd/goose/driver_mssql.go ================================================ //go:build !no_mssql package main import ( _ "github.com/microsoft/go-mssqldb" ) ================================================ FILE: cmd/goose/driver_mysql.go ================================================ //go:build !no_mysql package main import ( "crypto/tls" "crypto/x509" "fmt" "log" "os" "github.com/go-sql-driver/mysql" _ "github.com/ziutek/mymysql/godrv" ) // normalizeMySQLDSN parses the dsn used with the mysql driver to always have // the parameter `parseTime` set to true. This allows internal goose logic // to assume that DATETIME/DATE/TIMESTAMP can be scanned into the time.Time // type. func normalizeDBString(driver string, str string, certfile string, sslcert string, sslkey string) string { if driver == "mysql" { isTLS := certfile != "" if isTLS { if err := registerTLSConfig(certfile, sslcert, sslkey); err != nil { log.Fatalf("goose run: %v", err) } } var err error str, err = normalizeMySQLDSN(str, isTLS) if err != nil { log.Fatalf("failed to normalize MySQL connection string: %v", err) } } return str } const tlsConfigKey = "custom" func normalizeMySQLDSN(dsn string, tls bool) (string, error) { config, err := mysql.ParseDSN(dsn) if err != nil { return "", err } config.ParseTime = true if tls { config.TLSConfig = tlsConfigKey } return config.FormatDSN(), nil } func registerTLSConfig(pemfile string, sslcert string, sslkey string) error { rootCertPool := x509.NewCertPool() pem, err := os.ReadFile(pemfile) if err != nil { return err } if ok := rootCertPool.AppendCertsFromPEM(pem); !ok { return fmt.Errorf("failed to append PEM: %q", pemfile) } tlsConfig := &tls.Config{ RootCAs: rootCertPool, } if sslcert != "" && sslkey != "" { cert, err := tls.LoadX509KeyPair(sslcert, sslkey) if err != nil { return fmt.Errorf("failed to load x509 keypair: %w", err) } tlsConfig.Certificates = append(tlsConfig.Certificates, cert) } return mysql.RegisterTLSConfig(tlsConfigKey, tlsConfig) } ================================================ FILE: cmd/goose/driver_no_mysql.go ================================================ //go:build no_mysql package main func normalizeDBString(driver string, str string, certfile string, sslcert string, sslkey string) string { return str } ================================================ FILE: cmd/goose/driver_postgres.go ================================================ //go:build !no_postgres package main import ( _ "github.com/jackc/pgx/v5/stdlib" ) ================================================ FILE: cmd/goose/driver_sqlite3.go ================================================ //go:build !no_sqlite3 && !(windows && arm64) package main import ( _ "modernc.org/sqlite" ) ================================================ FILE: cmd/goose/driver_turso.go ================================================ //go:build !no_libsql package main import ( _ "github.com/tursodatabase/libsql-client-go/libsql" ) ================================================ FILE: cmd/goose/driver_vertica.go ================================================ //go:build !no_vertica package main import ( _ "github.com/vertica/vertica-sql-go" ) ================================================ FILE: cmd/goose/driver_ydb.go ================================================ //go:build !no_ydb package main import ( _ "github.com/ydb-platform/ydb-go-sdk/v3" ) ================================================ FILE: cmd/goose/main.go ================================================ package main import ( "context" "database/sql" "errors" "flag" "fmt" "io/fs" "log" "os" "path/filepath" "runtime/debug" "sort" "strconv" "strings" "text/tabwriter" "text/template" "github.com/joho/godotenv" "github.com/mfridman/xflag" "github.com/pressly/goose/v3" "github.com/pressly/goose/v3/internal/migrationstats" ) var ( DefaultMigrationDir = "." flags = flag.NewFlagSet("goose", flag.ExitOnError) dir = flags.String("dir", DefaultMigrationDir, "directory with migration files, (GOOSE_MIGRATION_DIR env variable supported)") table = flags.String("table", "", "migrations table name") verbose = flags.Bool("v", false, "enable verbose mode") help = flags.Bool("h", false, "print help") versionFlag = flags.Bool("version", false, "print version") certfile = flags.String("certfile", "", "file path to root CA's certificates in pem format (only support on mysql)") sequential = flags.Bool("s", false, "use sequential numbering for new migrations") allowMissing = flags.Bool("allow-missing", false, "applies missing (out-of-order) migrations") sslcert = flags.String("ssl-cert", "", "file path to SSL certificates in pem format (only support on mysql)") sslkey = flags.String("ssl-key", "", "file path to SSL key in pem format (only support on mysql)") noVersioning = flags.Bool("no-versioning", false, "apply migration commands with no versioning, in file order, from directory pointed to") noColor = flags.Bool("no-color", false, "disable color output (NO_COLOR env variable supported)") timeout = flags.Duration("timeout", 0, "maximum allowed duration for queries to run; e.g., 1h13m") envFile = flags.String("env", "", "load environment variables from file (default .env)") ) var version string func main() { ctx := context.Background() flags.Usage = usage if err := xflag.ParseToEnd(flags, os.Args[1:]); err != nil { log.Fatalf("failed to parse args: %v", err) return } if *versionFlag { buildInfo, ok := debug.ReadBuildInfo() if version == "" && ok && buildInfo != nil && buildInfo.Main.Version != "" { version = buildInfo.Main.Version } fmt.Printf("goose version: %s\n", strings.TrimSpace(version)) return } switch *envFile { case "": // Best effort to load default .env file _ = godotenv.Load() case "none": // Do not load any env file default: if err := godotenv.Load(*envFile); err != nil { log.Fatalf("failed to load env file: %v", err) } } envConfig := loadEnvConfig() if *verbose { goose.SetVerbose(true) } if *sequential { goose.SetSequential(true) } // The order of precedence should be: flag > env variable > default value. goose.SetTableName(firstNonEmpty(*table, envConfig.table, goose.DefaultTablename)) args := flags.Args() if *help { flags.Usage() return } if len(args) == 0 { flags.Usage() os.Exit(1) } // The -dir option has not been set, check whether the env variable is set // before defaulting to ".". if *dir == DefaultMigrationDir && envConfig.dir != "" { *dir = envConfig.dir } switch args[0] { case "init": if err := gooseInit(*dir); err != nil { log.Fatalf("goose run: %v", err) } return case "create": if err := goose.RunContext(ctx, "create", nil, *dir, args[1:]...); err != nil { log.Fatalf("goose run: %v", err) } return case "fix": if err := goose.RunContext(ctx, "fix", nil, *dir); err != nil { log.Fatalf("goose run: %v", err) } return case "env": for _, env := range envConfig.listEnvs() { fmt.Printf("%s=%q\n", env.Name, env.Value) } return case "validate": if err := printValidate(*dir, *verbose); err != nil { log.Fatalf("goose validate: %v", err) } return case "beta": remain := args[1:] if len(remain) == 0 { log.Println("goose beta: missing subcommand") os.Exit(1) } switch remain[0] { case "drivers": printDrivers() } return } args = mergeArgs(envConfig, args) if len(args) < 3 { flags.Usage() os.Exit(1) } driver, dbstring, command := args[0], args[1], args[2] db, err := goose.OpenDBWithDriver(driver, normalizeDBString(driver, dbstring, *certfile, *sslcert, *sslkey)) if err != nil { log.Fatalf("-dbstring=%q: %v", dbstring, err) } defer func() { if err := db.Close(); err != nil { log.Fatalf("goose: failed to close DB: %v", err) } }() arguments := []string{} if len(args) > 3 { arguments = append(arguments, args[3:]...) } options := []goose.OptionsFunc{} if *noColor || envConfig.noColor { options = append(options, goose.WithNoColor(true)) } if *allowMissing { options = append(options, goose.WithAllowMissing()) } if *noVersioning { options = append(options, goose.WithNoVersioning()) } if timeout != nil && *timeout != 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, *timeout) defer cancel() } if err := goose.RunWithOptionsContext( ctx, command, db, *dir, arguments, options..., ); err != nil { log.Fatalf("goose run: %v", err) } } func printDrivers() { drivers := mergeDrivers(sql.Drivers()) if len(drivers) == 0 { fmt.Println("No drivers found") return } fmt.Println("Available drivers:") for _, driver := range drivers { fmt.Printf(" %s\n", driver) } } // mergeDrivers merges drivers with a common prefix into a single line. func mergeDrivers(drivers []string) []string { driverMap := make(map[string][]string) for _, driver := range drivers { parts := strings.Split(driver, "/") if len(parts) > 1 { // Merge drivers with a common prefix "/" prefix := parts[0] driverMap[prefix] = append(driverMap[prefix], driver) } else { // Add drivers without a prefix directly driverMap[driver] = append(driverMap[driver], driver) } } var merged []string for _, versions := range driverMap { sort.Strings(versions) merged = append(merged, strings.Join(versions, ", ")) } sort.Strings(merged) return merged } func mergeArgs(config *envConfig, args []string) []string { if len(args) < 1 { return args } if s := config.driver; s != "" { args = append([]string{s}, args...) } if s := config.dbstring; s != "" { args = append([]string{args[0], s}, args[1:]...) } return args } func usage() { fmt.Println(usagePrefix) flags.PrintDefaults() fmt.Println(usageCommands) } var ( usagePrefix = `Usage: goose DRIVER DBSTRING [OPTIONS] COMMAND or Set environment key GOOSE_DRIVER=DRIVER GOOSE_DBSTRING=DBSTRING GOOSE_MIGRATION_DIR=MIGRATION_DIR Usage: goose [OPTIONS] COMMAND Drivers: postgres mysql sqlite3 spanner mssql redshift tidb clickhouse ydb starrocks turso Examples: goose sqlite3 ./foo.db status goose sqlite3 ./foo.db create init sql goose sqlite3 ./foo.db create add_some_column sql goose sqlite3 ./foo.db create fetch_user_data go goose sqlite3 ./foo.db up goose postgres "user=postgres dbname=postgres sslmode=disable" status goose mysql "user:password@/dbname?parseTime=true" status goose redshift "postgres://user:password@qwerty.us-east-1.redshift.amazonaws.com:5439/db" status goose tidb "user:password@/dbname?parseTime=true" status goose mssql "sqlserver://user:password@dbname:1433?database=master" status goose clickhouse "tcp://127.0.0.1:9000" status goose ydb "grpcs://localhost:2135/local?go_query_mode=scripting&go_fake_tx=scripting&go_query_bind=declare,numeric" status goose turso "libsql://dbname.turso.io?authToken=token" status GOOSE_DRIVER=sqlite3 GOOSE_DBSTRING=./foo.db goose status GOOSE_DRIVER=sqlite3 GOOSE_DBSTRING=./foo.db goose create init sql GOOSE_DRIVER=postgres GOOSE_DBSTRING="user=postgres dbname=postgres sslmode=disable" goose status GOOSE_DRIVER=mysql GOOSE_DBSTRING="user:password@/dbname" goose status GOOSE_DRIVER=redshift GOOSE_DBSTRING="postgres://user:password@qwerty.us-east-1.redshift.amazonaws.com:5439/db" goose status GOOSE_DRIVER=turso GOOSE_DBSTRING="libsql://dbname.turso.io?authToken=token" goose status GOOSE_DRIVER=clickhouse GOOSE_DBSTRING="clickhouse://user:password@qwerty.clickhouse.cloud:9440/dbname?secure=true&skip_verify=false" goose status Options: ` usageCommands = ` Commands: up Migrate the DB to the most recent version available up-by-one Migrate the DB up by 1 up-to VERSION Migrate the DB to a specific VERSION down Roll back the version by 1 down-to VERSION Roll back to a specific VERSION redo Re-run the latest migration reset Roll back all migrations status Dump the migration status for the current DB version Print the current version of the database create NAME [sql|go] Creates new migration file with the current timestamp fix Apply sequential ordering to migrations validate Check migration files without running them ` ) var sqlMigrationTemplate = template.Must(template.New("goose.sql-migration").Parse(`-- Thank you for giving goose a try! -- -- This file was automatically created running goose init. If you're familiar with goose -- feel free to remove/rename this file, write some SQL and goose up. Briefly, -- -- Documentation can be found here: https://pressly.github.io/goose -- -- A single goose .sql file holds both Up and Down migrations. -- -- All goose .sql files are expected to have a -- +goose Up annotation. -- The -- +goose Down annotation is optional, but recommended, and must come after the Up annotation. -- -- The -- +goose NO TRANSACTION annotation may be added to the top of the file to run statements -- outside a transaction. Both Up and Down migrations within this file will be run without a transaction. -- -- More complex statements that have semicolons within them must be annotated with -- the -- +goose StatementBegin and -- +goose StatementEnd annotations to be properly recognized. -- -- Use GitHub issues for reporting bugs and requesting features, enjoy! -- +goose Up SELECT 'up SQL query'; -- +goose Down SELECT 'down SQL query'; `)) // initDir will create a directory with an empty SQL migration file. func gooseInit(dir string) error { if dir == "" || dir == DefaultMigrationDir { dir = "migrations" } _, err := os.Stat(dir) switch { case errors.Is(err, fs.ErrNotExist): case err == nil, errors.Is(err, fs.ErrExist): return fmt.Errorf("directory already exists: %s", dir) default: return err } if err := os.MkdirAll(dir, 0755); err != nil { return err } return goose.CreateWithTemplate(nil, dir, sqlMigrationTemplate, "initial", "sql") } func gatherFilenames(filename string) ([]string, error) { stat, err := os.Stat(filename) if err != nil { return nil, err } var filenames []string if stat.IsDir() { for _, pattern := range []string{"*.sql", "*.go"} { file, err := filepath.Glob(filepath.Join(filename, pattern)) if err != nil { return nil, err } filenames = append(filenames, file...) } } else { filenames = append(filenames, filename) } sort.Strings(filenames) return filenames, nil } func printValidate(filename string, verbose bool) error { filenames, err := gatherFilenames(filename) if err != nil { return err } stats, err := migrationstats.GatherStats( migrationstats.NewFileWalker(filenames...), false, ) if err != nil { return err } // TODO(mf): we should introduce a --debug flag, which allows printing // more internal debug information and leave verbose for additional information. if !verbose { return nil } w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', tabwriter.TabIndent) fmtPattern := "%v\t%v\t%v\t%v\t%v\t\n" fmt.Fprintf(w, fmtPattern, "Type", "Txn", "Up", "Down", "Name") fmt.Fprintf(w, fmtPattern, "────", "───", "──", "────", "────") for _, m := range stats { txnStr := "✔" if !m.Tx { txnStr = "✘" } fmt.Fprintf(w, fmtPattern, strings.TrimPrefix(filepath.Ext(m.FileName), "."), txnStr, m.UpCount, m.DownCount, filepath.Base(m.FileName), ) } return w.Flush() } type envConfig struct { driver string dbstring string dir string table string noColor bool } func loadEnvConfig() *envConfig { noColorBool, _ := strconv.ParseBool(envOr("NO_COLOR", "false")) return &envConfig{ driver: envOr("GOOSE_DRIVER", ""), dbstring: envOr("GOOSE_DBSTRING", ""), table: envOr("GOOSE_TABLE", ""), dir: envOr("GOOSE_MIGRATION_DIR", DefaultMigrationDir), // https://no-color.org/ noColor: noColorBool, } } func (c *envConfig) listEnvs() []envVar { return []envVar{ {Name: "GOOSE_DRIVER", Value: c.driver}, {Name: "GOOSE_DBSTRING", Value: c.dbstring}, {Name: "GOOSE_MIGRATION_DIR", Value: c.dir}, {Name: "GOOSE_TABLE", Value: c.table}, {Name: "NO_COLOR", Value: strconv.FormatBool(c.noColor)}, } } type envVar struct { Name string Value string } // envOr returns os.Getenv(key) if set, or else default. func envOr(key, def string) string { val := os.Getenv(key) if val == "" { val = def } return val } // firstNonEmpty returns the first non-empty string from the provided input or an empty string if all are empty. func firstNonEmpty(values ...string) string { for _, v := range values { if v != "" { return v } } return "" } ================================================ FILE: cmd/goose/main_test.go ================================================ package main import ( "testing" ) func TestFirstNonEmpty(t *testing.T) { tests := []struct { name string input []string expected string }{ { name: "no values", input: []string{}, expected: "", }, { name: "all empty values", input: []string{"", "", ""}, expected: "", }, { name: "single non-empty value at start", input: []string{"value", "", ""}, expected: "value", }, { name: "single non-empty value in middle", input: []string{"", "value", ""}, expected: "value", }, { name: "single non-empty value at end", input: []string{"", "", "value"}, expected: "value", }, { name: "multiple non-empty values", input: []string{"first", "second", "third"}, expected: "first", }, { name: "mixed empty and non-empty values", input: []string{"", "value1", "", "value2"}, expected: "value1", }, { name: "only one value, empty", input: []string{""}, expected: "", }, { name: "only one value, non-empty", input: []string{"value"}, expected: "value", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := firstNonEmpty(tt.input...) if result != tt.expected { t.Errorf("expected %q, got %q", tt.expected, result) } }) } } ================================================ FILE: create.go ================================================ package goose import ( "database/sql" "errors" "fmt" "os" "path/filepath" "text/template" "time" ) type tmplVars struct { Version string CamelName string } var ( sequential = false ) // SetSequential set whether to use sequential versioning instead of timestamp based versioning func SetSequential(s bool) { sequential = s } // Create writes a new blank migration file. func CreateWithTemplate(db *sql.DB, dir string, tmpl *template.Template, name, migrationType string) error { version := time.Now().UTC().Format(timestampFormat) if sequential { // always use DirFS here because it's modifying operation migrations, err := collectMigrationsFS(osFS{}, dir, minVersion, maxVersion, registeredGoMigrations) if err != nil && !errors.Is(err, ErrNoMigrationFiles) { return err } vMigrations, err := migrations.versioned() if err != nil { return err } if last, err := vMigrations.Last(); err == nil { version = fmt.Sprintf(seqVersionTemplate, last.Version+1) } else { version = fmt.Sprintf(seqVersionTemplate, int64(1)) } } filename := fmt.Sprintf("%v_%v.%v", version, snakeCase(name), migrationType) if tmpl == nil { if migrationType == "go" { tmpl = goSQLMigrationTemplate } else { tmpl = sqlMigrationTemplate } } path := filepath.Join(dir, filename) if _, err := os.Stat(path); !os.IsNotExist(err) { return fmt.Errorf("failed to create migration file: %w", err) } f, err := os.Create(path) if err != nil { return fmt.Errorf("failed to create migration file: %w", err) } defer f.Close() vars := tmplVars{ Version: version, CamelName: camelCase(name), } if err := tmpl.Execute(f, vars); err != nil { return fmt.Errorf("failed to execute tmpl: %w", err) } log.Printf("Created new file: %s", f.Name()) return nil } // Create writes a new blank migration file. func Create(db *sql.DB, dir, name, migrationType string) error { return CreateWithTemplate(db, dir, nil, name, migrationType) } var sqlMigrationTemplate = template.Must(template.New("goose.sql-migration").Parse(`-- +goose Up SELECT 'up SQL query'; -- +goose Down SELECT 'down SQL query'; `)) var goSQLMigrationTemplate = template.Must(template.New("goose.go-migration").Parse(`package migrations import ( "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationContext(up{{.CamelName}}, down{{.CamelName}}) } func up{{.CamelName}}(ctx context.Context, tx *sql.Tx) error { // This code is executed when the migration is applied. return nil } func down{{.CamelName}}(ctx context.Context, tx *sql.Tx) error { // This code is executed when the migration is rolled back. return nil } `)) ================================================ FILE: create_test.go ================================================ package goose import ( "fmt" "os" "os/exec" "strings" "testing" "time" ) func TestSequential(t *testing.T) { t.Parallel() if testing.Short() { t.Skip("skip long running test") } dir := t.TempDir() defer os.Remove("./bin/create-goose") // clean up commands := []string{ "go build -o ./bin/create-goose ./cmd/goose", fmt.Sprintf("./bin/create-goose -s -dir=%s create create_table", dir), fmt.Sprintf("./bin/create-goose -s -dir=%s create add_users", dir), fmt.Sprintf("./bin/create-goose -s -dir=%s create add_indices", dir), fmt.Sprintf("./bin/create-goose -s -dir=%s create update_users", dir), } for _, cmd := range commands { args := strings.Split(cmd, " ") time.Sleep(1 * time.Second) cmd := exec.Command(args[0], args[1:]...) cmd.Env = os.Environ() out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("%s:\n%v\n\n%s", err, cmd, out) } } files, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } // check that the files are in order for i, f := range files { expected := fmt.Sprintf("%05v", i+1) if !strings.HasPrefix(f.Name(), expected) { t.Errorf("failed to find %s prefix in %s", expected, f.Name()) } } } ================================================ FILE: database/dialect/querier.go ================================================ package dialect // Querier is the interface that wraps the basic methods to create a dialect specific query. // // It is intended tio be using with [database.NewStoreFromQuerier] to create a new [database.Store] // implementation based on a custom querier. type Querier interface { // CreateTable returns the SQL query string to create the db version table. CreateTable(tableName string) string // InsertVersion returns the SQL query string to insert a new version into the db version table. InsertVersion(tableName string) string // DeleteVersion returns the SQL query string to delete a version from the db version table. DeleteVersion(tableName string) string // GetMigrationByVersion returns the SQL query string to get a single migration by version. // // The query should return the timestamp and is_applied columns. GetMigrationByVersion(tableName string) string // ListMigrations returns the SQL query string to list all migrations in descending order by id. // // The query should return the version_id and is_applied columns. ListMigrations(tableName string) string // GetLatestVersion returns the SQL query string to get the last version_id from the db version // table. Returns a nullable int64 value. GetLatestVersion(tableName string) string } ================================================ FILE: database/dialect/querier_extended.go ================================================ package dialect // QuerierExtender extends the [Querier] interface with optional database-specific optimizations. // While not required, implementing these methods can improve performance. // // IMPORTANT: This interface may be expanded in future versions. Implementors must be prepared to // update their implementations when new methods are added. // // Example compile-time check: // // var _ QuerierExtender = (*CustomQuerierExtended)(nil) // // In short, it's exported to allow implementors to have a compile-time check that they are // implementing the interface correctly. type QuerierExtender interface { Querier // TableExists returns a database-specific SQL query to check if a table exists. For example, // implementations might query system catalogs like pg_tables or sqlite_master. Return empty // string if not supported. TableExists(tableName string) string } ================================================ FILE: database/dialects.go ================================================ package database import ( "context" "database/sql" "errors" "fmt" "github.com/pressly/goose/v3/database/dialect" "github.com/pressly/goose/v3/internal/dialects" ) // Dialect is the type of database dialect. type Dialect string const ( DialectCustom Dialect = "" DialectClickHouse Dialect = "clickhouse" DialectAuroraDSQL Dialect = "dsql" DialectMSSQL Dialect = "mssql" DialectMySQL Dialect = "mysql" DialectPostgres Dialect = "postgres" DialectRedshift Dialect = "redshift" DialectSQLite3 Dialect = "sqlite3" DialectSpanner Dialect = "spanner" DialectStarrocks Dialect = "starrocks" DialectTiDB Dialect = "tidb" DialectTurso Dialect = "turso" DialectYdB Dialect = "ydb" // DEPRECATED: Vertica support is deprecated and will be removed in a future release. DialectVertica Dialect = "vertica" ) // NewStore returns a new [Store] implementation for the given dialect. func NewStore(d Dialect, tableName string) (Store, error) { if d == DialectCustom { return nil, errors.New("custom dialect is not supported") } lookup := map[Dialect]dialect.Querier{ DialectClickHouse: dialects.NewClickhouse(), DialectAuroraDSQL: dialects.NewAuroraDSQL(), DialectMSSQL: dialects.NewSqlserver(), DialectMySQL: dialects.NewMysql(), DialectPostgres: dialects.NewPostgres(), DialectRedshift: dialects.NewRedshift(), DialectSQLite3: dialects.NewSqlite3(), DialectSpanner: dialects.NewSpanner(), DialectStarrocks: dialects.NewStarrocks(), DialectTiDB: dialects.NewTidb(), DialectTurso: dialects.NewTurso(), DialectVertica: dialects.NewVertica(), DialectYdB: dialects.NewYDB(), } querier, ok := lookup[d] if !ok { return nil, fmt.Errorf("unknown dialect: %q", d) } return NewStoreFromQuerier(tableName, querier) } // NewStoreFromQuerier returns a new [Store] implementation for the given querier. // // Most of the time you should use [NewStore] instead of this function, as it will automatically // create a dialect-specific querier for you. This function is useful if you want to use a custom // querier that is not part of the standard dialects. func NewStoreFromQuerier(tableName string, querier dialect.Querier) (Store, error) { if tableName == "" { return nil, errors.New("table name must not be empty") } if querier == nil { return nil, errors.New("querier must not be nil") } return &store{ tableName: tableName, querier: newQueryController(querier), }, nil } type store struct { tableName string querier *queryController } var _ Store = (*store)(nil) func (s *store) Tablename() string { return s.tableName } func (s *store) CreateVersionTable(ctx context.Context, db DBTxConn) error { q := s.querier.CreateTable(s.tableName) if _, err := db.ExecContext(ctx, q); err != nil { return fmt.Errorf("failed to create version table %q: %w", s.tableName, err) } return nil } func (s *store) Insert(ctx context.Context, db DBTxConn, req InsertRequest) error { q := s.querier.InsertVersion(s.tableName) if _, err := db.ExecContext(ctx, q, req.Version, true); err != nil { return fmt.Errorf("failed to insert version %d: %w", req.Version, err) } return nil } func (s *store) Delete(ctx context.Context, db DBTxConn, version int64) error { q := s.querier.DeleteVersion(s.tableName) if _, err := db.ExecContext(ctx, q, version); err != nil { return fmt.Errorf("failed to delete version %d: %w", version, err) } return nil } func (s *store) GetMigration( ctx context.Context, db DBTxConn, version int64, ) (*GetMigrationResult, error) { q := s.querier.GetMigrationByVersion(s.tableName) var result GetMigrationResult if err := db.QueryRowContext(ctx, q, version).Scan( &result.Timestamp, &result.IsApplied, ); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("%w: %d", ErrVersionNotFound, version) } return nil, fmt.Errorf("failed to get migration %d: %w", version, err) } return &result, nil } func (s *store) GetLatestVersion(ctx context.Context, db DBTxConn) (int64, error) { q := s.querier.GetLatestVersion(s.tableName) var version sql.NullInt64 if err := db.QueryRowContext(ctx, q).Scan(&version); err != nil { return -1, fmt.Errorf("failed to get latest version: %w", err) } if !version.Valid { return -1, fmt.Errorf("latest %w", ErrVersionNotFound) } return version.Int64, nil } func (s *store) ListMigrations( ctx context.Context, db DBTxConn, ) ([]*ListMigrationsResult, error) { q := s.querier.ListMigrations(s.tableName) rows, err := db.QueryContext(ctx, q) if err != nil { return nil, fmt.Errorf("failed to list migrations: %w", err) } defer rows.Close() var migrations []*ListMigrationsResult for rows.Next() { var result ListMigrationsResult if err := rows.Scan(&result.Version, &result.IsApplied); err != nil { return nil, fmt.Errorf("failed to scan list migrations result: %w", err) } migrations = append(migrations, &result) } if err := rows.Err(); err != nil { return nil, err } return migrations, nil } // // // // Additional methods that are not part of the core Store interface, but are extended by the // [controller.StoreController] type. // // // func (s *store) TableExists(ctx context.Context, db DBTxConn) (bool, error) { q := s.querier.TableExists(s.tableName) if q == "" { return false, errors.ErrUnsupported } var exists bool // Note, we do not pass the table name as an argument to the query, as the query should be // pre-defined by the dialect. if err := db.QueryRowContext(ctx, q).Scan(&exists); err != nil { return false, fmt.Errorf("failed to check if table exists: %w", err) } return exists, nil } var _ dialect.Querier = (*queryController)(nil) type queryController struct{ dialect.Querier } // newQueryController returns a new QueryController that wraps the given Querier. func newQueryController(querier dialect.Querier) *queryController { return &queryController{Querier: querier} } // Optional methods // TableExists returns the SQL query string to check if the version table exists. If the Querier // does not implement this method, it will return an empty string. // // Returns a boolean value. func (c *queryController) TableExists(tableName string) string { if t, ok := c.Querier.(interface{ TableExists(string) string }); ok { return t.TableExists(tableName) } return "" } ================================================ FILE: database/doc.go ================================================ // Package database defines a generic [Store] interface for goose to use when interacting with the // database. It is meant to be generic and not tied to any specific database technology. // // At a high level, a [Store] is responsible for: // - Creating a version table // - Inserting and deleting a version // - Getting a specific version // - Listing all applied versions // // Use the [NewStore] function to create a [Store] for one of the supported dialects. // // For more advanced use cases, it's possible to implement a custom [Store] for a database that // goose does not support. package database ================================================ FILE: database/sql_extended.go ================================================ package database import ( "context" "database/sql" ) // DBTxConn is a thin interface for common methods that is satisfied by *sql.DB, *sql.Tx and // *sql.Conn. // // There is a long outstanding issue to formalize a std lib interface, but alas. See: // https://github.com/golang/go/issues/14468 type DBTxConn interface { ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row } var ( _ DBTxConn = (*sql.DB)(nil) _ DBTxConn = (*sql.Tx)(nil) _ DBTxConn = (*sql.Conn)(nil) ) ================================================ FILE: database/store.go ================================================ package database import ( "context" "errors" "time" ) var ( // ErrVersionNotFound must be returned by [GetMigration] or [GetLatestVersion] when a migration // does not exist. ErrVersionNotFound = errors.New("version not found") // ErrNotImplemented must be returned by methods that are not implemented. ErrNotImplemented = errors.New("not implemented") ) // Store is an interface that defines methods for tracking and managing migrations. It is used by // the goose package to interact with a database. By defining a Store interface, multiple // implementations can be created to support different databases without reimplementing the // migration logic. // // This package provides several dialects that implement the Store interface. While most users won't // need to create their own Store, if you need to support a database that isn't currently supported, // you can implement your own! type Store interface { // Tablename is the name of the version table. This table is used to record applied migrations // and must not be an empty string. Tablename() string // CreateVersionTable creates the version table, which is used to track migrations. CreateVersionTable(ctx context.Context, db DBTxConn) error // Insert a version id into the version table. Insert(ctx context.Context, db DBTxConn, req InsertRequest) error // Delete a version id from the version table. Delete(ctx context.Context, db DBTxConn, version int64) error // GetMigration retrieves a single migration by version id. If the query succeeds, but the // version is not found, this method must return [ErrVersionNotFound]. GetMigration(ctx context.Context, db DBTxConn, version int64) (*GetMigrationResult, error) // GetLatestVersion retrieves the last applied migration version. If no migrations exist, this // method must return [ErrVersionNotFound]. GetLatestVersion(ctx context.Context, db DBTxConn) (int64, error) // ListMigrations retrieves all migrations sorted in descending order by id or timestamp. If // there are no migrations, return empty slice with no error. Typically this method will return // at least one migration, because the initial version (0) is always inserted into the version // table when it is created. ListMigrations(ctx context.Context, db DBTxConn) ([]*ListMigrationsResult, error) } type InsertRequest struct { Version int64 // TODO(mf): in the future, we maybe want to expand this struct so implementors can store // additional information. See the following issues for more information: // - https://github.com/pressly/goose/issues/422 // - https://github.com/pressly/goose/issues/288 } type GetMigrationResult struct { Timestamp time.Time IsApplied bool } type ListMigrationsResult struct { Version int64 IsApplied bool } ================================================ FILE: database/store_extended.go ================================================ package database import "context" // StoreExtender is an extension of the Store interface that provides optional optimizations and // database-specific features. While not required by the core goose package, implementing these // methods can improve performance and functionality for specific databases. // // IMPORTANT: This interface may be expanded in future versions. Implementors MUST be prepared to // update their implementations when new methods are added, either by implementing the new // functionality or returning [errors.ErrUnsupported]. // // The goose package handles these extended capabilities through a [controller.StoreController], // which automatically uses optimized methods when available while falling back to default behavior // when they're not implemented. // // Example usage to verify implementation: // // var _ StoreExtender = (*CustomStoreExtended)(nil) // // In short, it's exported to allows implementors to have a compile-time check that they are // implementing the interface correctly. type StoreExtender interface { Store // TableExists checks if the migrations table exists in the database. Implementing this method // allows goose to optimize table existence checks by using database-specific system catalogs // (e.g., pg_tables for PostgreSQL, sqlite_master for SQLite) instead of generic SQL queries. // // Return [errors.ErrUnsupported] if the database does not provide an efficient way to check // table existence. TableExists(ctx context.Context, db DBTxConn) (bool, error) } ================================================ FILE: database/store_test.go ================================================ package database_test import ( "context" "database/sql" "errors" "path/filepath" "testing" "github.com/pressly/goose/v3/database" "github.com/stretchr/testify/require" "go.uber.org/multierr" "modernc.org/sqlite" ) // The goal of this test is to verify the database store package works as expected. This test is not // meant to be exhaustive or test every possible database dialect. It is meant to verify the Store // interface works against a real database. func TestDialectStore(t *testing.T) { t.Parallel() t.Run("invalid", func(t *testing.T) { // Test empty table name. _, err := database.NewStore(database.DialectSQLite3, "") require.Error(t, err) // Test unknown dialect. _, err = database.NewStore("unknown-dialect", "foo") require.Error(t, err) // Test empty dialect. _, err = database.NewStore("", "foo") require.Error(t, err) }) // Test generic behavior. t.Run("sqlite3", func(t *testing.T) { db, err := sql.Open("sqlite", ":memory:") require.NoError(t, err) testStore(context.Background(), t, database.DialectSQLite3, db, func(t *testing.T, err error) { t.Helper() var sqliteErr *sqlite.Error ok := errors.As(err, &sqliteErr) require.True(t, ok) require.Equal(t, 1, sqliteErr.Code()) // Generic error (SQLITE_ERROR) require.Contains(t, sqliteErr.Error(), "table test_goose_db_version already exists") }) }) t.Run("ListMigrations", func(t *testing.T) { dir := t.TempDir() db, err := sql.Open("sqlite", filepath.Join(dir, "sql_embed.db")) require.NoError(t, err) store, err := database.NewStore(database.DialectSQLite3, "foo") require.NoError(t, err) err = store.CreateVersionTable(context.Background(), db) require.NoError(t, err) insert := func(db *sql.DB, version int64) error { return store.Insert(context.Background(), db, database.InsertRequest{Version: version}) } require.NoError(t, insert(db, 1)) require.NoError(t, insert(db, 3)) require.NoError(t, insert(db, 2)) res, err := store.ListMigrations(context.Background(), db) require.NoError(t, err) require.Len(t, res, 3) // Check versions are in descending order: [2, 3, 1] require.EqualValues(t, 2, res[0].Version) require.EqualValues(t, 3, res[1].Version) require.EqualValues(t, 1, res[2].Version) }) } // testStore tests various store operations. // // If alreadyExists is not nil, it will be used to assert the error returned by CreateVersionTable // when the version table already exists. func testStore( ctx context.Context, t *testing.T, d database.Dialect, db *sql.DB, alreadyExists func(t *testing.T, err error), ) { const ( tableName = "test_goose_db_version" ) store, err := database.NewStore(d, tableName) require.NoError(t, err) // Create the version table. err = runTx(ctx, db, func(tx *sql.Tx) error { return store.CreateVersionTable(ctx, tx) }) require.NoError(t, err) // Create the version table again. This should fail. err = runTx(ctx, db, func(tx *sql.Tx) error { return store.CreateVersionTable(ctx, tx) }) require.Error(t, err) if alreadyExists != nil { alreadyExists(t, err) } // Get the latest version. There should be none. _, err = store.GetLatestVersion(ctx, db) require.ErrorIs(t, err, database.ErrVersionNotFound) // List migrations. There should be none. err = runConn(ctx, db, func(conn *sql.Conn) error { res, err := store.ListMigrations(ctx, conn) require.NoError(t, err) require.Empty(t, res, 0) return nil }) require.NoError(t, err) // Insert 5 migrations in addition to the zero migration. for i := range 6 { err = runConn(ctx, db, func(conn *sql.Conn) error { err := store.Insert(ctx, conn, database.InsertRequest{Version: int64(i)}) require.NoError(t, err) latest, err := store.GetLatestVersion(ctx, conn) require.NoError(t, err) require.Equal(t, latest, int64(i)) return nil }) require.NoError(t, err) } // List migrations. There should be 6. err = runConn(ctx, db, func(conn *sql.Conn) error { res, err := store.ListMigrations(ctx, conn) require.NoError(t, err) require.Len(t, res, 6) // Check versions are in descending order. for i := range 6 { require.EqualValues(t, res[i].Version, 5-i) } return nil }) require.NoError(t, err) // Delete 3 migrations backwards for i := 5; i >= 3; i-- { err = runConn(ctx, db, func(conn *sql.Conn) error { err := store.Delete(ctx, conn, int64(i)) require.NoError(t, err) latest, err := store.GetLatestVersion(ctx, conn) require.NoError(t, err) require.Equal(t, latest, int64(i-1)) return nil }) require.NoError(t, err) } // List migrations. There should be 3. err = runConn(ctx, db, func(conn *sql.Conn) error { res, err := store.ListMigrations(ctx, conn) require.NoError(t, err) require.Len(t, res, 3) // Check that the remaining versions are in descending order. for i := range 3 { require.EqualValues(t, res[i].Version, 2-i) } return nil }) require.NoError(t, err) // Get remaining migrations one by one. for i := range 3 { err = runConn(ctx, db, func(conn *sql.Conn) error { res, err := store.GetMigration(ctx, conn, int64(i)) require.NoError(t, err) require.True(t, res.IsApplied) require.False(t, res.Timestamp.IsZero()) return nil }) require.NoError(t, err) } // Delete remaining migrations one by one and use all 3 connection types: // 1. *sql.Tx err = runTx(ctx, db, func(tx *sql.Tx) error { err := store.Delete(ctx, tx, 2) require.NoError(t, err) latest, err := store.GetLatestVersion(ctx, tx) require.NoError(t, err) require.EqualValues(t, 1, latest) return nil }) require.NoError(t, err) // 2. *sql.Conn err = runConn(ctx, db, func(conn *sql.Conn) error { err := store.Delete(ctx, conn, 1) require.NoError(t, err) latest, err := store.GetLatestVersion(ctx, conn) require.NoError(t, err) require.EqualValues(t, 0, latest) return nil }) require.NoError(t, err) // 3. *sql.DB err = store.Delete(ctx, db, 0) require.NoError(t, err) _, err = store.GetLatestVersion(ctx, db) require.ErrorIs(t, err, database.ErrVersionNotFound) // List migrations. There should be none. err = runConn(ctx, db, func(conn *sql.Conn) error { res, err := store.ListMigrations(ctx, conn) require.NoError(t, err) require.Empty(t, res) return nil }) require.NoError(t, err) // Try to get a migration that does not exist. err = runConn(ctx, db, func(conn *sql.Conn) error { _, err := store.GetMigration(ctx, conn, 0) require.Error(t, err) require.ErrorIs(t, err, database.ErrVersionNotFound) return nil }) require.NoError(t, err) } func runTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) (retErr error) { tx, err := db.BeginTx(ctx, nil) if err != nil { return err } defer func() { if retErr != nil { retErr = multierr.Append(retErr, tx.Rollback()) } }() if err := fn(tx); err != nil { return err } return tx.Commit() } func runConn(ctx context.Context, db *sql.DB, fn func(*sql.Conn) error) (retErr error) { conn, err := db.Conn(ctx) if err != nil { return err } defer func() { if retErr != nil { retErr = multierr.Append(retErr, conn.Close()) } }() if err := fn(conn); err != nil { return err } return conn.Close() } ================================================ FILE: db.go ================================================ package goose import ( "database/sql" "fmt" ) // OpenDBWithDriver creates a connection to a database, and modifies goose internals to be // compatible with the supplied driver by calling SetDialect. func OpenDBWithDriver(driver string, dbstring string) (*sql.DB, error) { if err := SetDialect(driver); err != nil { return nil, err } // The Go ecosystem has added more and more drivers over the years. As a result, there's no // longer a one-to-one match between the driver name and the dialect name. For instance, there's // no "redshift" driver, but that's the internal dialect name within goose. Hence, we need to // convert the dialect name to a supported driver name. This conversion is a best-effort // attempt, as we can't support both lib/pq and pgx, which some users might have. // // We recommend users to create a [NewProvider] with the desired dialect, open a connection // using their preferred driver, and provide the *sql.DB to goose. This approach removes the // need for mapping dialects to drivers, rendering this function unnecessary. switch driver { case "mssql": driver = "sqlserver" case "tidb": driver = "mysql" case "spanner": driver = "spanner" case "turso": driver = "libsql" case "sqlite3": driver = "sqlite" case "postgres", "redshift": driver = "pgx" case "starrocks": driver = "mysql" } switch driver { case "postgres", "pgx", "sqlite3", "sqlite", "spanner", "mysql", "sqlserver", "clickhouse", "vertica", "azuresql", "ydb", "libsql", "starrocks": return sql.Open(driver, dbstring) default: return nil, fmt.Errorf("unsupported driver %s", driver) } } ================================================ FILE: dialect.go ================================================ package goose import ( "fmt" "github.com/pressly/goose/v3/database" "github.com/pressly/goose/v3/internal/legacystore" ) // Dialect is the type of database dialect. It is an alias for [database.Dialect]. type Dialect = database.Dialect const ( DialectCustom Dialect = database.DialectCustom DialectClickHouse Dialect = database.DialectClickHouse DialectMSSQL Dialect = database.DialectMSSQL DialectMySQL Dialect = database.DialectMySQL DialectPostgres Dialect = database.DialectPostgres DialectRedshift Dialect = database.DialectRedshift DialectSQLite3 Dialect = database.DialectSQLite3 DialectSpanner Dialect = database.DialectSpanner DialectStarrocks Dialect = database.DialectStarrocks DialectTiDB Dialect = database.DialectTiDB DialectTurso Dialect = database.DialectTurso DialectYdB Dialect = database.DialectYdB // Dialects only available to the [Provider]. DialectAuroraDSQL Dialect = database.DialectAuroraDSQL // DEPRECATED: Vertica support is deprecated and will be removed in a future release. DialectVertica Dialect = database.DialectVertica ) func init() { store, _ = legacystore.NewStore(DialectPostgres) } var store legacystore.Store // SetDialect sets the dialect to use for the goose package. func SetDialect(s string) error { var d Dialect switch s { case "postgres", "pgx": d = DialectPostgres case "mysql": d = DialectMySQL case "sqlite3", "sqlite": d = DialectSQLite3 case "spanner": d = DialectSpanner case "mssql", "azuresql", "sqlserver": d = DialectMSSQL case "redshift": d = DialectRedshift case "tidb": d = DialectTiDB case "clickhouse": d = DialectClickHouse case "vertica": d = DialectVertica case "ydb": d = DialectYdB case "turso": d = DialectTurso case "starrocks": d = DialectStarrocks default: return fmt.Errorf("%q: unknown dialect", s) } var err error store, err = legacystore.NewStore(d) return err } ================================================ FILE: down.go ================================================ package goose import ( "context" "database/sql" "fmt" ) // Down rolls back a single migration from the current version. func Down(db *sql.DB, dir string, opts ...OptionsFunc) error { ctx := context.Background() return DownContext(ctx, db, dir, opts...) } // DownContext rolls back a single migration from the current version. func DownContext(ctx context.Context, db *sql.DB, dir string, opts ...OptionsFunc) error { option := &options{} for _, f := range opts { f(option) } migrations, err := CollectMigrations(dir, minVersion, maxVersion) if err != nil { return err } if option.noVersioning { if len(migrations) == 0 { return nil } currentVersion := migrations[len(migrations)-1].Version // Migrate only the latest migration down. return downToNoVersioning(ctx, db, migrations, currentVersion-1) } currentVersion, err := GetDBVersionContext(ctx, db) if err != nil { return err } current, err := migrations.Current(currentVersion) if err != nil { return fmt.Errorf("migration %v: %w", currentVersion, err) } return current.DownContext(ctx, db) } // DownTo rolls back migrations to a specific version. func DownTo(db *sql.DB, dir string, version int64, opts ...OptionsFunc) error { ctx := context.Background() return DownToContext(ctx, db, dir, version, opts...) } // DownToContext rolls back migrations to a specific version. func DownToContext(ctx context.Context, db *sql.DB, dir string, version int64, opts ...OptionsFunc) error { option := &options{} for _, f := range opts { f(option) } migrations, err := CollectMigrations(dir, minVersion, maxVersion) if err != nil { return err } if option.noVersioning { return downToNoVersioning(ctx, db, migrations, version) } for { currentVersion, err := GetDBVersionContext(ctx, db) if err != nil { return err } if currentVersion == 0 { log.Printf("goose: no migrations to run. current version: %d", currentVersion) return nil } current, err := migrations.Current(currentVersion) if err != nil { log.Printf("goose: migration file not found for current version (%d), error: %s", currentVersion, err) return err } if current.Version <= version { log.Printf("goose: no migrations to run. current version: %d", currentVersion) return nil } if err = current.DownContext(ctx, db); err != nil { return err } } } // downToNoVersioning applies down migrations down to, but not including, the // target version. func downToNoVersioning(ctx context.Context, db *sql.DB, migrations Migrations, version int64) error { var finalVersion int64 for i := len(migrations) - 1; i >= 0; i-- { if version >= migrations[i].Version { finalVersion = migrations[i].Version break } migrations[i].noVersioning = true if err := migrations[i].DownContext(ctx, db); err != nil { return err } } log.Printf("goose: down to current file version: %d", finalVersion) return nil } ================================================ FILE: examples/README.md ================================================ # 1. [SQL migrations](sql-migrations) # 2. [Go migrations](go-migrations) ================================================ FILE: examples/go-migrations/00001_create_users_table.sql ================================================ -- +goose Up CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, name TEXT, surname TEXT ); INSERT INTO users VALUES (0, 'root', '', ''), (1, 'vojtechvitek', 'Vojtech', 'Vitek'); -- +goose Down DROP TABLE users; ================================================ FILE: examples/go-migrations/00002_rename_root.go ================================================ package main import ( "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationContext(Up00002, Down00002) } func Up00002(ctx context.Context, tx *sql.Tx) error { _, err := tx.ExecContext(ctx, "UPDATE users SET username='admin' WHERE username='root';") return err } func Down00002(ctx context.Context, tx *sql.Tx) error { _, err := tx.ExecContext(ctx, "UPDATE users SET username='root' WHERE username='admin';") return err } ================================================ FILE: examples/go-migrations/00003_add_user_no_tx.go ================================================ package main import ( "context" "database/sql" "errors" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationNoTxContext(Up00003, Down00003) } func Up00003(ctx context.Context, db *sql.DB) error { id, err := getUserID(db, "jamesbond") if err != nil { return err } if id == 0 { query := "INSERT INTO users (username, name, surname) VALUES ($1, $2, $3)" if _, err := db.ExecContext(ctx, query, "jamesbond", "James", "Bond"); err != nil { return err } } return nil } func getUserID(db *sql.DB, username string) (int, error) { var id int err := db.QueryRow("SELECT id FROM users WHERE username = $1", username).Scan(&id) if err != nil && !errors.Is(err, sql.ErrNoRows) { return 0, err } return id, nil } func Down00003(ctx context.Context, db *sql.DB) error { query := "DELETE FROM users WHERE username = $1" if _, err := db.ExecContext(ctx, query, "jamesbond"); err != nil { return err } return nil } ================================================ FILE: examples/go-migrations/README.md ================================================ # SQL + Go migrations ## This example: Custom goose binary with built-in Go migrations ```bash $ go build -o goose-custom *.go ``` ```bash $ ./goose-custom sqlite3 ./foo.db status Applied At Migration ======================================= Pending -- 00001_create_users_table.sql Pending -- 00002_rename_root.go Pending -- 00003_add_user_no_tx.go $ ./goose-custom sqlite3 ./foo.db up OK 00001_create_users_table.sql (711.58µs) OK 00002_rename_root.go (302.08µs) OK 00003_add_user_no_tx.go (648.71µs) goose: no migrations to run. current version: 3 $ ./goose-custom sqlite3 ./foo.db status Applied At Migration ======================================= 00001_create_users_table.sql 00002_rename_root.go 00003_add_user_no_tx.go ``` ## Best practice: Split migrations into a standalone package 1. Move [main.go](main.go) into your `cmd/` directory 2. Rename package name in all `*_.go` migration files from `main` to `migrations`. 3. Import this `migrations` package from your custom [cmd/main.go](main.go) file: ```go import ( // Invoke init() functions within migrations pkg. _ "github.com/pressly/goose/example/migrations-go" ) ``` ================================================ FILE: examples/go-migrations/main.go ================================================ // This is custom goose binary with sqlite3 support only. package main import ( "context" "flag" "log" "os" "github.com/pressly/goose/v3" _ "modernc.org/sqlite" ) var ( flags = flag.NewFlagSet("goose", flag.ExitOnError) dir = flags.String("dir", ".", "directory with migration files") ) func main() { if err := flags.Parse(os.Args[1:]); err != nil { log.Fatalf("goose: failed to parse flags: %v", err) } args := flags.Args() if len(args) < 3 { flags.Usage() return } dbstring, command := args[1], args[2] db, err := goose.OpenDBWithDriver("sqlite", dbstring) if err != nil { log.Fatalf("goose: failed to open DB: %v", err) } defer func() { if err := db.Close(); err != nil { log.Fatalf("goose: failed to close DB: %v", err) } }() arguments := []string{} if len(args) > 3 { arguments = append(arguments, args[3:]...) } ctx := context.Background() if err := goose.RunContext(ctx, command, db, *dir, arguments...); err != nil { log.Fatalf("goose %v: %v", command, err) } } ================================================ FILE: examples/sql-migrations/00001_create_users_table.sql ================================================ -- +goose Up CREATE TABLE users ( id int NOT NULL PRIMARY KEY, username text, name text, surname text ); INSERT INTO users VALUES (0, 'root', '', ''), (1, 'vojtechvitek', 'Vojtech', 'Vitek'); -- +goose Down DROP TABLE users; ================================================ FILE: examples/sql-migrations/00002_rename_root.sql ================================================ -- +goose Up -- +goose StatementBegin UPDATE users SET username='admin' WHERE username='root'; -- +goose StatementEnd -- +goose Down -- +goose StatementBegin UPDATE users SET username='root' WHERE username='admin'; -- +goose StatementEnd ================================================ FILE: examples/sql-migrations/00003_no_transaction.sql ================================================ -- +goose NO TRANSACTION -- +goose Up CREATE TABLE post ( id int NOT NULL, title text, body text, PRIMARY KEY(id) ); -- +goose Down DROP TABLE post; ================================================ FILE: examples/sql-migrations/README.md ================================================ # SQL migrations only See [this example](../go-migrations) for Go migrations. ```bash $ go install github.com/pressly/goose/v3/cmd/goose@latest ``` ```bash $ goose sqlite3 ./foo.db status Applied At Migration ======================================= Pending -- 00001_create_users_table.sql Pending -- 00002_rename_root.sql $ goose sqlite3 ./foo.db up OK 00001_create_users_table.sql OK 00002_rename_root.sql goose: no migrations to run. current version: 2 $ goose sqlite3 ./foo.db status Applied At Migration ======================================= Mon Jun 19 21:56:00 2017 -- 00001_create_users_table.sql Mon Jun 19 21:56:00 2017 -- 00002_rename_root.sql ``` ================================================ FILE: fix.go ================================================ package goose import ( "fmt" "os" "path/filepath" "strings" ) const seqVersionTemplate = "%05v" func Fix(dir string) error { // always use osFS here because it's modifying operation migrations, err := collectMigrationsFS(osFS{}, dir, minVersion, maxVersion, registeredGoMigrations) if err != nil { return err } // split into timestamped and versioned migrations tsMigrations, err := migrations.timestamped() if err != nil { return err } vMigrations, err := migrations.versioned() if err != nil { return err } // Initial version. version := int64(1) if last, err := vMigrations.Last(); err == nil { version = last.Version + 1 } // fix filenames by replacing timestamps with sequential versions for _, tsm := range tsMigrations { oldPath := tsm.Source newPath := strings.Replace( oldPath, fmt.Sprintf("%d", tsm.Version), fmt.Sprintf(seqVersionTemplate, version), 1, ) if err := os.Rename(oldPath, newPath); err != nil { return err } log.Printf("RENAMED %s => %s", filepath.Base(oldPath), filepath.Base(newPath)) version++ } return nil } ================================================ FILE: fix_test.go ================================================ package goose import ( "fmt" "os" "os/exec" "strings" "testing" "time" ) func TestFix(t *testing.T) { t.Parallel() if testing.Short() { t.Skip("skip long running test") } dir := t.TempDir() defer os.Remove("./bin/fix-goose") // clean up commands := []string{ "go build -o ./bin/fix-goose ./cmd/goose", fmt.Sprintf("./bin/fix-goose -dir=%s create create_table", dir), fmt.Sprintf("./bin/fix-goose -dir=%s create add_users", dir), fmt.Sprintf("./bin/fix-goose -dir=%s create add_indices", dir), fmt.Sprintf("./bin/fix-goose -dir=%s create update_users", dir), fmt.Sprintf("./bin/fix-goose -dir=%s fix", dir), } for _, cmd := range commands { args := strings.Split(cmd, " ") time.Sleep(1 * time.Second) cmd := exec.Command(args[0], args[1:]...) cmd.Env = os.Environ() out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("%s:\n%v\n\n%s", err, cmd, out) } } files, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } // check that the files are in order for i, f := range files { expected := fmt.Sprintf("%05v", i+1) if !strings.HasPrefix(f.Name(), expected) { t.Errorf("failed to find %s prefix in %s", expected, f.Name()) } } // add more migrations and then fix it commands = []string{ fmt.Sprintf("./bin/fix-goose -dir=%s create remove_column", dir), fmt.Sprintf("./bin/fix-goose -dir=%s create create_books_table", dir), fmt.Sprintf("./bin/fix-goose -dir=%s fix", dir), } for _, cmd := range commands { args := strings.Split(cmd, " ") time.Sleep(1 * time.Second) out, err := exec.Command(args[0], args[1:]...).CombinedOutput() if err != nil { t.Fatalf("%s:\n%v\n\n%s", err, cmd, out) } } files, err = os.ReadDir(dir) if err != nil { t.Fatal(err) } // check that the files still in order for i, f := range files { expected := fmt.Sprintf("%05v", i+1) if !strings.HasPrefix(f.Name(), expected) { t.Errorf("failed to find %s prefix in %s", expected, f.Name()) } } } ================================================ FILE: globals.go ================================================ package goose import ( "errors" "fmt" "path/filepath" ) var ( registeredGoMigrations = make(map[int64]*Migration) ) // ResetGlobalMigrations resets the global Go migrations registry. // // Not safe for concurrent use. func ResetGlobalMigrations() { registeredGoMigrations = make(map[int64]*Migration) } // SetGlobalMigrations registers Go migrations globally. It returns an error if a migration with the // same version has already been registered. Go migrations must be constructed using the // [NewGoMigration] function. // // Not safe for concurrent use. func SetGlobalMigrations(migrations ...*Migration) error { for _, m := range migrations { if _, ok := registeredGoMigrations[m.Version]; ok { return fmt.Errorf("go migration with version %d already registered", m.Version) } if err := checkGoMigration(m); err != nil { return fmt.Errorf("invalid go migration: %w", err) } registeredGoMigrations[m.Version] = m } return nil } func checkGoMigration(m *Migration) error { if !m.construct { return errors.New("must use NewGoMigration to construct migrations") } if !m.Registered { return errors.New("must be registered") } if m.Type != TypeGo { return fmt.Errorf("type must be %q", TypeGo) } if m.Version < 1 { return errors.New("version must be greater than zero") } if m.Source != "" { if filepath.Ext(m.Source) != ".go" { return fmt.Errorf("source must have .go extension: %q", m.Source) } // If the source is set, expect it to be a path with a numeric component that matches the // version. This field is not intended to be used for descriptive purposes. version, err := NumericComponent(m.Source) if err != nil { return fmt.Errorf("invalid source: %w", err) } if version != m.Version { return fmt.Errorf("version:%d does not match numeric component in source %q", m.Version, m.Source) } } if err := checkGoFunc(m.goUp); err != nil { return fmt.Errorf("up function: %w", err) } if err := checkGoFunc(m.goDown); err != nil { return fmt.Errorf("down function: %w", err) } if m.UpFnContext != nil && m.UpFnNoTxContext != nil { return errors.New("must specify exactly one of UpFnContext or UpFnNoTxContext") } if m.UpFn != nil && m.UpFnNoTx != nil { return errors.New("must specify exactly one of UpFn or UpFnNoTx") } if m.DownFnContext != nil && m.DownFnNoTxContext != nil { return errors.New("must specify exactly one of DownFnContext or DownFnNoTxContext") } if m.DownFn != nil && m.DownFnNoTx != nil { return errors.New("must specify exactly one of DownFn or DownFnNoTx") } return nil } func checkGoFunc(f *GoFunc) error { if f.RunTx != nil && f.RunDB != nil { return errors.New("must specify exactly one of RunTx or RunDB") } switch f.Mode { case TransactionEnabled, TransactionDisabled: // No functions, but mode is set. This is not an error. It means the user wants to // record a version with the given mode but not run any functions. default: return fmt.Errorf("invalid mode: %d", f.Mode) } if f.RunDB != nil && f.Mode != TransactionDisabled { return fmt.Errorf("transaction mode must be disabled or unspecified when RunDB is set") } if f.RunTx != nil && f.Mode != TransactionEnabled { return fmt.Errorf("transaction mode must be enabled or unspecified when RunTx is set") } return nil } ================================================ FILE: globals_test.go ================================================ package goose import ( "context" "database/sql" "testing" "github.com/stretchr/testify/require" ) func TestNewGoMigration(t *testing.T) { t.Run("valid_both_nil", func(t *testing.T) { m := NewGoMigration(1, nil, nil) // roundtrip require.EqualValues(t, 1, m.Version) require.Equal(t, TypeGo, m.Type) require.True(t, m.Registered) require.EqualValues(t, -1, m.Next) require.EqualValues(t, -1, m.Previous) require.Empty(t, m.Source) require.Nil(t, m.UpFnNoTxContext) require.Nil(t, m.DownFnNoTxContext) require.Nil(t, m.UpFnContext) require.Nil(t, m.DownFnContext) require.Nil(t, m.UpFn) require.Nil(t, m.DownFn) require.Nil(t, m.UpFnNoTx) require.Nil(t, m.DownFnNoTx) require.NotNil(t, m.goUp) require.NotNil(t, m.goDown) require.Equal(t, TransactionEnabled, m.goUp.Mode) require.Equal(t, TransactionEnabled, m.goDown.Mode) }) t.Run("all_set", func(t *testing.T) { // This will eventually be an error when registering migrations. m := NewGoMigration( 1, &GoFunc{RunTx: func(context.Context, *sql.Tx) error { return nil }, RunDB: func(context.Context, *sql.DB) error { return nil }}, &GoFunc{RunTx: func(context.Context, *sql.Tx) error { return nil }, RunDB: func(context.Context, *sql.DB) error { return nil }}, ) // check only functions require.NotNil(t, m.UpFn) require.NotNil(t, m.UpFnContext) require.NotNil(t, m.UpFnNoTx) require.NotNil(t, m.UpFnNoTxContext) require.NotNil(t, m.DownFn) require.NotNil(t, m.DownFnContext) require.NotNil(t, m.DownFnNoTx) require.NotNil(t, m.DownFnNoTxContext) }) } func TestTransactionMode(t *testing.T) { t.Cleanup(ResetGlobalMigrations) runDB := func(context.Context, *sql.DB) error { return nil } runTx := func(context.Context, *sql.Tx) error { return nil } err := SetGlobalMigrations( NewGoMigration(1, &GoFunc{RunTx: runTx, RunDB: runDB}, nil), // cannot specify both ) require.Error(t, err) require.Contains(t, err.Error(), "up function: must specify exactly one of RunTx or RunDB") err = SetGlobalMigrations( NewGoMigration(1, nil, &GoFunc{RunTx: runTx, RunDB: runDB}), // cannot specify both ) require.Error(t, err) require.Contains(t, err.Error(), "down function: must specify exactly one of RunTx or RunDB") err = SetGlobalMigrations( NewGoMigration(1, &GoFunc{RunTx: runTx, Mode: TransactionDisabled}, nil), // invalid explicit mode tx ) require.Error(t, err) require.Contains(t, err.Error(), "up function: transaction mode must be enabled or unspecified when RunTx is set") err = SetGlobalMigrations( NewGoMigration(1, nil, &GoFunc{RunTx: runTx, Mode: TransactionDisabled}), // invalid explicit mode tx ) require.Error(t, err) require.Contains(t, err.Error(), "down function: transaction mode must be enabled or unspecified when RunTx is set") err = SetGlobalMigrations( NewGoMigration(1, &GoFunc{RunDB: runDB, Mode: TransactionEnabled}, nil), // invalid explicit mode no-tx ) require.Error(t, err) require.Contains(t, err.Error(), "up function: transaction mode must be disabled or unspecified when RunDB is set") err = SetGlobalMigrations( NewGoMigration(1, nil, &GoFunc{RunDB: runDB, Mode: TransactionEnabled}), // invalid explicit mode no-tx ) require.Error(t, err) require.Contains(t, err.Error(), "down function: transaction mode must be disabled or unspecified when RunDB is set") t.Run("default_mode", func(t *testing.T) { t.Cleanup(ResetGlobalMigrations) m := NewGoMigration(1, nil, nil) err = SetGlobalMigrations(m) require.NoError(t, err) require.Len(t, registeredGoMigrations, 1) registered := registeredGoMigrations[1] require.NotNil(t, registered.goUp) require.NotNil(t, registered.goDown) require.Equal(t, TransactionEnabled, registered.goUp.Mode) require.Equal(t, TransactionEnabled, registered.goDown.Mode) migration2 := NewGoMigration(2, nil, nil) // reset so we can check the default is set migration2.goUp.Mode, migration2.goDown.Mode = 0, 0 err = SetGlobalMigrations(migration2) require.Error(t, err) require.Contains(t, err.Error(), "invalid go migration: up function: invalid mode: 0") migration3 := NewGoMigration(3, nil, nil) // reset so we can check the default is set migration3.goDown.Mode = 0 err = SetGlobalMigrations(migration3) require.Error(t, err) require.Contains(t, err.Error(), "invalid go migration: down function: invalid mode: 0") }) t.Run("unknown_mode", func(t *testing.T) { m := NewGoMigration(1, nil, nil) m.goUp.Mode, m.goDown.Mode = 3, 3 // reset to default err := SetGlobalMigrations(m) require.Error(t, err) require.Contains(t, err.Error(), "invalid mode: 3") }) } func TestLegacyFunctions(t *testing.T) { t.Cleanup(ResetGlobalMigrations) runDB := func(context.Context, *sql.DB) error { return nil } runTx := func(context.Context, *sql.Tx) error { return nil } assertMigration := func(t *testing.T, m *Migration, version int64) { t.Helper() require.Equal(t, version, m.Version) require.Equal(t, TypeGo, m.Type) require.True(t, m.Registered) require.EqualValues(t, -1, m.Next) require.EqualValues(t, -1, m.Previous) require.Empty(t, m.Source) } t.Run("all_tx", func(t *testing.T) { t.Cleanup(ResetGlobalMigrations) err := SetGlobalMigrations( NewGoMigration(1, &GoFunc{RunTx: runTx}, &GoFunc{RunTx: runTx}), ) require.NoError(t, err) require.Len(t, registeredGoMigrations, 1) m := registeredGoMigrations[1] assertMigration(t, m, 1) // Legacy functions. require.Nil(t, m.UpFnNoTxContext) require.Nil(t, m.DownFnNoTxContext) // Context-aware functions. require.NotNil(t, m.goUp) require.NotNil(t, m.UpFnContext) require.NotNil(t, m.goDown) require.NotNil(t, m.DownFnContext) // Always nil require.NotNil(t, m.UpFn) require.NotNil(t, m.DownFn) require.Nil(t, m.UpFnNoTx) require.Nil(t, m.DownFnNoTx) }) t.Run("all_db", func(t *testing.T) { t.Cleanup(ResetGlobalMigrations) err := SetGlobalMigrations( NewGoMigration(2, &GoFunc{RunDB: runDB}, &GoFunc{RunDB: runDB}), ) require.NoError(t, err) require.Len(t, registeredGoMigrations, 1) m := registeredGoMigrations[2] assertMigration(t, m, 2) // Legacy functions. require.NotNil(t, m.UpFnNoTxContext) require.NotNil(t, m.goUp) require.NotNil(t, m.DownFnNoTxContext) require.NotNil(t, m.goDown) // Context-aware functions. require.Nil(t, m.UpFnContext) require.Nil(t, m.DownFnContext) // Always nil require.Nil(t, m.UpFn) require.Nil(t, m.DownFn) require.NotNil(t, m.UpFnNoTx) require.NotNil(t, m.DownFnNoTx) }) } func TestGlobalRegister(t *testing.T) { t.Cleanup(ResetGlobalMigrations) // runDB := func(context.Context, *sql.DB) error { return nil } runTx := func(context.Context, *sql.Tx) error { return nil } // Success. err := SetGlobalMigrations([]*Migration{}...) require.NoError(t, err) err = SetGlobalMigrations( NewGoMigration(1, &GoFunc{RunTx: runTx}, nil), ) require.NoError(t, err) // Try to register the same migration again. err = SetGlobalMigrations( NewGoMigration(1, &GoFunc{RunTx: runTx}, nil), ) require.Error(t, err) require.Contains(t, err.Error(), "go migration with version 1 already registered") err = SetGlobalMigrations(&Migration{Registered: true, Version: 2, Type: TypeGo}) require.Error(t, err) require.Contains(t, err.Error(), "must use NewGoMigration to construct migrations") } func TestCheckMigration(t *testing.T) { // Success. err := checkGoMigration(NewGoMigration(1, nil, nil)) require.NoError(t, err) // Failures. err = checkGoMigration(&Migration{}) require.Error(t, err) require.Contains(t, err.Error(), "must use NewGoMigration to construct migrations") err = checkGoMigration(&Migration{construct: true}) require.Error(t, err) require.Contains(t, err.Error(), "must be registered") err = checkGoMigration(&Migration{construct: true, Registered: true}) require.Error(t, err) require.Contains(t, err.Error(), `type must be "go"`) err = checkGoMigration(&Migration{construct: true, Registered: true, Type: TypeGo}) require.Error(t, err) require.Contains(t, err.Error(), "version must be greater than zero") err = checkGoMigration(&Migration{construct: true, Registered: true, Type: TypeGo, Version: 1, goUp: &GoFunc{}, goDown: &GoFunc{}}) require.Error(t, err) require.Contains(t, err.Error(), "up function: invalid mode: 0") err = checkGoMigration(&Migration{construct: true, Registered: true, Type: TypeGo, Version: 1, goUp: &GoFunc{Mode: TransactionEnabled}, goDown: &GoFunc{}}) require.Error(t, err) require.Contains(t, err.Error(), "down function: invalid mode: 0") // Success. err = checkGoMigration(&Migration{construct: true, Registered: true, Type: TypeGo, Version: 1, goUp: &GoFunc{Mode: TransactionEnabled}, goDown: &GoFunc{Mode: TransactionEnabled}}) require.NoError(t, err) // Failures. err = checkGoMigration(&Migration{construct: true, Registered: true, Type: TypeGo, Version: 1, Source: "foo"}) require.Error(t, err) require.Contains(t, err.Error(), `source must have .go extension: "foo"`) err = checkGoMigration(&Migration{construct: true, Registered: true, Type: TypeGo, Version: 1, Source: "foo.go"}) require.Error(t, err) require.Contains(t, err.Error(), `no filename separator '_' found`) err = checkGoMigration(&Migration{construct: true, Registered: true, Type: TypeGo, Version: 2, Source: "00001_foo.sql"}) require.Error(t, err) require.Contains(t, err.Error(), `source must have .go extension: "00001_foo.sql"`) err = checkGoMigration(&Migration{construct: true, Registered: true, Type: TypeGo, Version: 2, Source: "00001_foo.go"}) require.Error(t, err) require.Contains(t, err.Error(), `version:2 does not match numeric component in source "00001_foo.go"`) err = checkGoMigration(&Migration{construct: true, Registered: true, Type: TypeGo, Version: 1, UpFnContext: func(context.Context, *sql.Tx) error { return nil }, UpFnNoTxContext: func(context.Context, *sql.DB) error { return nil }, goUp: &GoFunc{Mode: TransactionEnabled}, goDown: &GoFunc{Mode: TransactionEnabled}, }) require.Error(t, err) require.Contains(t, err.Error(), "must specify exactly one of UpFnContext or UpFnNoTxContext") err = checkGoMigration(&Migration{construct: true, Registered: true, Type: TypeGo, Version: 1, DownFnContext: func(context.Context, *sql.Tx) error { return nil }, DownFnNoTxContext: func(context.Context, *sql.DB) error { return nil }, goUp: &GoFunc{Mode: TransactionEnabled}, goDown: &GoFunc{Mode: TransactionEnabled}, }) require.Error(t, err) require.Contains(t, err.Error(), "must specify exactly one of DownFnContext or DownFnNoTxContext") err = checkGoMigration(&Migration{construct: true, Registered: true, Type: TypeGo, Version: 1, UpFn: func(*sql.Tx) error { return nil }, UpFnNoTx: func(*sql.DB) error { return nil }, goUp: &GoFunc{Mode: TransactionEnabled}, goDown: &GoFunc{Mode: TransactionEnabled}, }) require.Error(t, err) require.Contains(t, err.Error(), "must specify exactly one of UpFn or UpFnNoTx") err = checkGoMigration(&Migration{construct: true, Registered: true, Type: TypeGo, Version: 1, DownFn: func(*sql.Tx) error { return nil }, DownFnNoTx: func(*sql.DB) error { return nil }, goUp: &GoFunc{Mode: TransactionEnabled}, goDown: &GoFunc{Mode: TransactionEnabled}, }) require.Error(t, err) require.Contains(t, err.Error(), "must specify exactly one of DownFn or DownFnNoTx") } ================================================ FILE: go.mod ================================================ module github.com/pressly/goose/v3 go 1.25.0 require ( github.com/ClickHouse/clickhouse-go/v2 v2.43.0 github.com/containerd/errdefs v1.0.0 github.com/go-sql-driver/mysql v1.9.3 github.com/jackc/pgx/v5 v5.8.0 github.com/joho/godotenv v1.5.1 github.com/mfridman/interpolate v0.0.2 github.com/mfridman/xflag v0.1.0 github.com/microsoft/go-mssqldb v1.9.6 github.com/moby/moby/api v1.54.0 github.com/moby/moby/client v0.3.0 github.com/sethvargo/go-retry v0.3.0 github.com/stretchr/testify v1.11.1 github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc github.com/vertica/vertica-sql-go v1.3.5 github.com/ydb-platform/ydb-go-sdk/v3 v3.127.0 github.com/ziutek/mymysql v1.5.4 go.uber.org/multierr v1.11.0 golang.org/x/sync v0.20.0 modernc.org/sqlite v1.46.1 ) require ( filippo.io/edwards25519 v1.2.0 // indirect github.com/ClickHouse/ch-go v0.71.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/elastic/go-sysinfo v1.15.4 // indirect github.com/elastic/go-windows v1.0.2 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // 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/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/paulmach/orb v0.12.0 // indirect github.com/pierrec/lz4/v4 v4.1.25 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/ydb-platform/ydb-go-genproto v0.0.0-20260128080146-c4ed16b24b37 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/net v0.50.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect howett.net/plist v1.0.1 // indirect modernc.org/libc v1.68.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) retract ( v3.21.0 // Invalid replace directives v3.12.2 // Invalid module reference v3.12.1 // Invalid module reference v3.12.0 // Invalid module reference ) ================================================ FILE: go.sum ================================================ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 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.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= github.com/ClickHouse/clickhouse-go/v2 v2.43.0 h1:fUR05TrF1GyvLDa/mAQjkx7KbgwdLRffs2n9O3WobtE= github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= 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= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elastic/go-sysinfo v1.8.1/go.mod h1:JfllUnzoQV/JRYymbH3dO1yggI3mV2oTKSXsDHM+uIM= github.com/elastic/go-sysinfo v1.15.4 h1:A3zQcunCxik14MgXu39cXFXcIw2sFXZ0zL886eyiv1Q= github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU= github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU= github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI= github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 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/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mfridman/xflag v0.1.0 h1:TWZrZwG1QklFX5S4j1vxfF1sZbZeZSGofMwPMLAF29M= github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE= github.com/microsoft/go-mssqldb v1.9.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw= github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/moby/api v1.54.0 h1:7kbUgyiKcoBhm0UrWbdrMs7RX8dnwzURKVbZGy2GnL0= github.com/moby/moby/api v1.54.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= github.com/moby/moby/client v0.3.0 h1:UUGL5okry+Aomj3WhGt9Aigl3ZOxZGqR7XPo+RLPlKs= github.com/moby/moby/client v0.3.0/go.mod h1:HJgFbJRvogDQjbM8fqc1MCEm4mIAGMLjXbgwoZp6jCQ= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s= github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= 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/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/procfs v0.0.0-20190425082905-87a4384529e0/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/rekby/fixenv v0.6.1 h1:jUFiSPpajT4WY2cYuc++7Y1zWrnCxnovGCIX72PZniM= github.com/rekby/fixenv v0.6.1/go.mod h1:/b5LRc06BYJtslRtHKxsPWFT/ySpHV+rWvzTg+XWk4c= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc h1:lzi/5fg2EfinRlh3v//YyIhnc4tY7BTqazQGwb1ar+0= github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU= github.com/vertica/vertica-sql-go v1.3.5 h1:IrfH2WIgzZ45yDHyjVFrXU2LuKNIjF5Nwi90a6cfgUI= github.com/vertica/vertica-sql-go v1.3.5/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/ydb-platform/ydb-go-genproto v0.0.0-20260128080146-c4ed16b24b37 h1:kUXMT/fM/DpDT66WQgRUf3I8VOAWjypkMf52W5PChwA= github.com/ydb-platform/ydb-go-genproto v0.0.0-20260128080146-c4ed16b24b37/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= github.com/ydb-platform/ydb-go-sdk/v3 v3.127.0 h1:OfHS9ZkZgCy6y/CJ9N8123DXrgaY2BPxWsQiQ8e3wC8= github.com/ydb-platform/ydb-go-sdk/v3 v3.127.0/go.mod h1:stS1mQYjbJvwwYaYzKyFY9eMiuVXWWXQA6T+SpOLg9c= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so= modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= ================================================ FILE: goose.go ================================================ package goose import ( "context" "database/sql" "fmt" "io/fs" "strconv" ) // Deprecated: VERSION will no longer be supported in the next major release. const VERSION = "v3.18.0" var ( minVersion = int64(0) maxVersion = int64((1 << 63) - 1) timestampFormat = "20060102150405" verbose = false noColor = false // base fs to lookup migrations baseFS fs.FS = osFS{} ) // SetVerbose set the goose verbosity mode func SetVerbose(v bool) { verbose = v } // SetBaseFS sets a base FS to discover migrations. It can be used with 'embed' package. // Calling with 'nil' argument leads to default behaviour: discovering migrations from os filesystem. // Note that modifying operations like Create will use os filesystem anyway. func SetBaseFS(fsys fs.FS) { if fsys == nil { fsys = osFS{} } baseFS = fsys } // Run runs a goose command. // // Deprecated: Use RunContext. func Run(command string, db *sql.DB, dir string, args ...string) error { ctx := context.Background() return RunContext(ctx, command, db, dir, args...) } // RunContext runs a goose command. func RunContext(ctx context.Context, command string, db *sql.DB, dir string, args ...string) error { return run(ctx, command, db, dir, args) } // RunWithOptions runs a goose command with options. // // Deprecated: Use RunWithOptionsContext. func RunWithOptions(command string, db *sql.DB, dir string, args []string, options ...OptionsFunc) error { ctx := context.Background() return RunWithOptionsContext(ctx, command, db, dir, args, options...) } // RunWithOptionsContext runs a goose command with options. func RunWithOptionsContext(ctx context.Context, command string, db *sql.DB, dir string, args []string, options ...OptionsFunc) error { return run(ctx, command, db, dir, args, options...) } func run(ctx context.Context, command string, db *sql.DB, dir string, args []string, options ...OptionsFunc) error { switch command { case "up": if err := UpContext(ctx, db, dir, options...); err != nil { return err } case "up-by-one": if err := UpByOneContext(ctx, db, dir, options...); err != nil { return err } case "up-to": if len(args) == 0 { return fmt.Errorf("up-to must be of form: goose DRIVER DBSTRING [OPTIONS] up-to VERSION") } version, err := strconv.ParseInt(args[0], 10, 64) if err != nil { return fmt.Errorf("version must be a number (got '%s')", args[0]) } if err := UpToContext(ctx, db, dir, version, options...); err != nil { return err } case "create": if len(args) == 0 { return fmt.Errorf("create must be of form: goose DRIVER DBSTRING [OPTIONS] create NAME [go|sql]") } migrationType := "go" if len(args) == 2 { migrationType = args[1] } if err := Create(db, dir, args[0], migrationType); err != nil { return err } case "down": if err := DownContext(ctx, db, dir, options...); err != nil { return err } case "down-to": if len(args) == 0 { return fmt.Errorf("down-to must be of form: goose DRIVER DBSTRING [OPTIONS] down-to VERSION") } version, err := strconv.ParseInt(args[0], 10, 64) if err != nil { return fmt.Errorf("version must be a number (got '%s')", args[0]) } if err := DownToContext(ctx, db, dir, version, options...); err != nil { return err } case "fix": if err := Fix(dir); err != nil { return err } case "redo": if err := RedoContext(ctx, db, dir, options...); err != nil { return err } case "reset": if err := ResetContext(ctx, db, dir, options...); err != nil { return err } case "status": if err := StatusContext(ctx, db, dir, options...); err != nil { return err } case "version": if err := VersionContext(ctx, db, dir, options...); err != nil { return err } default: return fmt.Errorf("%q: no such command", command) } return nil } ================================================ FILE: goose_cli_test.go ================================================ package goose_test import ( "fmt" "os" "os/exec" "path/filepath" "strconv" "testing" "time" "github.com/stretchr/testify/require" _ "modernc.org/sqlite" ) const ( // gooseTestBinaryVersion is utilized in conjunction with a linker variable to set the version // of a binary created solely for testing purposes. It is used to test the --version flag. gooseTestBinaryVersion = "v0.0.0" ) func TestFullBinary(t *testing.T) { t.Parallel() cli := buildGooseCLI(t, false) out, err := cli.run("--version") require.NoError(t, err) require.Equal(t, "goose version: "+gooseTestBinaryVersion+"\n", out) } func TestLiteBinary(t *testing.T) { t.Parallel() cli := buildGooseCLI(t, true) t.Run("binary_version", func(t *testing.T) { t.Parallel() out, err := cli.run("--version") require.NoError(t, err) require.Equal(t, "goose version: "+gooseTestBinaryVersion+"\n", out) }) t.Run("default_binary", func(t *testing.T) { t.Parallel() dir := t.TempDir() total := countSQLFiles(t, "testdata/migrations") commands := []struct { cmd string out string }{ {"up", "goose: successfully migrated database to version: " + strconv.Itoa(total)}, {"version", "goose: version " + strconv.Itoa(total)}, {"down", "OK"}, {"version", "goose: version " + strconv.Itoa(total-1)}, {"status", ""}, {"reset", "OK"}, {"version", "goose: version 0"}, } for _, c := range commands { out, err := cli.run("-dir=testdata/migrations", "sqlite3", filepath.Join(dir, "sql.db"), c.cmd) require.NoError(t, err) require.Contains(t, out, c.out) } }) t.Run("gh_issue_532", func(t *testing.T) { // https://github.com/pressly/goose/issues/532 t.Parallel() dir := t.TempDir() total := countSQLFiles(t, "testdata/migrations") _, err := cli.run("-dir=testdata/migrations", "sqlite3", filepath.Join(dir, "sql.db"), "up") require.NoError(t, err) out, err := cli.run("-dir=testdata/migrations", "sqlite3", filepath.Join(dir, "sql.db"), "up") require.NoError(t, err) require.Contains(t, out, "goose: no migrations to run. current version: "+strconv.Itoa(total)) out, err = cli.run("-dir=testdata/migrations", "sqlite3", filepath.Join(dir, "sql.db"), "version") require.NoError(t, err) require.Contains(t, out, "goose: version "+strconv.Itoa(total)) }) t.Run("gh_issue_293", func(t *testing.T) { // https://github.com/pressly/goose/issues/293 t.Parallel() dir := t.TempDir() total := countSQLFiles(t, "testdata/migrations") commands := []struct { cmd string out string }{ {"up", "goose: successfully migrated database to version: " + strconv.Itoa(total)}, {"version", "goose: version " + strconv.Itoa(total)}, {"down", "OK"}, {"down", "OK"}, {"version", "goose: version " + strconv.Itoa(total-2)}, {"up", "goose: successfully migrated database to version: " + strconv.Itoa(total)}, {"status", ""}, } for _, c := range commands { out, err := cli.run("-dir=testdata/migrations", "sqlite3", filepath.Join(dir, "sql.db"), c.cmd) require.NoError(t, err) require.Contains(t, out, c.out) } }) t.Run("gh_issue_336", func(t *testing.T) { // https://github.com/pressly/goose/issues/336 t.Parallel() dir := t.TempDir() _, err := cli.run("-dir="+dir, "sqlite3", filepath.Join(dir, "sql.db"), "up") require.Error(t, err) require.Contains(t, err.Error(), "goose run: no migration files found") }) t.Run("create_and_fix", func(t *testing.T) { t.Parallel() dir := t.TempDir() createEmptyFile(t, dir, "00001_alpha.sql") createEmptyFile(t, dir, "00003_bravo.sql") createEmptyFile(t, dir, "20230826163141_charlie.sql") createEmptyFile(t, dir, "20230826163151_delta.go") total, err := os.ReadDir(dir) require.NoError(t, err) require.Len(t, total, 4) migrationFiles := []struct { name string fileType string }{ {"echo", "sql"}, {"foxtrot", "go"}, {"golf", ""}, } for i, f := range migrationFiles { args := []string{"-dir=" + dir, "create", f.name} if f.fileType != "" { args = append(args, f.fileType) } out, err := cli.run(args...) require.NoError(t, err) require.Contains(t, out, "Created new file") // ensure different timestamps, granularity is 1 second if i < len(migrationFiles)-1 { time.Sleep(1100 * time.Millisecond) } } total, err = os.ReadDir(dir) require.NoError(t, err) require.Len(t, total, 7) out, err := cli.run("-dir="+dir, "fix") require.NoError(t, err) require.Contains(t, out, "RENAMED") files, err := os.ReadDir(dir) require.NoError(t, err) require.Len(t, files, 7) expected := []string{ "00001_alpha.sql", "00003_bravo.sql", "00004_charlie.sql", "00005_delta.go", "00006_echo.sql", "00007_foxtrot.go", "00008_golf.go", } for i, f := range files { require.Equal(t, f.Name(), expected[i]) } }) } type gooseBinary struct { binaryPath string } func (g gooseBinary) run(params ...string) (string, error) { cmd := exec.Command(g.binaryPath, params...) out, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("failed to run goose command: %v\nout: %v", err, string(out)) } return string(out), nil } // buildGooseCLI builds goose test binary, which is used for testing goose CLI. It is built with all // drivers enabled, unless lite is true, in which case all drivers are disabled except sqlite3 func buildGooseCLI(t *testing.T, lite bool) gooseBinary { t.Helper() binName := "goose-test" dir := t.TempDir() output := filepath.Join(dir, binName) // usage: go build [-o output] [build flags] [packages] args := []string{ "build", "-o", output, "-ldflags=-s -w -X main.version=" + gooseTestBinaryVersion, } if lite { args = append(args, "-tags=no_clickhouse no_mssql no_mysql no_vertica no_postgres") } args = append(args, "./cmd/goose") build := exec.Command("go", args...) out, err := build.CombinedOutput() if err != nil { t.Fatalf("failed to build %s binary: %v: %s", binName, err, string(out)) } return gooseBinary{ binaryPath: output, } } func countSQLFiles(t *testing.T, dir string) int { t.Helper() files, err := filepath.Glob(filepath.Join(dir, "*.sql")) require.NoError(t, err) return len(files) } func createEmptyFile(t *testing.T, dir, name string) { t.Helper() path := filepath.Join(dir, name) f, err := os.Create(path) require.NoError(t, err) defer f.Close() } ================================================ FILE: goose_embed_test.go ================================================ package goose_test import ( "database/sql" "embed" "io/fs" "os" "path/filepath" "testing" "github.com/pressly/goose/v3" "github.com/stretchr/testify/require" _ "modernc.org/sqlite" ) //go:embed testdata/migrations/*.sql var embedMigrations embed.FS func TestEmbeddedMigrations(t *testing.T) { dir := t.TempDir() // not using t.Parallel here to avoid races db, err := sql.Open("sqlite", filepath.Join(dir, "sql_embed.db")) require.NoError(t, err) db.SetMaxOpenConns(1) migrationFiles, err := fs.ReadDir(embedMigrations, "testdata/migrations") require.NoError(t, err) total := len(migrationFiles) // decouple from existing structure fsys, err := fs.Sub(embedMigrations, "testdata/migrations") require.NoError(t, err) goose.SetBaseFS(fsys) t.Cleanup(func() { goose.SetBaseFS(nil) }) require.NoError(t, goose.SetDialect("sqlite3")) t.Run("migration_cycle", func(t *testing.T) { err := goose.Up(db, ".") require.NoError(t, err) ver, err := goose.GetDBVersion(db) require.NoError(t, err) require.EqualValues(t, ver, total) err = goose.Reset(db, ".") require.NoError(t, err) ver, err = goose.GetDBVersion(db) require.NoError(t, err) require.EqualValues(t, 0, ver) }) t.Run("create_uses_os_fs", func(t *testing.T) { dir := t.TempDir() err := goose.Create(db, dir, "test", "sql") require.NoError(t, err) paths, _ := filepath.Glob(filepath.Join(dir, "*test.sql")) require.NotEmpty(t, paths) err = goose.Fix(dir) require.NoError(t, err) _, err = os.Stat(filepath.Join(dir, "00001_test.sql")) require.NoError(t, err) }) } ================================================ FILE: helpers.go ================================================ package goose import ( "bytes" "strings" "unicode" "unicode/utf8" ) type camelSnakeStateMachine int const ( // _$$_This is some text, OK?! idle camelSnakeStateMachine = iota // 0 ↑ ↑ ↑ firstAlphaNum // 1 ↑ ↑ ↑ ↑ ↑ alphaNum // 2 ↑↑↑ ↑ ↑↑↑ ↑↑↑ ↑ delimiter // 3 ↑ ↑ ↑ ↑ ↑ ) func (s camelSnakeStateMachine) next(r rune) camelSnakeStateMachine { switch s { case idle: if isAlphaNum(r) { return firstAlphaNum } case firstAlphaNum: if isAlphaNum(r) { return alphaNum } return delimiter case alphaNum: if !isAlphaNum(r) { return delimiter } case delimiter: if isAlphaNum(r) { return firstAlphaNum } return idle } return s } func camelCase(str string) string { var b strings.Builder stateMachine := idle for i := 0; i < len(str); { r, size := utf8.DecodeRuneInString(str[i:]) i += size stateMachine = stateMachine.next(r) switch stateMachine { case firstAlphaNum: b.WriteRune(unicode.ToUpper(r)) case alphaNum: b.WriteRune(unicode.ToLower(r)) } } return b.String() } func snakeCase(str string) string { var b bytes.Buffer stateMachine := idle for i := 0; i < len(str); { r, size := utf8.DecodeRuneInString(str[i:]) i += size stateMachine = stateMachine.next(r) switch stateMachine { case firstAlphaNum, alphaNum: b.WriteRune(unicode.ToLower(r)) case delimiter: b.WriteByte('_') } } if stateMachine == idle { return string(bytes.TrimSuffix(b.Bytes(), []byte{'_'})) } return b.String() } func isAlphaNum(r rune) bool { return unicode.IsLetter(r) || unicode.IsNumber(r) } ================================================ FILE: helpers_test.go ================================================ package goose import ( "testing" ) func TestCamelSnake(t *testing.T) { t.Parallel() tt := []struct { in string camel string snake string }{ {in: "Add updated_at to users table", camel: "AddUpdatedAtToUsersTable", snake: "add_updated_at_to_users_table"}, {in: "$()&^%(_--crazy__--input$)", camel: "CrazyInput", snake: "crazy_input"}, } for _, test := range tt { if got := camelCase(test.in); got != test.camel { t.Errorf("unexpected CamelCase for input(%q), got %q, want %q", test.in, got, test.camel) } if got := snakeCase(test.in); got != test.snake { t.Errorf("unexpected snake_case for input(%q), got %q, want %q", test.in, got, test.snake) } } } ================================================ FILE: install.sh ================================================ #!/bin/sh # Adapted from the Deno installer: Copyright 2019 the Deno authors. All rights reserved. MIT license. # Ref: https://github.com/denoland/deno_install # TODO(everyone): Keep this script simple and easily auditable. # TODO(mf): this should work on Linux and macOS. Not intended for Windows. set -e os=$(uname -s | tr '[:upper:]' '[:lower:]') arch=$(uname -m) if [ "$arch" = "aarch64" ]; then arch="arm64" fi if [ $# -eq 0 ]; then goose_uri="https://github.com/pressly/goose/releases/latest/download/goose_${os}_${arch}" else goose_uri="https://github.com/pressly/goose/releases/download/${1}/goose_${os}_${arch}" fi goose_install="${GOOSE_INSTALL:-/usr/local}" bin_dir="${goose_install}/bin" exe="${bin_dir}/goose" if [ ! -d "${bin_dir}" ]; then mkdir -p "${bin_dir}" fi curl --silent --show-error --location --fail --location --output "${exe}" "$goose_uri" chmod +x "${exe}" echo "Goose was installed successfully to ${exe}" if command -v goose >/dev/null; then echo "Run 'goose --help' to get started" fi ================================================ FILE: internal/controller/store.go ================================================ package controller import ( "context" "errors" "github.com/pressly/goose/v3/database" ) // A StoreController is used by the goose package to interact with a database. This type is a // wrapper around the Store interface, but can be extended to include additional (optional) methods // that are not part of the core Store interface. type StoreController struct{ database.Store } var _ database.StoreExtender = (*StoreController)(nil) // NewStoreController returns a new StoreController that wraps the given Store. // // If the Store implements the following optional methods, the StoreController will call them as // appropriate: // // - TableExists(context.Context, DBTxConn) (bool, error) // // If the Store does not implement a method, it will either return a [errors.ErrUnsupported] error // or fall back to the default behavior. func NewStoreController(store database.Store) *StoreController { return &StoreController{store} } func (c *StoreController) TableExists(ctx context.Context, db database.DBTxConn) (bool, error) { if t, ok := c.Store.(interface { TableExists(ctx context.Context, db database.DBTxConn) (bool, error) }); ok { return t.TableExists(ctx, db) } return false, errors.ErrUnsupported } ================================================ FILE: internal/dialects/clickhouse.go ================================================ package dialects import ( "fmt" "github.com/pressly/goose/v3/database/dialect" ) // NewClickhouse returns a new [dialect.Querier] for Clickhouse dialect. func NewClickhouse() dialect.Querier { return &clickhouse{} } type clickhouse struct{} var _ dialect.Querier = (*clickhouse)(nil) func (c *clickhouse) CreateTable(tableName string) string { q := `CREATE TABLE IF NOT EXISTS %s ( version_id Int64, is_applied UInt8, date Date default now(), tstamp DateTime default now() ) ENGINE = MergeTree() ORDER BY (date)` return fmt.Sprintf(q, tableName) } func (c *clickhouse) InsertVersion(tableName string) string { q := `INSERT INTO %s (version_id, is_applied) VALUES ($1, $2)` return fmt.Sprintf(q, tableName) } func (c *clickhouse) DeleteVersion(tableName string) string { q := `ALTER TABLE %s DELETE WHERE version_id = $1 SETTINGS mutations_sync = 2` return fmt.Sprintf(q, tableName) } func (c *clickhouse) GetMigrationByVersion(tableName string) string { q := `SELECT tstamp, is_applied FROM %s WHERE version_id = $1 ORDER BY tstamp DESC LIMIT 1` return fmt.Sprintf(q, tableName) } func (c *clickhouse) ListMigrations(tableName string) string { q := `SELECT version_id, is_applied FROM %s ORDER BY version_id DESC` return fmt.Sprintf(q, tableName) } func (c *clickhouse) GetLatestVersion(tableName string) string { q := `SELECT max(version_id) FROM %s` return fmt.Sprintf(q, tableName) } ================================================ FILE: internal/dialects/dsql.go ================================================ package dialects import ( "fmt" "github.com/pressly/goose/v3/database/dialect" ) // NewAuroraDSQL returns a new [dialect.Querier] for Aurora DSQL dialect. func NewAuroraDSQL() dialect.QuerierExtender { return &dsql{} } type dsql struct{} var _ dialect.QuerierExtender = (*dsql)(nil) func (d *dsql) CreateTable(tableName string) string { q := `CREATE TABLE %s ( id integer PRIMARY KEY, version_id bigint NOT NULL, is_applied boolean NOT NULL, tstamp timestamp NOT NULL DEFAULT now() )` return fmt.Sprintf(q, tableName) } func (d *dsql) InsertVersion(tableName string) string { q := `INSERT INTO %s (id, version_id, is_applied) VALUES ( COALESCE((SELECT MAX(id) FROM %s), 0) + 1, $1, $2 )` return fmt.Sprintf(q, tableName, tableName) } func (d *dsql) DeleteVersion(tableName string) string { q := `DELETE FROM %s WHERE version_id=$1` return fmt.Sprintf(q, tableName) } func (d *dsql) GetMigrationByVersion(tableName string) string { q := `SELECT tstamp, is_applied FROM %s WHERE version_id=$1 ORDER BY tstamp DESC LIMIT 1` return fmt.Sprintf(q, tableName) } func (d *dsql) ListMigrations(tableName string) string { q := `SELECT version_id, is_applied from %s ORDER BY id DESC` return fmt.Sprintf(q, tableName) } func (d *dsql) GetLatestVersion(tableName string) string { q := `SELECT max(version_id) FROM %s` return fmt.Sprintf(q, tableName) } func (d *dsql) TableExists(tableName string) string { schemaName, tableName := parseTableIdentifier(tableName) if schemaName != "" { q := `SELECT EXISTS ( SELECT 1 FROM pg_tables WHERE schemaname = '%s' AND tablename = '%s' )` return fmt.Sprintf(q, schemaName, tableName) } q := `SELECT EXISTS ( SELECT 1 FROM pg_tables WHERE (current_schema() IS NULL OR schemaname = current_schema()) AND tablename = '%s' )` return fmt.Sprintf(q, tableName) } ================================================ FILE: internal/dialects/mysql.go ================================================ package dialects import ( "fmt" "github.com/pressly/goose/v3/database/dialect" ) // NewMysql returns a new [dialect.Querier] for MySQL dialect. func NewMysql() dialect.QuerierExtender { return &mysql{} } type mysql struct{} var _ dialect.QuerierExtender = (*mysql)(nil) func (m *mysql) CreateTable(tableName string) string { q := `CREATE TABLE %s ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, version_id bigint NOT NULL, is_applied boolean NOT NULL, tstamp timestamp NULL default now(), PRIMARY KEY(id) )` return fmt.Sprintf(q, tableName) } func (m *mysql) InsertVersion(tableName string) string { q := `INSERT INTO %s (version_id, is_applied) VALUES (?, ?)` return fmt.Sprintf(q, tableName) } func (m *mysql) DeleteVersion(tableName string) string { q := `DELETE FROM %s WHERE version_id=?` return fmt.Sprintf(q, tableName) } func (m *mysql) GetMigrationByVersion(tableName string) string { q := `SELECT tstamp, is_applied FROM %s WHERE version_id=? ORDER BY tstamp DESC LIMIT 1` return fmt.Sprintf(q, tableName) } func (m *mysql) ListMigrations(tableName string) string { q := `SELECT version_id, is_applied from %s ORDER BY id DESC` return fmt.Sprintf(q, tableName) } func (m *mysql) GetLatestVersion(tableName string) string { q := `SELECT MAX(version_id) FROM %s` return fmt.Sprintf(q, tableName) } func (m *mysql) TableExists(tableName string) string { schemaName, tableName := parseTableIdentifier(tableName) if schemaName != "" { q := `SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = '%s' AND table_name = '%s' )` return fmt.Sprintf(q, schemaName, tableName) } q := `SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE (database() IS NULL OR table_schema = database()) AND table_name = '%s' )` return fmt.Sprintf(q, tableName) } ================================================ FILE: internal/dialects/postgres.go ================================================ package dialects import ( "fmt" "strings" "github.com/pressly/goose/v3/database/dialect" ) // NewPostgres returns a new [dialect.Querier] for PostgreSQL dialect. func NewPostgres() dialect.QuerierExtender { return &postgres{} } type postgres struct{} var _ dialect.QuerierExtender = (*postgres)(nil) func (p *postgres) CreateTable(tableName string) string { q := `CREATE TABLE %s ( id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, version_id bigint NOT NULL, is_applied boolean NOT NULL, tstamp timestamp NOT NULL DEFAULT now() )` return fmt.Sprintf(q, tableName) } func (p *postgres) InsertVersion(tableName string) string { q := `INSERT INTO %s (version_id, is_applied) VALUES ($1, $2)` return fmt.Sprintf(q, tableName) } func (p *postgres) DeleteVersion(tableName string) string { q := `DELETE FROM %s WHERE version_id=$1` return fmt.Sprintf(q, tableName) } func (p *postgres) GetMigrationByVersion(tableName string) string { q := `SELECT tstamp, is_applied FROM %s WHERE version_id=$1 ORDER BY tstamp DESC LIMIT 1` return fmt.Sprintf(q, tableName) } func (p *postgres) ListMigrations(tableName string) string { q := `SELECT version_id, is_applied from %s ORDER BY id DESC` return fmt.Sprintf(q, tableName) } func (p *postgres) GetLatestVersion(tableName string) string { q := `SELECT max(version_id) FROM %s` return fmt.Sprintf(q, tableName) } func (p *postgres) TableExists(tableName string) string { schemaName, tableName := parseTableIdentifier(tableName) if schemaName != "" { q := `SELECT EXISTS ( SELECT 1 FROM pg_tables WHERE schemaname = '%s' AND tablename = '%s' )` return fmt.Sprintf(q, schemaName, tableName) } q := `SELECT EXISTS ( SELECT 1 FROM pg_tables WHERE (current_schema() IS NULL OR schemaname = current_schema()) AND tablename = '%s' )` return fmt.Sprintf(q, tableName) } func parseTableIdentifier(name string) (schema, table string) { schema, table, found := strings.Cut(name, ".") if !found { return "", name } return schema, table } ================================================ FILE: internal/dialects/redshift.go ================================================ package dialects import ( "fmt" "github.com/pressly/goose/v3/database/dialect" ) // Redshift returns a new [dialect.Querier] for Redshift dialect. func NewRedshift() dialect.Querier { return &redshift{} } type redshift struct{} var _ dialect.Querier = (*redshift)(nil) func (r *redshift) CreateTable(tableName string) string { q := `CREATE TABLE %s ( id integer NOT NULL identity(1, 1), version_id bigint NOT NULL, is_applied boolean NOT NULL, tstamp timestamp NULL default sysdate, PRIMARY KEY(id) )` return fmt.Sprintf(q, tableName) } func (r *redshift) InsertVersion(tableName string) string { q := `INSERT INTO %s (version_id, is_applied) VALUES ($1, $2)` return fmt.Sprintf(q, tableName) } func (r *redshift) DeleteVersion(tableName string) string { q := `DELETE FROM %s WHERE version_id=$1` return fmt.Sprintf(q, tableName) } func (r *redshift) GetMigrationByVersion(tableName string) string { q := `SELECT tstamp, is_applied FROM %s WHERE version_id=$1 ORDER BY tstamp DESC LIMIT 1` return fmt.Sprintf(q, tableName) } func (r *redshift) ListMigrations(tableName string) string { q := `SELECT version_id, is_applied from %s ORDER BY id DESC` return fmt.Sprintf(q, tableName) } func (r *redshift) GetLatestVersion(tableName string) string { q := `SELECT max(version_id) FROM %s` return fmt.Sprintf(q, tableName) } ================================================ FILE: internal/dialects/spanner.go ================================================ package dialects import ( "fmt" "github.com/pressly/goose/v3/database/dialect" ) // NewSpanner returns a [dialect.Querier] for Spanner dialect. func NewSpanner() dialect.Querier { return &spanner{} } type spanner struct{} var _ dialect.Querier = (*spanner)(nil) func (s *spanner) CreateTable(tableName string) string { q := `CREATE TABLE %s ( version_id INT64 NOT NULL, is_applied BOOL NOT NULL, tstamp TIMESTAMP DEFAULT (CURRENT_TIMESTAMP()), ) PRIMARY KEY(version_id)` return fmt.Sprintf(q, tableName) } func (s *spanner) InsertVersion(tableName string) string { q := `INSERT INTO %s (version_id, is_applied) VALUES (?, ?)` return fmt.Sprintf(q, tableName) } func (s *spanner) DeleteVersion(tableName string) string { q := `DELETE FROM %s WHERE version_id=?` return fmt.Sprintf(q, tableName) } func (s *spanner) GetMigrationByVersion(tableName string) string { q := `SELECT tstamp, is_applied FROM %s WHERE version_id=? ORDER BY tstamp DESC LIMIT 1` return fmt.Sprintf(q, tableName) } func (s *spanner) ListMigrations(tableName string) string { q := `SELECT version_id, is_applied from %s ORDER BY version_id DESC` return fmt.Sprintf(q, tableName) } func (s *spanner) GetLatestVersion(tableName string) string { q := `SELECT MAX(version_id) FROM %s` return fmt.Sprintf(q, tableName) } ================================================ FILE: internal/dialects/sqlite3.go ================================================ package dialects import ( "fmt" "github.com/pressly/goose/v3/database/dialect" ) // NewSqlite3 returns a [dialect.Querier] for SQLite3 dialect. func NewSqlite3() dialect.Querier { return &sqlite3{} } type sqlite3 struct{} var _ dialect.Querier = (*sqlite3)(nil) func (s *sqlite3) CreateTable(tableName string) string { q := `CREATE TABLE %s ( id INTEGER PRIMARY KEY AUTOINCREMENT, version_id INTEGER NOT NULL, is_applied INTEGER NOT NULL, tstamp TIMESTAMP DEFAULT (datetime('now')) )` return fmt.Sprintf(q, tableName) } func (s *sqlite3) InsertVersion(tableName string) string { q := `INSERT INTO %s (version_id, is_applied) VALUES (?, ?)` return fmt.Sprintf(q, tableName) } func (s *sqlite3) DeleteVersion(tableName string) string { q := `DELETE FROM %s WHERE version_id=?` return fmt.Sprintf(q, tableName) } func (s *sqlite3) GetMigrationByVersion(tableName string) string { q := `SELECT tstamp, is_applied FROM %s WHERE version_id=? ORDER BY tstamp DESC LIMIT 1` return fmt.Sprintf(q, tableName) } func (s *sqlite3) ListMigrations(tableName string) string { q := `SELECT version_id, is_applied from %s ORDER BY id DESC` return fmt.Sprintf(q, tableName) } func (s *sqlite3) GetLatestVersion(tableName string) string { q := `SELECT MAX(version_id) FROM %s` return fmt.Sprintf(q, tableName) } ================================================ FILE: internal/dialects/sqlserver.go ================================================ package dialects import ( "fmt" "github.com/pressly/goose/v3/database/dialect" ) // NewSqlserver returns a [dialect.Querier] for SQL Server dialect. func NewSqlserver() dialect.Querier { return &sqlserver{} } type sqlserver struct{} var _ dialect.Querier = (*sqlserver)(nil) func (s *sqlserver) CreateTable(tableName string) string { q := `CREATE TABLE %s ( id INT NOT NULL IDENTITY(1,1) PRIMARY KEY, version_id BIGINT NOT NULL, is_applied BIT NOT NULL, tstamp DATETIME NULL DEFAULT CURRENT_TIMESTAMP )` return fmt.Sprintf(q, tableName) } func (s *sqlserver) InsertVersion(tableName string) string { q := `INSERT INTO %s (version_id, is_applied) VALUES (@p1, @p2)` return fmt.Sprintf(q, tableName) } func (s *sqlserver) DeleteVersion(tableName string) string { q := `DELETE FROM %s WHERE version_id=@p1` return fmt.Sprintf(q, tableName) } func (s *sqlserver) GetMigrationByVersion(tableName string) string { q := `SELECT TOP 1 tstamp, is_applied FROM %s WHERE version_id=@p1 ORDER BY tstamp DESC` return fmt.Sprintf(q, tableName) } func (s *sqlserver) ListMigrations(tableName string) string { q := `SELECT version_id, is_applied FROM %s ORDER BY id DESC` return fmt.Sprintf(q, tableName) } func (s *sqlserver) GetLatestVersion(tableName string) string { q := `SELECT MAX(version_id) FROM %s` return fmt.Sprintf(q, tableName) } ================================================ FILE: internal/dialects/starrocks.go ================================================ package dialects import ( "fmt" "github.com/pressly/goose/v3/database/dialect" ) // NewStarrocks returns a [dialect.Querier] for StarRocks dialect. func NewStarrocks() dialect.Querier { return &starrocks{} } type starrocks struct{} var _ dialect.Querier = (*starrocks)(nil) func (m *starrocks) CreateTable(tableName string) string { q := `CREATE TABLE IF NOT EXISTS %s ( id bigint NOT NULL AUTO_INCREMENT, version_id bigint NOT NULL, is_applied boolean NOT NULL, tstamp datetime NULL default CURRENT_TIMESTAMP ) PRIMARY KEY (id) DISTRIBUTED BY HASH (id) ORDER BY (id,version_id)` return fmt.Sprintf(q, tableName) } func (m *starrocks) InsertVersion(tableName string) string { q := `INSERT INTO %s (version_id, is_applied) VALUES (?, ?)` return fmt.Sprintf(q, tableName) } func (m *starrocks) DeleteVersion(tableName string) string { q := `DELETE FROM %s WHERE version_id=?` return fmt.Sprintf(q, tableName) } func (m *starrocks) GetMigrationByVersion(tableName string) string { q := `SELECT tstamp, is_applied FROM %s WHERE version_id=? ORDER BY tstamp DESC LIMIT 1` return fmt.Sprintf(q, tableName) } func (m *starrocks) ListMigrations(tableName string) string { q := `SELECT version_id, is_applied from %s ORDER BY id DESC` return fmt.Sprintf(q, tableName) } func (m *starrocks) GetLatestVersion(tableName string) string { q := `SELECT MAX(version_id) FROM %s` return fmt.Sprintf(q, tableName) } ================================================ FILE: internal/dialects/tidb.go ================================================ package dialects import ( "fmt" "github.com/pressly/goose/v3/database/dialect" ) // NewTidb returns a [dialect.Querier] for TiDB dialect. func NewTidb() dialect.Querier { return &Tidb{} } type Tidb struct{} var _ dialect.Querier = (*Tidb)(nil) func (t *Tidb) CreateTable(tableName string) string { q := `CREATE TABLE %s ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT UNIQUE, version_id bigint NOT NULL, is_applied boolean NOT NULL, tstamp timestamp NULL default now(), PRIMARY KEY(id) )` return fmt.Sprintf(q, tableName) } func (t *Tidb) InsertVersion(tableName string) string { q := `INSERT INTO %s (version_id, is_applied) VALUES (?, ?)` return fmt.Sprintf(q, tableName) } func (t *Tidb) DeleteVersion(tableName string) string { q := `DELETE FROM %s WHERE version_id=?` return fmt.Sprintf(q, tableName) } func (t *Tidb) GetMigrationByVersion(tableName string) string { q := `SELECT tstamp, is_applied FROM %s WHERE version_id=? ORDER BY tstamp DESC LIMIT 1` return fmt.Sprintf(q, tableName) } func (t *Tidb) ListMigrations(tableName string) string { q := `SELECT version_id, is_applied from %s ORDER BY id DESC` return fmt.Sprintf(q, tableName) } func (t *Tidb) GetLatestVersion(tableName string) string { q := `SELECT MAX(version_id) FROM %s` return fmt.Sprintf(q, tableName) } ================================================ FILE: internal/dialects/turso.go ================================================ package dialects import "github.com/pressly/goose/v3/database/dialect" // NewTurso returns a [dialect.Querier] for Turso dialect. func NewTurso() dialect.Querier { return &turso{} } type turso struct { sqlite3 } var _ dialect.Querier = (*turso)(nil) ================================================ FILE: internal/dialects/vertica.go ================================================ package dialects import ( "fmt" "github.com/pressly/goose/v3/database/dialect" ) // NewVertica returns a new [dialect.Querier] for Vertica dialect. // // DEPRECATED: Vertica support is deprecated and will be removed in a future release. func NewVertica() dialect.Querier { return &vertica{} } type vertica struct{} var _ dialect.Querier = (*vertica)(nil) func (v *vertica) CreateTable(tableName string) string { q := `CREATE TABLE %s ( id identity(1,1) NOT NULL, version_id bigint NOT NULL, is_applied boolean NOT NULL, tstamp timestamp NULL default now(), PRIMARY KEY(id) )` return fmt.Sprintf(q, tableName) } func (v *vertica) InsertVersion(tableName string) string { q := `INSERT INTO %s (version_id, is_applied) VALUES (?, ?)` return fmt.Sprintf(q, tableName) } func (v *vertica) DeleteVersion(tableName string) string { q := `DELETE FROM %s WHERE version_id=?` return fmt.Sprintf(q, tableName) } func (v *vertica) GetMigrationByVersion(tableName string) string { q := `SELECT tstamp, is_applied FROM %s WHERE version_id=? ORDER BY tstamp DESC LIMIT 1` return fmt.Sprintf(q, tableName) } func (v *vertica) ListMigrations(tableName string) string { q := `SELECT version_id, is_applied from %s ORDER BY id DESC` return fmt.Sprintf(q, tableName) } func (v *vertica) GetLatestVersion(tableName string) string { q := `SELECT MAX(version_id) FROM %s` return fmt.Sprintf(q, tableName) } ================================================ FILE: internal/dialects/ydb.go ================================================ package dialects import ( "fmt" "github.com/pressly/goose/v3/database/dialect" ) // NewYDB returns a new [dialect.Querier] for Vertica dialect. func NewYDB() dialect.Querier { return &ydb{} } type ydb struct{} var _ dialect.Querier = (*ydb)(nil) func formatYDBTableName(tableName string) string { return fmt.Sprintf("`%s`", tableName) } func (c *ydb) CreateTable(tableName string) string { formatedYDBTableName := formatYDBTableName(tableName) q := `CREATE TABLE %s ( version_id Uint64, is_applied Bool, tstamp Timestamp, PRIMARY KEY(version_id) )` return fmt.Sprintf(q, formatedYDBTableName) } func (c *ydb) InsertVersion(tableName string) string { formatedYDBTableName := formatYDBTableName(tableName) q := `INSERT INTO %s ( version_id, is_applied, tstamp ) VALUES ( CAST($1 AS Uint64), $2, CurrentUtcTimestamp() )` return fmt.Sprintf(q, formatedYDBTableName) } func (c *ydb) DeleteVersion(tableName string) string { formatedYDBTableName := formatYDBTableName(tableName) q := `DELETE FROM %s WHERE version_id = $1` return fmt.Sprintf(q, formatedYDBTableName) } func (c *ydb) GetMigrationByVersion(tableName string) string { formatedYDBTableName := formatYDBTableName(tableName) q := `SELECT tstamp, is_applied FROM %s WHERE version_id = $1 ORDER BY tstamp DESC LIMIT 1` return fmt.Sprintf(q, formatedYDBTableName) } func (c *ydb) ListMigrations(tableName string) string { formatedYDBTableName := formatYDBTableName(tableName) q := ` SELECT version_id, is_applied, tstamp AS __discard_column_tstamp FROM %s ORDER BY __discard_column_tstamp DESC` return fmt.Sprintf(q, formatedYDBTableName) } func (c *ydb) GetLatestVersion(tableName string) string { formatedYDBTableName := formatYDBTableName(tableName) q := `SELECT MAX(version_id) FROM %s` return fmt.Sprintf(q, formatedYDBTableName) } ================================================ FILE: internal/gooseutil/resolve.go ================================================ // Package gooseutil provides utility functions we want to keep internal to the package. It's // intended to be a collection of well-tested helper functions. package gooseutil import ( "fmt" "math" "slices" "strconv" "strings" ) // UpVersions returns a list of migrations to apply based on the versions in the filesystem and the // versions in the database. The target version can be used to specify a target version. In most // cases this will be math.MaxInt64. // // The allowMissing flag can be used to allow missing migrations as part of the list of migrations // to apply. Otherwise, an error will be returned if there are missing migrations in the database. func UpVersions( fsysVersions []int64, dbVersions []int64, target int64, allowMissing bool, ) ([]int64, error) { // Sort the list of versions in the filesystem. This should already be sorted, but we do this // just in case. slices.Sort(fsysVersions) // dbAppliedVersions is a map of all applied migrations in the database. dbAppliedVersions := make(map[int64]bool, len(dbVersions)) var dbMaxVersion int64 for _, v := range dbVersions { dbAppliedVersions[v] = true if v > dbMaxVersion { dbMaxVersion = v } } // Get a list of migrations that are missing from the database. A missing migration is one that // has a version less than the max version in the database and has not been applied. // // In most cases the target version is math.MaxInt64, but it can be used to specify a target // version. In which case we respect the target version and only surface migrations up to and // including that target. var missing []int64 for _, v := range fsysVersions { if dbAppliedVersions[v] { continue } if v < dbMaxVersion && v <= target { missing = append(missing, v) } } // feat(mf): It is very possible someone may want to apply ONLY new migrations and skip missing // migrations entirely. At the moment this is not supported, but leaving this comment because // that's where that logic would be handled. // // For example, if database has 1,4 already applied and 2,3,5 are new, we would apply only 5 and // skip 2,3. Not sure if this is a common use case, but it's possible someone may want to do // this. if len(missing) > 0 && !allowMissing { return nil, newMissingError(missing, dbMaxVersion, target) } var out []int64 // 1. Add missing migrations to the list of migrations to apply, if any. out = append(out, missing...) // 2. Add new migrations to the list of migrations to apply, if any. for _, v := range fsysVersions { if dbAppliedVersions[v] { continue } if v > dbMaxVersion && v <= target { out = append(out, v) } } // 3. Sort the list of migrations to apply. slices.Sort(out) return out, nil } func newMissingError( missing []int64, dbMaxVersion int64, target int64, ) error { slices.Sort(missing) collected := make([]string, 0, len(missing)) for _, v := range missing { collected = append(collected, strconv.FormatInt(v, 10)) } msg := "migration" if len(collected) > 1 { msg += "s" } var versionsMsg string if len(collected) > 1 { versionsMsg = "versions " + strings.Join(collected, ",") } else { versionsMsg = "version " + collected[0] } desiredMsg := fmt.Sprintf("database version (%d)", dbMaxVersion) if target != math.MaxInt64 { desiredMsg += fmt.Sprintf(", with target version (%d)", target) } return fmt.Errorf("detected %d missing (out-of-order) %s lower than %s: %s", len(missing), msg, desiredMsg, versionsMsg, ) } ================================================ FILE: internal/gooseutil/resolve_test.go ================================================ package gooseutil import ( "math" "slices" "testing" "github.com/stretchr/testify/require" ) func TestResolveVersions(t *testing.T) { t.Parallel() t.Run("not_allow_missing", func(t *testing.T) { // Nothing to apply nil got, err := UpVersions(nil, nil, math.MaxInt64, false) require.NoError(t, err) require.Empty(t, got) // Nothing to apply empty got, err = UpVersions([]int64{}, []int64{}, math.MaxInt64, false) require.NoError(t, err) require.Empty(t, got) // Nothing new got, err = UpVersions([]int64{1, 2, 3}, []int64{1, 2, 3}, math.MaxInt64, false) require.NoError(t, err) require.Empty(t, got) // All new got, err = UpVersions([]int64{1, 2, 3}, []int64{}, math.MaxInt64, false) require.NoError(t, err) require.Len(t, got, 3) require.Equal(t, int64(1), got[0]) require.Equal(t, int64(2), got[1]) require.Equal(t, int64(3), got[2]) // Squashed, no new got, err = UpVersions([]int64{3}, []int64{3}, math.MaxInt64, false) require.NoError(t, err) require.Empty(t, got) // Squashed, 1 new got, err = UpVersions([]int64{3, 4}, []int64{3}, math.MaxInt64, false) require.NoError(t, err) require.Len(t, got, 1) require.Equal(t, int64(4), got[0]) // Some new with target got, err = UpVersions([]int64{1, 2, 3, 4, 5}, []int64{1, 2}, 4, false) require.NoError(t, err) require.Len(t, got, 2) require.Equal(t, int64(3), got[0]) require.Equal(t, int64(4), got[1]) // up to and including target // Some new with zero target got, err = UpVersions([]int64{1, 2, 3, 4, 5}, []int64{1, 2}, 0, false) require.NoError(t, err) require.Empty(t, got) // Error: one missing migrations with max target _, err = UpVersions([]int64{1, 2, 3, 4}, []int64{1 /* 2*/, 3}, math.MaxInt64, false) require.Error(t, err) require.Equal(t, "detected 1 missing (out-of-order) migration lower than database version (3): version 2", err.Error(), ) // Error: multiple missing migrations with max target _, err = UpVersions([]int64{1, 2, 3, 4, 5}, []int64{ /* 1 */ 2 /* 3 */, 4, 5}, math.MaxInt64, false) require.Error(t, err) require.Equal(t, "detected 2 missing (out-of-order) migrations lower than database version (5): versions 1,3", err.Error(), ) t.Run("target_lower_than_max", func(t *testing.T) { // These tests are a bit of an edge case but an important one worth documenting. There // can be missing migrations above and/or below the target version which itself can be // lower than the max db version. For example, migrations 1,2,3,4 in the filesystem, and // migrations 1,2,4 applied to the database and the user requested target 2. Technically // there are no missing migrations based on the target version since 1,2 have been // applied, but there is 1 missing migration (3) based on the max db version. Should // this return an error, or report no pending migrations? // // We've taken the stance that this SHOULD respect the target version and surface an // error if there are missing migrations below the target version. This is because the // user has explicitly requested a target version and we should respect that. got, err = UpVersions([]int64{1, 2, 3}, []int64{1 /* 2 */, 3}, 1, false) require.NoError(t, err) require.Empty(t, got) got, err = UpVersions([]int64{1, 2, 3}, []int64{1 /* 2 */, 3}, 2, false) require.Error(t, err) require.Equal(t, "detected 1 missing (out-of-order) migration lower than database version (3), with target version (2): version 2", err.Error(), ) got, err = UpVersions([]int64{1, 2, 3}, []int64{1 /* 2 */, 3}, 3, false) require.Error(t, err) require.Equal(t, "detected 1 missing (out-of-order) migration lower than database version (3), with target version (3): version 2", err.Error(), ) _, err = UpVersions([]int64{1, 2, 3, 4, 5, 6}, []int64{1 /* 2 */, 3, 4 /* 5*/, 6}, 4, false) require.Error(t, err) require.Equal(t, "detected 1 missing (out-of-order) migration lower than database version (6), with target version (4): version 2", err.Error(), ) _, err = UpVersions([]int64{1, 2, 3, 4, 5, 6}, []int64{1 /* 2 */, 3, 4 /* 5*/, 6}, 6, false) require.Error(t, err) require.Equal(t, "detected 2 missing (out-of-order) migrations lower than database version (6), with target version (6): versions 2,5", err.Error(), ) }) }) t.Run("allow_missing", func(t *testing.T) { // Nothing to apply nil got, err := UpVersions(nil, nil, math.MaxInt64, true) require.NoError(t, err) require.Empty(t, got) // Nothing to apply empty got, err = UpVersions([]int64{}, []int64{}, math.MaxInt64, true) require.NoError(t, err) require.Empty(t, got) // Nothing new got, err = UpVersions([]int64{1, 2, 3}, []int64{1, 2, 3}, math.MaxInt64, true) require.NoError(t, err) require.Empty(t, got) // All new got, err = UpVersions([]int64{1, 2, 3}, []int64{}, math.MaxInt64, true) require.NoError(t, err) require.Len(t, got, 3) require.Equal(t, int64(1), got[0]) require.Equal(t, int64(2), got[1]) require.Equal(t, int64(3), got[2]) // Squashed, no new got, err = UpVersions([]int64{3}, []int64{3}, math.MaxInt64, true) require.NoError(t, err) require.Empty(t, got) // Squashed, 1 new got, err = UpVersions([]int64{3, 4}, []int64{3}, math.MaxInt64, true) require.NoError(t, err) require.Len(t, got, 1) require.Equal(t, int64(4), got[0]) // Some new with target got, err = UpVersions([]int64{1, 2, 3, 4, 5}, []int64{1, 2}, 4, true) require.NoError(t, err) require.Len(t, got, 2) require.Equal(t, int64(3), got[0]) require.Equal(t, int64(4), got[1]) // up to and including target // Some new with zero target got, err = UpVersions([]int64{1, 2, 3, 4, 5}, []int64{1, 2}, 0, true) require.NoError(t, err) require.Empty(t, got) // No error: one missing got, err = UpVersions([]int64{1, 2, 3}, []int64{1 /* 2*/, 3}, math.MaxInt64, true) require.NoError(t, err) require.Len(t, got, 1) require.Equal(t, int64(2), got[0]) // missing // No error: multiple missing and new with max target got, err = UpVersions([]int64{1, 2, 3, 4, 5}, []int64{ /* 1 */ 2 /* 3 */, 4}, math.MaxInt64, true) require.NoError(t, err) require.Len(t, got, 3) require.Equal(t, int64(1), got[0]) // missing require.Equal(t, int64(3), got[1]) // missing require.Equal(t, int64(5), got[2]) t.Run("target_lower_than_max", func(t *testing.T) { got, err = UpVersions([]int64{1, 2, 3}, []int64{1 /* 2 */, 3}, 1, true) require.NoError(t, err) require.Empty(t, got) got, err = UpVersions([]int64{1, 2, 3}, []int64{1 /* 2 */, 3}, 2, true) require.NoError(t, err) require.Len(t, got, 1) require.Equal(t, int64(2), got[0]) // missing got, err = UpVersions([]int64{1, 2, 3}, []int64{1 /* 2 */, 3}, 3, true) require.NoError(t, err) require.Len(t, got, 1) require.Equal(t, int64(2), got[0]) // missing got, err = UpVersions([]int64{1, 2, 3, 4, 5, 6}, []int64{1 /* 2 */, 3, 4 /* 5*/, 6}, 4, true) require.NoError(t, err) require.Len(t, got, 1) require.Equal(t, int64(2), got[0]) // missing got, err = UpVersions([]int64{1, 2, 3, 4, 5, 6}, []int64{1 /* 2 */, 3, 4 /* 5*/, 6}, 6, true) require.NoError(t, err) require.Len(t, got, 2) require.Equal(t, int64(2), got[0]) // missing require.Equal(t, int64(5), got[1]) // missing }) }) t.Run("sort_ascending", func(t *testing.T) { got := []int64{5, 3, 4, 2, 1} slices.Sort(got) require.Equal(t, []int64{1, 2, 3, 4, 5}, got) }) } ================================================ FILE: internal/legacystore/legacystore.go ================================================ package legacystore import ( "context" "database/sql" "fmt" "time" "github.com/pressly/goose/v3/database" "github.com/pressly/goose/v3/database/dialect" "github.com/pressly/goose/v3/internal/dialects" ) // Store is the interface that wraps the basic methods for a database dialect. // // A dialect is a set of SQL statements that are specific to a database. // // By defining a store interface, we can support multiple databases // with a single codebase. // // The underlying implementation does not modify the error. It is the callers // responsibility to assert for the correct error, such as sql.ErrNoRows. type Store interface { // CreateVersionTable creates the version table within a transaction. // This table is used to store goose migrations. CreateVersionTable(ctx context.Context, tx *sql.Tx, tableName string) error // InsertVersion inserts a version id into the version table within a transaction. InsertVersion(ctx context.Context, tx *sql.Tx, tableName string, version int64) error // InsertVersionNoTx inserts a version id into the version table without a transaction. InsertVersionNoTx(ctx context.Context, db *sql.DB, tableName string, version int64) error // DeleteVersion deletes a version id from the version table within a transaction. DeleteVersion(ctx context.Context, tx *sql.Tx, tableName string, version int64) error // DeleteVersionNoTx deletes a version id from the version table without a transaction. DeleteVersionNoTx(ctx context.Context, db *sql.DB, tableName string, version int64) error // GetMigrationRow retrieves a single migration by version id. // // Returns the raw sql error if the query fails. It is the callers responsibility // to assert for the correct error, such as sql.ErrNoRows. GetMigration(ctx context.Context, db *sql.DB, tableName string, version int64) (*GetMigrationResult, error) // ListMigrations retrieves all migrations sorted in descending order by id. // // If there are no migrations, an empty slice is returned with no error. ListMigrations(ctx context.Context, db *sql.DB, tableName string) ([]*ListMigrationsResult, error) } // NewStore returns a new Store for the given dialect. func NewStore(d database.Dialect) (Store, error) { var querier dialect.Querier switch d { case database.DialectPostgres: querier = dialects.NewPostgres() case database.DialectMySQL: querier = dialects.NewMysql() case database.DialectSQLite3: querier = dialects.NewSqlite3() case database.DialectSpanner: querier = dialects.NewSpanner() case database.DialectMSSQL: querier = dialects.NewSqlserver() case database.DialectRedshift: querier = dialects.NewRedshift() case database.DialectTiDB: querier = dialects.NewTidb() case database.DialectClickHouse: querier = dialects.NewClickhouse() case database.DialectVertica: querier = dialects.NewVertica() case database.DialectYdB: querier = dialects.NewYDB() case database.DialectTurso: querier = dialects.NewTurso() case database.DialectStarrocks: querier = dialects.NewStarrocks() default: return nil, fmt.Errorf("unknown querier dialect: %v", d) } return &store{querier: querier}, nil } type GetMigrationResult struct { IsApplied bool Timestamp time.Time } type ListMigrationsResult struct { VersionID int64 IsApplied bool } type store struct { querier dialect.Querier } var _ Store = (*store)(nil) func (s *store) CreateVersionTable(ctx context.Context, tx *sql.Tx, tableName string) error { q := s.querier.CreateTable(tableName) _, err := tx.ExecContext(ctx, q) return err } func (s *store) InsertVersion(ctx context.Context, tx *sql.Tx, tableName string, version int64) error { q := s.querier.InsertVersion(tableName) _, err := tx.ExecContext(ctx, q, version, true) return err } func (s *store) InsertVersionNoTx(ctx context.Context, db *sql.DB, tableName string, version int64) error { q := s.querier.InsertVersion(tableName) _, err := db.ExecContext(ctx, q, version, true) return err } func (s *store) DeleteVersion(ctx context.Context, tx *sql.Tx, tableName string, version int64) error { q := s.querier.DeleteVersion(tableName) _, err := tx.ExecContext(ctx, q, version) return err } func (s *store) DeleteVersionNoTx(ctx context.Context, db *sql.DB, tableName string, version int64) error { q := s.querier.DeleteVersion(tableName) _, err := db.ExecContext(ctx, q, version) return err } func (s *store) GetMigration( ctx context.Context, db *sql.DB, tableName string, version int64, ) (*GetMigrationResult, error) { q := s.querier.GetMigrationByVersion(tableName) var timestamp time.Time var isApplied bool err := db.QueryRowContext(ctx, q, version).Scan(×tamp, &isApplied) if err != nil { return nil, err } return &GetMigrationResult{ IsApplied: isApplied, Timestamp: timestamp, }, nil } func (s *store) ListMigrations(ctx context.Context, db *sql.DB, tableName string) ([]*ListMigrationsResult, error) { q := s.querier.ListMigrations(tableName) rows, err := db.QueryContext(ctx, q) if err != nil { return nil, err } defer rows.Close() var migrations []*ListMigrationsResult for rows.Next() { var version int64 var isApplied bool if err := rows.Scan(&version, &isApplied); err != nil { return nil, err } migrations = append(migrations, &ListMigrationsResult{ VersionID: version, IsApplied: isApplied, }) } if err := rows.Err(); err != nil { return nil, err } return migrations, nil } ================================================ FILE: internal/migrationstats/migration_go.go ================================================ package migrationstats import ( "errors" "fmt" "go/ast" "go/parser" "go/token" "io" "strings" ) const ( registerGoFuncName = "AddMigration" registerGoFuncNameNoTx = "AddMigrationNoTx" registerGoFuncNameContext = "AddMigrationContext" registerGoFuncNameNoTxContext = "AddMigrationNoTxContext" ) type goMigration struct { name string useTx *bool upFuncName, downFuncName string } func parseGoFile(r io.Reader) (*goMigration, error) { astFile, err := parser.ParseFile( token.NewFileSet(), "", // filename r, // We don't need to resolve imports, so we can skip it. // This speeds up the parsing process. // See https://github.com/golang/go/issues/46485 parser.SkipObjectResolution, ) if err != nil { return nil, err } for _, decl := range astFile.Decls { fn, ok := decl.(*ast.FuncDecl) if !ok || fn == nil || fn.Name == nil { continue } if fn.Name.Name == "init" { return parseInitFunc(fn) } } return nil, errors.New("no init function") } func parseInitFunc(fd *ast.FuncDecl) (*goMigration, error) { if fd == nil { return nil, fmt.Errorf("function declaration must not be nil") } if fd.Body == nil { return nil, fmt.Errorf("no function body") } if len(fd.Body.List) == 0 { return nil, fmt.Errorf("no registered goose functions") } gf := new(goMigration) for _, statement := range fd.Body.List { expr, ok := statement.(*ast.ExprStmt) if !ok { continue } call, ok := expr.X.(*ast.CallExpr) if !ok { continue } sel, ok := call.Fun.(*ast.SelectorExpr) if !ok || sel == nil { continue } funcName := sel.Sel.Name b := false switch funcName { case registerGoFuncName, registerGoFuncNameContext: b = true gf.useTx = &b case registerGoFuncNameNoTx, registerGoFuncNameNoTxContext: gf.useTx = &b default: continue } if gf.name != "" { return nil, fmt.Errorf("found duplicate registered functions:\nprevious: %v\ncurrent: %v", gf.name, funcName) } gf.name = funcName if len(call.Args) != 2 { return nil, fmt.Errorf("registered goose functions have 2 arguments: got %d", len(call.Args)) } getNameFromExpr := func(expr ast.Expr) (string, error) { arg, ok := expr.(*ast.Ident) if !ok { return "", fmt.Errorf("failed to assert argument identifier: got %T", arg) } return arg.Name, nil } var err error gf.upFuncName, err = getNameFromExpr(call.Args[0]) if err != nil { return nil, err } gf.downFuncName, err = getNameFromExpr(call.Args[1]) if err != nil { return nil, err } } // validation switch gf.name { case registerGoFuncName, registerGoFuncNameNoTx, registerGoFuncNameContext, registerGoFuncNameNoTxContext: default: return nil, fmt.Errorf("goose register function must be one of: %s", strings.Join([]string{ registerGoFuncName, registerGoFuncNameNoTx, registerGoFuncNameContext, registerGoFuncNameNoTxContext, }, ", "), ) } if gf.useTx == nil { return nil, errors.New("validation error: failed to identify transaction: got nil bool") } // The up and down functions can either be named Go functions or "nil", an // empty string means there is a flaw in our parsing logic of the Go source code. if gf.upFuncName == "" { return nil, fmt.Errorf("validation error: up function is empty string") } if gf.downFuncName == "" { return nil, fmt.Errorf("validation error: down function is empty string") } return gf, nil } ================================================ FILE: internal/migrationstats/migration_sql.go ================================================ package migrationstats import ( "bytes" "fmt" "io" "github.com/pressly/goose/v3/internal/sqlparser" ) type sqlMigration struct { useTx bool upCount, downCount int } func parseSQLFile(r io.Reader, debug bool) (*sqlMigration, error) { by, err := io.ReadAll(r) if err != nil { return nil, err } upStatements, txUp, err := sqlparser.ParseSQLMigration( bytes.NewReader(by), sqlparser.DirectionUp, debug, ) if err != nil { return nil, err } downStatements, txDown, err := sqlparser.ParseSQLMigration( bytes.NewReader(by), sqlparser.DirectionDown, debug, ) if err != nil { return nil, err } // This is a sanity check to ensure that the parser is behaving as expected. if txUp != txDown { return nil, fmt.Errorf("up and down statements must have the same transaction mode") } return &sqlMigration{ useTx: txUp, upCount: len(upStatements), downCount: len(downStatements), }, nil } ================================================ FILE: internal/migrationstats/migrationstats.go ================================================ package migrationstats import ( "fmt" "io" "path/filepath" "github.com/pressly/goose/v3" ) // FileWalker walks all files for GatherStats. type FileWalker interface { // Walk invokes fn for each file. Walk(fn func(filename string, r io.Reader) error) error } // Stats contains the stats for a migration file. type Stats struct { // FileName is the name of the file. FileName string // Version is the version of the migration. Version int64 // Tx is true if the .sql migration file has a +goose NO TRANSACTION annotation // or the .go migration file calls AddMigrationNoTx. Tx bool // UpCount is the number of statements in the Up migration. UpCount int // DownCount is the number of statements in the Down migration. DownCount int } // GatherStats returns the migration file stats. func GatherStats(fw FileWalker, debug bool) ([]*Stats, error) { var stats []*Stats err := fw.Walk(func(filename string, r io.Reader) error { version, err := goose.NumericComponent(filename) if err != nil { return fmt.Errorf("failed to get version from file %q: %w", filename, err) } var up, down int var tx bool switch filepath.Ext(filename) { case ".sql": m, err := parseSQLFile(r, debug) if err != nil { return fmt.Errorf("failed to parse file %q: %w", filename, err) } up, down = m.upCount, m.downCount tx = m.useTx case ".go": m, err := parseGoFile(r) if err != nil { return fmt.Errorf("failed to parse file %q: %w", filename, err) } up, down = nilAsNumber(m.upFuncName), nilAsNumber(m.downFuncName) tx = *m.useTx } stats = append(stats, &Stats{ FileName: filename, Version: version, Tx: tx, UpCount: up, DownCount: down, }) return nil }) if err != nil { return nil, err } return stats, nil } func nilAsNumber(s string) int { if s != "nil" { return 1 } return 0 } ================================================ FILE: internal/migrationstats/migrationstats_test.go ================================================ package migrationstats import ( "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/require" ) func TestParsingGoMigrations(t *testing.T) { t.Parallel() tests := []struct { name string input string wantUpName, wantDownName string wantTx bool }{ // AddMigration {"upAndDown", upAndDown, "up001", "down001", true}, {"downOnly", downOnly, "nil", "down002", true}, {"upOnly", upOnly, "up003", "nil", true}, {"upAndDownNil", upAndDownNil, "nil", "nil", true}, // AddMigrationNoTx {"upAndDownNoTx", upAndDownNoTx, "up001", "down001", false}, {"downOnlyNoTx", downOnlyNoTx, "nil", "down002", false}, {"upOnlyNoTx", upOnlyNoTx, "up003", "nil", false}, {"upAndDownNilNoTx", upAndDownNilNoTx, "nil", "nil", false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { g, err := parseGoFile(strings.NewReader(tc.input)) require.NoError(t, err) require.NotNil(t, g.useTx) require.Equal(t, tc.wantTx, *g.useTx) require.Equal(t, tc.wantDownName, g.downFuncName) require.Equal(t, tc.wantUpName, g.upFuncName) }) } } func TestGoMigrationStats(t *testing.T) { t.Parallel() base := "../../tests/gomigrations/success/testdata" all, err := os.ReadDir(base) require.NoError(t, err) require.Len(t, all, 16) files := make([]string, 0, len(all)) for _, f := range all { files = append(files, filepath.Join(base, f.Name())) } stats, err := GatherStats(NewFileWalker(files...), false) require.NoError(t, err) require.Len(t, stats, 16) checkGoStats(t, stats[0], "001_up_down.go", 1, 1, 1, true) checkGoStats(t, stats[1], "002_up_only.go", 2, 1, 0, true) checkGoStats(t, stats[2], "003_down_only.go", 3, 0, 1, true) checkGoStats(t, stats[3], "004_empty.go", 4, 0, 0, true) checkGoStats(t, stats[4], "005_up_down_no_tx.go", 5, 1, 1, false) checkGoStats(t, stats[5], "006_up_only_no_tx.go", 6, 1, 0, false) checkGoStats(t, stats[6], "007_down_only_no_tx.go", 7, 0, 1, false) checkGoStats(t, stats[7], "008_empty_no_tx.go", 8, 0, 0, false) checkGoStats(t, stats[8], "009_up_down_ctx.go", 9, 1, 1, true) checkGoStats(t, stats[9], "010_up_only_ctx.go", 10, 1, 0, true) checkGoStats(t, stats[10], "011_down_only_ctx.go", 11, 0, 1, true) checkGoStats(t, stats[11], "012_empty_ctx.go", 12, 0, 0, true) checkGoStats(t, stats[12], "013_up_down_no_tx_ctx.go", 13, 1, 1, false) checkGoStats(t, stats[13], "014_up_only_no_tx_ctx.go", 14, 1, 0, false) checkGoStats(t, stats[14], "015_down_only_no_tx_ctx.go", 15, 0, 1, false) checkGoStats(t, stats[15], "016_empty_no_tx_ctx.go", 16, 0, 0, false) } func checkGoStats(t *testing.T, stats *Stats, filename string, version int64, upCount, downCount int, tx bool) { t.Helper() require.Equal(t, filepath.Base(stats.FileName), filename) require.Equal(t, stats.Version, version) require.Equal(t, stats.UpCount, upCount) require.Equal(t, stats.DownCount, downCount) require.Equal(t, stats.Tx, tx) } func TestParsingGoMigrationsError(t *testing.T) { t.Parallel() _, err := parseGoFile(strings.NewReader(emptyInit)) require.Error(t, err) require.Contains(t, err.Error(), "no registered goose functions") _, err = parseGoFile(strings.NewReader(wrongName)) require.Error(t, err) require.Contains(t, err.Error(), "AddMigration, AddMigrationNoTx, AddMigrationContext, AddMigrationNoTxContext") } var ( upAndDown = `package foo import ( "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigration(up001, down001) } func up001(tx *sql.Tx) error { return nil } func down001(tx *sql.Tx) error { return nil }` downOnly = `package testgo import ( "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigration(nil, down002) } func down002(tx *sql.Tx) error { return nil }` upOnly = `package testgo import ( "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigration(up003, nil) } func up003(tx *sql.Tx) error { return nil }` upAndDownNil = `package testgo import ( "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigration(nil, nil) }` ) var ( upAndDownNoTx = `package foo import ( "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationNoTx(up001, down001) } func up001(db *sql.DB) error { return nil } func down001(db *sql.DB) error { return nil }` downOnlyNoTx = `package testgo import ( "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationNoTx(nil, down002) } func down002(db *sql.DB) error { return nil }` upOnlyNoTx = `package testgo import ( "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationNoTx(up003, nil) } func up003(db *sql.DB) error { return nil }` upAndDownNilNoTx = `package testgo import ( "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationNoTx(nil, nil) }` ) var ( emptyInit = `package testgo func init() {}` wrongName = `package testgo import ( "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationWrongName(nil, nil) }` ) ================================================ FILE: internal/migrationstats/migrationstats_walker.go ================================================ package migrationstats import ( "io" "os" "path/filepath" ) // NewFileWalker returns a new FileWalker for the given filenames. // // Filenames without a .sql or .go extension are ignored. func NewFileWalker(filenames ...string) FileWalker { return &fileWalker{ filenames: filenames, } } type fileWalker struct { filenames []string } var _ FileWalker = (*fileWalker)(nil) func (f *fileWalker) Walk(fn func(filename string, r io.Reader) error) error { for _, filename := range f.filenames { ext := filepath.Ext(filename) if ext != ".sql" && ext != ".go" { continue } if err := walk(filename, fn); err != nil { return err } } return nil } func walk(filename string, fn func(filename string, r io.Reader) error) error { file, err := os.Open(filename) if err != nil { return err } defer file.Close() return fn(filename, file) } ================================================ FILE: internal/sqlparser/parse.go ================================================ package sqlparser import ( "fmt" "io/fs" "go.uber.org/multierr" "golang.org/x/sync/errgroup" ) type ParsedSQL struct { UseTx bool Up, Down []string } func ParseAllFromFS(fsys fs.FS, filename string, debug bool) (*ParsedSQL, error) { parsedSQL := new(ParsedSQL) // TODO(mf): parse is called twice, once for up and once for down. This is inefficient. It // should be possible to parse both directions in one pass. Also, UseTx is set once (but // returned twice), which is unnecessary and potentially error-prone if the two calls to // parseSQL disagree based on direction. var g errgroup.Group g.Go(func() error { up, useTx, err := parse(fsys, filename, DirectionUp, debug) if err != nil { return err } parsedSQL.Up = up parsedSQL.UseTx = useTx return nil }) g.Go(func() error { down, _, err := parse(fsys, filename, DirectionDown, debug) if err != nil { return err } parsedSQL.Down = down return nil }) if err := g.Wait(); err != nil { return nil, err } return parsedSQL, nil } func parse(fsys fs.FS, filename string, direction Direction, debug bool) (_ []string, _ bool, retErr error) { r, err := fsys.Open(filename) if err != nil { return nil, false, err } defer func() { retErr = multierr.Append(retErr, r.Close()) }() stmts, useTx, err := ParseSQLMigration(r, direction, debug) if err != nil { return nil, false, fmt.Errorf("failed to parse %s: %w", filename, err) } return stmts, useTx, nil } ================================================ FILE: internal/sqlparser/parse_test.go ================================================ package sqlparser_test import ( "os" "testing" "testing/fstest" "github.com/pressly/goose/v3/internal/sqlparser" "github.com/stretchr/testify/require" ) func TestParseAllFromFS(t *testing.T) { t.Parallel() t.Run("file_not_exist", func(t *testing.T) { mapFS := fstest.MapFS{} _, err := sqlparser.ParseAllFromFS(mapFS, "001_foo.sql", false) require.Error(t, err) require.ErrorIs(t, err, os.ErrNotExist) }) t.Run("empty_file", func(t *testing.T) { mapFS := fstest.MapFS{ "001_foo.sql": &fstest.MapFile{}, } _, err := sqlparser.ParseAllFromFS(mapFS, "001_foo.sql", false) require.Error(t, err) require.Contains(t, err.Error(), "failed to parse migration") require.Contains(t, err.Error(), "must start with '-- +goose Up' annotation") }) t.Run("all_statements", func(t *testing.T) { mapFS := fstest.MapFS{ "001_foo.sql": newFile(` -- +goose Up `), "002_bar.sql": newFile(` -- +goose Up -- +goose Down `), "003_baz.sql": newFile(` -- +goose Up CREATE TABLE foo (id int); CREATE TABLE bar (id int); -- +goose Down DROP TABLE bar; `), "004_qux.sql": newFile(` -- +goose NO TRANSACTION -- +goose Up CREATE TABLE foo (id int); -- +goose Down DROP TABLE foo; `), } parsedSQL, err := sqlparser.ParseAllFromFS(mapFS, "001_foo.sql", false) require.NoError(t, err) assertParsedSQL(t, parsedSQL, true, 0, 0) parsedSQL, err = sqlparser.ParseAllFromFS(mapFS, "002_bar.sql", false) require.NoError(t, err) assertParsedSQL(t, parsedSQL, true, 0, 0) parsedSQL, err = sqlparser.ParseAllFromFS(mapFS, "003_baz.sql", false) require.NoError(t, err) assertParsedSQL(t, parsedSQL, true, 2, 1) parsedSQL, err = sqlparser.ParseAllFromFS(mapFS, "004_qux.sql", false) require.NoError(t, err) assertParsedSQL(t, parsedSQL, false, 1, 1) }) } func assertParsedSQL(t *testing.T, got *sqlparser.ParsedSQL, useTx bool, up, down int) { t.Helper() require.NotNil(t, got) require.Equal(t, len(got.Up), up) require.Equal(t, len(got.Down), down) require.Equal(t, got.UseTx, useTx) } func newFile(data string) *fstest.MapFile { return &fstest.MapFile{ Data: []byte(data), } } ================================================ FILE: internal/sqlparser/parser.go ================================================ package sqlparser import ( "bufio" "bytes" "errors" "fmt" "io" "log" "os" "strings" "sync" "github.com/mfridman/interpolate" ) type Direction string const ( DirectionUp Direction = "up" DirectionDown Direction = "down" ) func FromBool(b bool) Direction { if b { return DirectionUp } return DirectionDown } func (d Direction) String() string { return string(d) } func (d Direction) ToBool() bool { return d == DirectionUp } type parserState int const ( start parserState = iota // 0 gooseUp // 1 gooseStatementBeginUp // 2 gooseStatementEndUp // 3 gooseDown // 4 gooseStatementBeginDown // 5 gooseStatementEndDown // 6 ) type stateMachine struct { state parserState verbose bool } func newStateMachine(begin parserState, verbose bool) *stateMachine { return &stateMachine{ state: begin, verbose: verbose, } } func (s *stateMachine) get() parserState { return s.state } func (s *stateMachine) set(new parserState) { s.print("set %d => %d", s.state, new) s.state = new } const ( grayColor = "\033[90m" resetColor = "\033[00m" ) func (s *stateMachine) print(msg string, args ...any) { msg = "StateMachine: " + msg if s.verbose { log.Printf(grayColor+msg+resetColor, args...) } } const scanBufSize = 4 * 1024 * 1024 var bufferPool = sync.Pool{ New: func() any { buf := make([]byte, scanBufSize) return &buf }, } // Split given SQL script into individual statements and return // SQL statements for given direction (up=true, down=false). // // The base case is to simply split on semicolons, as these // naturally terminate a statement. // // However, more complex cases like pl/pgsql can have semicolons // within a statement. For these cases, we provide the explicit annotations // 'StatementBegin' and 'StatementEnd' to allow the script to // tell us to ignore semicolons. func ParseSQLMigration(r io.Reader, direction Direction, debug bool) (stmts []string, useTx bool, err error) { scanBufPtr := bufferPool.Get().(*[]byte) scanBuf := *scanBufPtr defer bufferPool.Put(scanBufPtr) scanner := bufio.NewScanner(r) scanner.Buffer(scanBuf, scanBufSize) stateMachine := newStateMachine(start, debug) useTx = true useEnvsub := false var buf bytes.Buffer for scanner.Scan() { line := scanner.Text() if debug { log.Println(line) } if stateMachine.get() == start && strings.TrimSpace(line) == "" { continue } // Check for annotations. // All annotations must be in format: "-- +goose [annotation]" if strings.HasPrefix(strings.TrimSpace(line), "--") && strings.Contains(line, "+goose") { var cmd annotation cmd, err = extractAnnotation(line) if err != nil { return nil, false, fmt.Errorf("failed to parse annotation line %q: %w", line, err) } switch cmd { case annotationUp: switch stateMachine.get() { case start: stateMachine.set(gooseUp) default: return nil, false, fmt.Errorf("duplicate '-- +goose Up' annotations; stateMachine=%d, see https://github.com/pressly/goose#sql-migrations", stateMachine.state) } continue case annotationDown: switch stateMachine.get() { case gooseUp, gooseStatementEndUp: // If we hit a down annotation, but the buffer is not empty, we have an unfinished SQL query from a // previous up annotation. This is an error, because we expect the SQL query to be terminated by a semicolon // and the buffer to have been reset. if bufferRemaining := strings.TrimSpace(buf.String()); len(bufferRemaining) > 0 { return nil, false, missingSemicolonError(stateMachine.state, direction, bufferRemaining) } stateMachine.set(gooseDown) default: return nil, false, fmt.Errorf("must start with '-- +goose Up' annotation, stateMachine=%d, see https://github.com/pressly/goose#sql-migrations", stateMachine.state) } continue case annotationStatementBegin: switch stateMachine.get() { case gooseUp, gooseStatementEndUp: stateMachine.set(gooseStatementBeginUp) case gooseDown, gooseStatementEndDown: stateMachine.set(gooseStatementBeginDown) default: return nil, false, fmt.Errorf("'-- +goose StatementBegin' must be defined after '-- +goose Up' or '-- +goose Down' annotation, stateMachine=%d, see https://github.com/pressly/goose#sql-migrations", stateMachine.state) } continue case annotationStatementEnd: switch stateMachine.get() { case gooseStatementBeginUp: stateMachine.set(gooseStatementEndUp) case gooseStatementBeginDown: stateMachine.set(gooseStatementEndDown) default: return nil, false, errors.New("'-- +goose StatementEnd' must be defined after '-- +goose StatementBegin', see https://github.com/pressly/goose#sql-migrations") } case annotationNoTransaction: useTx = false continue case annotationEnvsubOn: useEnvsub = true continue case annotationEnvsubOff: useEnvsub = false continue default: return nil, false, fmt.Errorf("unknown annotation: %q", cmd) } } // Once we've started parsing a statement the buffer is no longer empty, // we keep all comments up until the end of the statement (the buffer will be reset). // All other comments in the file are ignored. if buf.Len() == 0 { // This check ensures leading comments and empty lines prior to a statement are ignored. if strings.HasPrefix(strings.TrimSpace(line), "--") || line == "" { stateMachine.print("ignore comment") continue } } switch stateMachine.get() { case gooseStatementEndDown, gooseStatementEndUp: // Do not include the "+goose StatementEnd" annotation in the final statement. default: if useEnvsub { expanded, err := interpolate.Interpolate(&envWrapper{}, line) if err != nil { return nil, false, fmt.Errorf("variable substitution failed: %w:\n%s", err, line) } line = expanded } // Write SQL line to a buffer. if _, err := buf.WriteString(line + "\n"); err != nil { return nil, false, fmt.Errorf("failed to write to buf: %w", err) } } // Read SQL body one by line, if we're in the right direction. // // 1) basic query with semicolon; 2) psql statement // // Export statement once we hit end of statement. switch stateMachine.get() { case gooseUp, gooseStatementBeginUp, gooseStatementEndUp: if direction == DirectionDown { buf.Reset() stateMachine.print("ignore down") continue } case gooseDown, gooseStatementBeginDown, gooseStatementEndDown: if direction == DirectionUp { buf.Reset() stateMachine.print("ignore up") continue } default: return nil, false, fmt.Errorf("failed to parse migration: unexpected state %d on line %q, see https://github.com/pressly/goose#sql-migrations", stateMachine.state, line) } switch stateMachine.get() { case gooseUp: if endsWithSemicolon(line) { stmts = append(stmts, cleanupStatement(buf.String())) buf.Reset() stateMachine.print("store simple Up query") } case gooseDown: if endsWithSemicolon(line) { stmts = append(stmts, cleanupStatement(buf.String())) buf.Reset() stateMachine.print("store simple Down query") } case gooseStatementEndUp: stmts = append(stmts, cleanupStatement(buf.String())) buf.Reset() stateMachine.print("store Up statement") stateMachine.set(gooseUp) case gooseStatementEndDown: stmts = append(stmts, cleanupStatement(buf.String())) buf.Reset() stateMachine.print("store Down statement") stateMachine.set(gooseDown) } } if err := scanner.Err(); err != nil { return nil, false, fmt.Errorf("failed to scan migration: %w", err) } // EOF switch stateMachine.get() { case start: return nil, false, errors.New("failed to parse migration: must start with '-- +goose Up' annotation, see https://github.com/pressly/goose#sql-migrations") case gooseStatementBeginUp, gooseStatementBeginDown: return nil, false, errors.New("failed to parse migration: missing '-- +goose StatementEnd' annotation") } if bufferRemaining := strings.TrimSpace(buf.String()); len(bufferRemaining) > 0 { return nil, false, missingSemicolonError(stateMachine.state, direction, bufferRemaining) } return stmts, useTx, nil } type annotation string const ( annotationUp annotation = "Up" annotationDown annotation = "Down" annotationStatementBegin annotation = "StatementBegin" annotationStatementEnd annotation = "StatementEnd" annotationNoTransaction annotation = "NO TRANSACTION" annotationEnvsubOn annotation = "ENVSUB ON" annotationEnvsubOff annotation = "ENVSUB OFF" ) var supportedAnnotations = map[annotation]struct{}{ annotationUp: {}, annotationDown: {}, annotationStatementBegin: {}, annotationStatementEnd: {}, annotationNoTransaction: {}, annotationEnvsubOn: {}, annotationEnvsubOff: {}, } var ( errEmptyAnnotation = errors.New("empty annotation") errInvalidAnnotation = errors.New("invalid annotation") ) // extractAnnotation extracts the annotation from the line. // All annotations must be in format: "-- +goose [annotation]" // Allowed annotations: Up, Down, StatementBegin, StatementEnd, NO TRANSACTION, ENVSUB ON, ENVSUB OFF func extractAnnotation(line string) (annotation, error) { // If line contains leading whitespace - return error. if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { return "", fmt.Errorf("%q contains leading whitespace: %w", line, errInvalidAnnotation) } // Extract the annotation from the line, by removing the leading "--" cmd := strings.ReplaceAll(line, "--", "") // Extract the annotation from the line, by removing the leading "+goose" cmd = strings.Replace(cmd, "+goose", "", 1) if strings.Contains(cmd, "+goose") { return "", fmt.Errorf("%q contains multiple '+goose' annotations: %w", cmd, errInvalidAnnotation) } // Remove leading and trailing whitespace from the annotation command. cmd = strings.TrimSpace(cmd) if cmd == "" { return "", errEmptyAnnotation } a := annotation(cmd) for s := range supportedAnnotations { if strings.EqualFold(string(s), string(a)) { return s, nil } } return "", fmt.Errorf("%q not supported: %w", cmd, errInvalidAnnotation) } func missingSemicolonError(state parserState, direction Direction, s string) error { return fmt.Errorf("failed to parse migration: state %d, direction: %v: unexpected unfinished SQL query: %q: missing semicolon?", state, direction, s, ) } type envWrapper struct{} var _ interpolate.Env = (*envWrapper)(nil) func (e *envWrapper) Get(key string) (string, bool) { return os.LookupEnv(key) } func cleanupStatement(input string) string { return strings.TrimSpace(input) } // Checks the line to see if the line has a statement-ending semicolon // or if the line contains a double-dash comment. func endsWithSemicolon(line string) bool { scanBufPtr := bufferPool.Get().(*[]byte) scanBuf := *scanBufPtr defer bufferPool.Put(scanBufPtr) prev := "" scanner := bufio.NewScanner(strings.NewReader(line)) scanner.Buffer(scanBuf, scanBufSize) scanner.Split(bufio.ScanWords) for scanner.Scan() { word := scanner.Text() if strings.HasPrefix(word, "--") { break } prev = word } return strings.HasSuffix(prev, ";") } ================================================ FILE: internal/sqlparser/parser_test.go ================================================ package sqlparser import ( "fmt" "os" "path/filepath" "strconv" "strings" "testing" "github.com/stretchr/testify/require" ) var ( debug = false ) func TestMain(m *testing.M) { debug, _ = strconv.ParseBool(os.Getenv("DEBUG_TEST")) os.Exit(m.Run()) } func TestSemicolons(t *testing.T) { t.Parallel() type testData struct { line string result bool } tests := []testData{ {line: "END;", result: true}, {line: "END; -- comment", result: true}, {line: "END ; -- comment", result: true}, {line: "END -- comment", result: false}, {line: "END -- comment ;", result: false}, {line: "END \" ; \" -- comment", result: false}, } for _, test := range tests { r := endsWithSemicolon(test.line) if r != test.result { t.Errorf("incorrect semicolon. got %v, want %v", r, test.result) } } } func TestSplitStatements(t *testing.T) { t.Parallel() type testData struct { sql string up int down int } tt := []testData{ {sql: multilineSQL, up: 4, down: 1}, {sql: emptySQL, up: 0, down: 0}, {sql: emptySQL2, up: 0, down: 0}, {sql: functxt, up: 2, down: 2}, {sql: mysqlChangeDelimiter, up: 4, down: 0}, {sql: copyFromStdin, up: 1, down: 0}, {sql: plpgsqlSyntax, up: 2, down: 2}, {sql: plpgsqlSyntaxMixedStatements, up: 2, down: 2}, } for i, test := range tt { // up stmts, _, err := ParseSQLMigration(strings.NewReader(test.sql), DirectionUp, debug) if err != nil { t.Error(fmt.Errorf("tt[%v] unexpected error: %w", i, err)) } if len(stmts) != test.up { t.Errorf("tt[%v] incorrect number of up stmts. got %v (%+v), want %v", i, len(stmts), stmts, test.up) } // down stmts, _, err = ParseSQLMigration(strings.NewReader(test.sql), DirectionDown, debug) if err != nil { t.Error(fmt.Errorf("tt[%v] unexpected error: %w", i, err)) } if len(stmts) != test.down { t.Errorf("tt[%v] incorrect number of down stmts. got %v (%+v), want %v", i, len(stmts), stmts, test.down) } } } func TestInvalidUp(t *testing.T) { t.Parallel() testdataDir := filepath.Join("testdata", "invalid", "up") entries, err := os.ReadDir(testdataDir) require.NoError(t, err) require.NotEmpty(t, entries) for _, entry := range entries { by, err := os.ReadFile(filepath.Join(testdataDir, entry.Name())) require.NoError(t, err) _, _, err = ParseSQLMigration(strings.NewReader(string(by)), DirectionUp, false) require.Error(t, err) } } func TestUseTransactions(t *testing.T) { t.Parallel() type testData struct { fileName string useTransactions bool } tests := []testData{ {fileName: "testdata/valid-txn/00001_create_users_table.sql", useTransactions: true}, {fileName: "testdata/valid-txn/00002_rename_root.sql", useTransactions: true}, {fileName: "testdata/valid-txn/00003_no_transaction.sql", useTransactions: false}, } for _, test := range tests { f, err := os.Open(test.fileName) if err != nil { t.Error(err) } _, useTx, err := ParseSQLMigration(f, DirectionUp, debug) if err != nil { t.Error(err) } if useTx != test.useTransactions { t.Errorf("Failed transaction check. got %v, want %v", useTx, test.useTransactions) } f.Close() } } func TestParsingErrors(t *testing.T) { tt := []string{ statementBeginNoStatementEnd, unfinishedSQL, noUpDownAnnotations, multiUpDown, downFirst, } for i, sql := range tt { _, _, err := ParseSQLMigration(strings.NewReader(sql), DirectionUp, debug) if err == nil { t.Errorf("expected error on tt[%v] %q", i, sql) } } } var multilineSQL = `-- +goose Up CREATE TABLE post ( id int NOT NULL, title text, body text, PRIMARY KEY(id) ); -- 1st stmt -- comment SELECT 2; -- 2nd stmt SELECT 3; SELECT 3; -- 3rd stmt SELECT 4; -- 4th stmt -- +goose Down -- comment DROP TABLE post; -- 1st stmt ` var functxt = `-- +goose Up CREATE TABLE IF NOT EXISTS histories ( id BIGSERIAL PRIMARY KEY, current_value varchar(2000) NOT NULL, created_at timestamp with time zone NOT NULL ); -- +goose StatementBegin CREATE OR REPLACE FUNCTION histories_partition_creation( DATE, DATE ) returns void AS $$ DECLARE create_query text; BEGIN FOR create_query IN SELECT 'CREATE TABLE IF NOT EXISTS histories_' || TO_CHAR( d, 'YYYY_MM' ) || ' ( CHECK( created_at >= timestamp ''' || TO_CHAR( d, 'YYYY-MM-DD 00:00:00' ) || ''' AND created_at < timestamp ''' || TO_CHAR( d + INTERVAL '1 month', 'YYYY-MM-DD 00:00:00' ) || ''' ) ) inherits ( histories );' FROM generate_series( $1, $2, '1 month' ) AS d LOOP EXECUTE create_query; END LOOP; -- LOOP END END; -- FUNCTION END $$ language plpgsql; -- +goose StatementEnd -- +goose Down drop function histories_partition_creation(DATE, DATE); drop TABLE histories; ` var multiUpDown = `-- +goose Up CREATE TABLE post ( id int NOT NULL, title text, body text, PRIMARY KEY(id) ); -- +goose Down DROP TABLE post; -- +goose Up CREATE TABLE fancier_post ( id int NOT NULL, title text, body text, created_on timestamp without time zone, PRIMARY KEY(id) ); ` var downFirst = `-- +goose Down DROP TABLE fancier_post; ` var statementBeginNoStatementEnd = `-- +goose Up CREATE TABLE IF NOT EXISTS histories ( id BIGSERIAL PRIMARY KEY, current_value varchar(2000) NOT NULL, created_at timestamp with time zone NOT NULL ); -- +goose StatementBegin CREATE OR REPLACE FUNCTION histories_partition_creation( DATE, DATE ) returns void AS $$ DECLARE create_query text; BEGIN FOR create_query IN SELECT 'CREATE TABLE IF NOT EXISTS histories_' || TO_CHAR( d, 'YYYY_MM' ) || ' ( CHECK( created_at >= timestamp ''' || TO_CHAR( d, 'YYYY-MM-DD 00:00:00' ) || ''' AND created_at < timestamp ''' || TO_CHAR( d + INTERVAL '1 month', 'YYYY-MM-DD 00:00:00' ) || ''' ) ) inherits ( histories );' FROM generate_series( $1, $2, '1 month' ) AS d LOOP EXECUTE create_query; END LOOP; -- LOOP END END; -- FUNCTION END $$ language plpgsql; -- +goose Down drop function histories_partition_creation(DATE, DATE); drop TABLE histories; ` var unfinishedSQL = ` -- +goose Up ALTER TABLE post -- +goose Down ` var emptySQL = `-- +goose Up -- This is just a comment` var emptySQL2 = ` -- comment -- +goose Up -- comment -- +goose Down ` var noUpDownAnnotations = ` CREATE TABLE post ( id int NOT NULL, title text, body text, PRIMARY KEY(id) ); ` var mysqlChangeDelimiter = ` -- +goose Up -- +goose StatementBegin DELIMITER | -- +goose StatementEnd -- +goose StatementBegin CREATE FUNCTION my_func( str CHAR(255) ) RETURNS CHAR(255) DETERMINISTIC BEGIN RETURN "Dummy Body"; END | -- +goose StatementEnd -- +goose StatementBegin DELIMITER ; -- +goose StatementEnd select my_func("123") from dual; -- +goose Down ` var copyFromStdin = ` -- +goose Up -- +goose StatementBegin COPY public.django_content_type (id, app_label, model) FROM stdin; 1 admin logentry 2 auth permission 3 auth group 4 auth user 5 contenttypes contenttype 6 sessions session \. -- +goose StatementEnd ` var plpgsqlSyntax = ` -- +goose Up -- +goose StatementBegin CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$ language 'plpgsql'; -- +goose StatementEnd -- +goose StatementBegin CREATE TRIGGER update_properties_updated_at BEFORE UPDATE ON properties FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DROP TRIGGER update_properties_updated_at -- +goose StatementEnd -- +goose StatementBegin DROP FUNCTION update_updated_at_column() -- +goose StatementEnd ` var plpgsqlSyntaxMixedStatements = ` -- +goose Up -- +goose StatementBegin CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$ language 'plpgsql'; -- +goose StatementEnd CREATE TRIGGER update_properties_updated_at BEFORE UPDATE ON properties FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); -- +goose Down DROP TRIGGER update_properties_updated_at; DROP FUNCTION update_updated_at_column(); ` func TestValidUp(t *testing.T) { t.Parallel() // Test valid "up" parser logic. // // This test expects each directory, such as: internal/sqlparser/testdata/valid-up/test01 // // to contain exactly one migration file called "input.sql". We read this file and pass it // to the parser. Then we compare the statements against the golden files. // Each golden file is equivalent to one statement. // // ├── 01.up.golden.sql // ├── 02.up.golden.sql // ├── 03.up.golden.sql // └── input.sql tests := []struct { Name string StatementsCount int }{ {Name: "test01", StatementsCount: 3}, {Name: "test02", StatementsCount: 1}, {Name: "test03", StatementsCount: 1}, {Name: "test04", StatementsCount: 3}, {Name: "test05", StatementsCount: 2}, {Name: "test06", StatementsCount: 5}, {Name: "test07", StatementsCount: 1}, {Name: "test08", StatementsCount: 6}, {Name: "test09", StatementsCount: 1}, } for _, tc := range tests { path := filepath.Join("testdata", "valid-up", tc.Name) t.Run(tc.Name, func(t *testing.T) { testValid(t, path, tc.StatementsCount, DirectionUp) }) } } func testValid(t *testing.T, dir string, count int, direction Direction) { t.Helper() f, err := os.Open(filepath.Join(dir, "input.sql")) require.NoError(t, err) t.Cleanup(func() { f.Close() }) statements, _, err := ParseSQLMigration(f, direction, debug) require.NoError(t, err) require.Equal(t, len(statements), count) compareStatements(t, dir, statements, direction) } func compareStatements(t *testing.T, dir string, statements []string, direction Direction) { t.Helper() files, err := filepath.Glob(filepath.Join(dir, fmt.Sprintf("*.%s.golden.sql", direction))) require.NoError(t, err) if len(statements) != len(files) { t.Fatalf("mismatch between parsed statements (%d) and golden files (%d), did you check in NN.{up|down}.golden.sql file in %q?", len(statements), len(files), dir) } for _, goldenFile := range files { goldenFile = filepath.Base(goldenFile) before, _, ok := strings.Cut(goldenFile, ".") if !ok { t.Fatal(`failed to cut on file delimiter ".", must be of the format NN.{up|down}.golden.sql`) } index, err := strconv.Atoi(before) require.NoError(t, err) index-- goldenFilePath := filepath.Join(dir, goldenFile) by, err := os.ReadFile(goldenFilePath) require.NoError(t, err) got, want := statements[index], string(by) if got != want { if isCIEnvironment() { t.Errorf("input does not match expected golden file:\n\ngot:\n%s\n\nwant:\n%s\n", got, want) } else { t.Error("input does not match expected output; diff files with .FAIL to debug") t.Logf("\ndiff %v %v", filepath.Join("internal", "sqlparser", goldenFilePath+".FAIL"), filepath.Join("internal", "sqlparser", goldenFilePath), ) err := os.WriteFile(goldenFilePath+".FAIL", []byte(got), 0644) require.NoError(t, err) } } } } func isCIEnvironment() bool { ok, _ := strconv.ParseBool(os.Getenv("CI")) return ok } func TestEnvsub(t *testing.T) { // Do not run in parallel, as this test sets environment variables. // Test valid migrations with ${var} like statements when on are substituted for the whole // migration. t.Setenv("GOOSE_ENV_REGION", "us_east_") t.Setenv("GOOSE_ENV_SET_BUT_EMPTY_VALUE", "") t.Setenv("GOOSE_ENV_NAME", "foo") tests := []struct { Name string DownCount int UpCount int }{ {Name: "test01", UpCount: 4, DownCount: 1}, {Name: "test02", UpCount: 3, DownCount: 0}, {Name: "test03", UpCount: 1, DownCount: 0}, } for _, tc := range tests { t.Run(tc.Name, func(t *testing.T) { dir := filepath.Join("testdata", "envsub", tc.Name) testValid(t, dir, tc.UpCount, DirectionUp) testValid(t, dir, tc.DownCount, DirectionDown) }) } } func TestEnvsubError(t *testing.T) { t.Parallel() s := ` -- +goose ENVSUB ON -- +goose Up CREATE TABLE post ( id int NOT NULL, title text, ${SOME_UNSET_VAR?required env var not set} text, PRIMARY KEY(id) ); ` _, _, err := ParseSQLMigration(strings.NewReader(s), DirectionUp, debug) require.Error(t, err) require.Contains(t, err.Error(), "variable substitution failed: $SOME_UNSET_VAR: required env var not set:") } func Test_extractAnnotation(t *testing.T) { tests := []struct { name string input string want annotation wantErr bool }{ { name: "Up", input: "-- +goose Up", want: annotationUp, wantErr: false, }, { name: "Down", input: "-- +goose Down", want: annotationDown, wantErr: false, }, { name: "StmtBegin", input: "-- +goose StatementBegin", want: annotationStatementBegin, wantErr: false, }, { name: "NoTransact", input: "-- +goose NO TRANSACTION", want: annotationNoTransaction, wantErr: false, }, { name: "Unsupported", input: "-- +goose unsupported", want: "", wantErr: true, }, { name: "Empty", input: "-- +goose", want: "", wantErr: true, }, { name: "statement with spaces and Uppercase", input: "-- +goose UP ", want: annotationUp, wantErr: false, }, { name: "statement with leading whitespace - error", input: " -- +goose UP ", want: "", wantErr: true, }, { name: "statement with leading \t - error", input: "\t-- +goose UP ", want: "", wantErr: true, }, { name: "multiple +goose annotations - error", input: "-- +goose +goose Up", want: "", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := extractAnnotation(tt.input) if tt.wantErr { require.Error(t, err) } else { require.NoError(t, err) } require.Equal(t, tt.want, got) }) } } ================================================ FILE: internal/sqlparser/testdata/envsub/test01/01.down.golden.sql ================================================ DROP TABLE us_east_post; -- 1st stmt ================================================ FILE: internal/sqlparser/testdata/envsub/test01/01.up.golden.sql ================================================ CREATE TABLE us_east_post ( id int NOT NULL, title text, body text, PRIMARY KEY(id) ); -- 1st stmt ================================================ FILE: internal/sqlparser/testdata/envsub/test01/02.up.golden.sql ================================================ SELECT 2; -- 2nd stmt ================================================ FILE: internal/sqlparser/testdata/envsub/test01/03.up.golden.sql ================================================ SELECT 3; SELECT 3; -- 3rd stmt ================================================ FILE: internal/sqlparser/testdata/envsub/test01/04.up.golden.sql ================================================ SELECT 4; -- 4th stmt ================================================ FILE: internal/sqlparser/testdata/envsub/test01/input.sql ================================================ -- +goose ENVSUB ON -- +goose Up CREATE TABLE ${GOOSE_ENV_REGION}post ( id int NOT NULL, title text, body text, PRIMARY KEY(id) ); -- 1st stmt -- comment SELECT 2; -- 2nd stmt SELECT 3; SELECT 3; -- 3rd stmt SELECT 4; -- 4th stmt -- +goose Down -- comment DROP TABLE ${GOOSE_ENV_REGION}post; -- 1st stmt ================================================ FILE: internal/sqlparser/testdata/envsub/test02/01.up.golden.sql ================================================ CREATE TABLE post ( id int NOT NULL, title text, foo text, footitle3 text, defaulttitle4 text, title5 text, ); ================================================ FILE: internal/sqlparser/testdata/envsub/test02/02.up.golden.sql ================================================ CREATE TABLE post ( id int NOT NULL, title text, $GOOSE_ENV_NAME text, ${GOOSE_ENV_NAME}title3 text, ${ANOTHER_VAR:-default}title4 text, ${GOOSE_ENV_SET_BUT_EMPTY_VALUE-default}title5 text, ); ================================================ FILE: internal/sqlparser/testdata/envsub/test02/03.up.golden.sql ================================================ CREATE OR REPLACE FUNCTION test_func() RETURNS void AS $$ BEGIN RAISE NOTICE 'foo $GOOSE_ENV_NAME $GOOSE_ENV_NAME'; END; $$ LANGUAGE plpgsql; ================================================ FILE: internal/sqlparser/testdata/envsub/test02/input.sql ================================================ -- +goose Up -- +goose ENVSUB ON CREATE TABLE post ( id int NOT NULL, title text, $GOOSE_ENV_NAME text, ${GOOSE_ENV_NAME}title3 text, ${ANOTHER_VAR:-default}title4 text, ${GOOSE_ENV_SET_BUT_EMPTY_VALUE-default}title5 text, ); -- +goose ENVSUB OFF CREATE TABLE post ( id int NOT NULL, title text, $GOOSE_ENV_NAME text, ${GOOSE_ENV_NAME}title3 text, ${ANOTHER_VAR:-default}title4 text, ${GOOSE_ENV_SET_BUT_EMPTY_VALUE-default}title5 text, ); -- +goose StatementBegin CREATE OR REPLACE FUNCTION test_func() RETURNS void AS $$ -- +goose ENVSUB ON BEGIN RAISE NOTICE '${GOOSE_ENV_NAME} \$GOOSE_ENV_NAME \$GOOSE_ENV_NAME'; END; -- +goose ENVSUB OFF $$ LANGUAGE plpgsql; -- +goose StatementEnd ================================================ FILE: internal/sqlparser/testdata/envsub/test03/01.up.golden.sql ================================================ CREATE TABLE post ( id int NOT NULL, title text, $NAME text, ${NAME}title3 text, ${ANOTHER_VAR:-default}title4 text, ${SET_BUT_EMPTY_VALUE-default}title5 text, ); ================================================ FILE: internal/sqlparser/testdata/envsub/test03/input.sql ================================================ -- +goose Up CREATE TABLE post ( id int NOT NULL, title text, $NAME text, ${NAME}title3 text, ${ANOTHER_VAR:-default}title4 text, ${SET_BUT_EMPTY_VALUE-default}title5 text, ); ================================================ FILE: internal/sqlparser/testdata/invalid/up/a.sql ================================================ -- +goose Up SELECT * FROM foo; SELECT * FROM bar -- +goose Down SELECT * FROM baz; ================================================ FILE: internal/sqlparser/testdata/invalid/up/b.sql ================================================ -- +goose Up SELECT * FROM bar -- +goose Down SELECT * FROM baz; ================================================ FILE: internal/sqlparser/testdata/invalid/up/c.sql ================================================ -- +goose Up SELECT * FROM bar -- +goose Down ================================================ FILE: internal/sqlparser/testdata/invalid/up/d.sql ================================================ -- +goose Up SELECT * FROM bar ================================================ FILE: internal/sqlparser/testdata/valid-txn/00001_create_users_table.sql ================================================ -- +goose Up CREATE TABLE users ( id int NOT NULL PRIMARY KEY, username text, name text, surname text ); INSERT INTO users VALUES (0, 'root', '', ''), (1, 'vojtechvitek', 'Vojtech', 'Vitek'); -- +goose Down DROP TABLE users; ================================================ FILE: internal/sqlparser/testdata/valid-txn/00002_rename_root.sql ================================================ -- +goose Up -- +goose StatementBegin UPDATE users SET username='admin' WHERE username='root'; -- +goose StatementEnd -- +goose Down -- +goose StatementBegin UPDATE users SET username='root' WHERE username='admin'; -- +goose StatementEnd ================================================ FILE: internal/sqlparser/testdata/valid-txn/00003_no_transaction.sql ================================================ -- +goose NO TRANSACTION -- +goose Up CREATE TABLE post ( id int NOT NULL, title text, body text, PRIMARY KEY(id) ); -- +goose Down DROP TABLE post; ================================================ FILE: internal/sqlparser/testdata/valid-up/test01/01.up.golden.sql ================================================ CREATE TABLE emp ( empname text, salary integer, last_date timestamp, last_user text ); ================================================ FILE: internal/sqlparser/testdata/valid-up/test01/02.up.golden.sql ================================================ CREATE FUNCTION emp_stamp() RETURNS trigger AS $emp_stamp$ BEGIN -- Check that empname and salary are given IF NEW.empname IS NULL THEN RAISE EXCEPTION 'empname cannot be null'; END IF; IF NEW.salary IS NULL THEN RAISE EXCEPTION '% cannot have null salary', NEW.empname; END IF; -- Who works for us when they must pay for it? IF NEW.salary < 0 THEN RAISE EXCEPTION '% cannot have a negative salary', NEW.empname; END IF; -- Remember who changed the payroll when NEW.last_date := current_timestamp; NEW.last_user := current_user; RETURN NEW; END; $emp_stamp$ LANGUAGE plpgsql; ================================================ FILE: internal/sqlparser/testdata/valid-up/test01/03.up.golden.sql ================================================ CREATE TRIGGER emp_stamp BEFORE INSERT OR UPDATE ON emp FOR EACH ROW EXECUTE FUNCTION emp_stamp(); ================================================ FILE: internal/sqlparser/testdata/valid-up/test01/input.sql ================================================ -- +goose UP CREATE TABLE emp ( empname text, salary integer, last_date timestamp, last_user text ); -- +goose statementBegin CREATE FUNCTION emp_stamp() RETURNS trigger AS $emp_stamp$ BEGIN -- Check that empname and salary are given IF NEW.empname IS NULL THEN RAISE EXCEPTION 'empname cannot be null'; END IF; IF NEW.salary IS NULL THEN RAISE EXCEPTION '% cannot have null salary', NEW.empname; END IF; -- Who works for us when they must pay for it? IF NEW.salary < 0 THEN RAISE EXCEPTION '% cannot have a negative salary', NEW.empname; END IF; -- Remember who changed the payroll when NEW.last_date := current_timestamp; NEW.last_user := current_user; RETURN NEW; END; $emp_stamp$ LANGUAGE plpgsql; -- +goose StatementEnd CREATE TRIGGER emp_stamp BEFORE INSERT OR UPDATE ON emp FOR EACH ROW EXECUTE FUNCTION emp_stamp(); -- +goose Down ================================================ FILE: internal/sqlparser/testdata/valid-up/test02/01.up.golden.sql ================================================ CREATE TABLE emp ( empname text NOT NULL, salary integer ); CREATE TABLE emp_audit( operation char(1) NOT NULL, stamp timestamp NOT NULL, userid text NOT NULL, empname text NOT NULL, salary integer ); CREATE OR REPLACE FUNCTION process_emp_audit() RETURNS TRIGGER AS $emp_audit$ BEGIN -- -- Create a row in emp_audit to reflect the operation performed on emp, -- making use of the special variable TG_OP to work out the operation. -- IF (TG_OP = 'DELETE') THEN INSERT INTO emp_audit SELECT 'D', now(), user, OLD.*; ELSIF (TG_OP = 'UPDATE') THEN INSERT INTO emp_audit SELECT 'U', now(), user, NEW.*; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO emp_audit SELECT 'I', now(), user, NEW.*; END IF; RETURN NULL; -- result is ignored since this is an AFTER trigger END; $emp_audit$ LANGUAGE plpgsql; CREATE TRIGGER emp_audit AFTER INSERT OR UPDATE OR DELETE ON emp FOR EACH ROW EXECUTE FUNCTION process_emp_audit(); ================================================ FILE: internal/sqlparser/testdata/valid-up/test02/input.sql ================================================ -- +goose Up -- +goose StatementBegin CREATE TABLE emp ( empname text NOT NULL, salary integer ); CREATE TABLE emp_audit( operation char(1) NOT NULL, stamp timestamp NOT NULL, userid text NOT NULL, empname text NOT NULL, salary integer ); CREATE OR REPLACE FUNCTION process_emp_audit() RETURNS TRIGGER AS $emp_audit$ BEGIN -- -- Create a row in emp_audit to reflect the operation performed on emp, -- making use of the special variable TG_OP to work out the operation. -- IF (TG_OP = 'DELETE') THEN INSERT INTO emp_audit SELECT 'D', now(), user, OLD.*; ELSIF (TG_OP = 'UPDATE') THEN INSERT INTO emp_audit SELECT 'U', now(), user, NEW.*; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO emp_audit SELECT 'I', now(), user, NEW.*; END IF; RETURN NULL; -- result is ignored since this is an AFTER trigger END; $emp_audit$ LANGUAGE plpgsql; CREATE TRIGGER emp_audit AFTER INSERT OR UPDATE OR DELETE ON emp FOR EACH ROW EXECUTE FUNCTION process_emp_audit(); -- +goose StatementEnd -- +goose Down ================================================ FILE: internal/sqlparser/testdata/valid-up/test03/01.up.golden.sql ================================================ CREATE FUNCTION cs_update_referrer_type_proc() RETURNS INTEGER AS ' DECLARE referrer_keys RECORD; -- Declare a generic record to be used in a FOR a_output varchar(4000); BEGIN a_output := ''CREATE FUNCTION cs_find_referrer_type(varchar,varchar,varchar) RETURNS VARCHAR AS '''' DECLARE v_host ALIAS FOR $1; v_domain ALIAS FOR $2; v_url ALIAS FOR $3; BEGIN ''; -- -- Notice how we scan through the results of a query in a FOR loop -- using the FOR construct. -- FOR referrer_keys IN SELECT * FROM cs_referrer_keys ORDER BY try_order LOOP a_output := a_output || '' IF v_'' || referrer_keys.kind || '' LIKE '''''''''' || referrer_keys.key_string || '''''''''' THEN RETURN '''''' || referrer_keys.referrer_type || ''''''; END IF;''; END LOOP; a_output := a_output || '' RETURN NULL; END; '''' LANGUAGE ''''plpgsql'''';''; -- This works because we are not substituting any variables -- Otherwise it would fail. Look at PERFORM for another way to run functions EXECUTE a_output; END; ' LANGUAGE 'plpgsql'; -- This comment WILL BE preserved. -- And so will this one. ================================================ FILE: internal/sqlparser/testdata/valid-up/test03/input.sql ================================================ -- +goose Up -- +goose StatementBegin CREATE FUNCTION cs_update_referrer_type_proc() RETURNS INTEGER AS ' DECLARE referrer_keys RECORD; -- Declare a generic record to be used in a FOR a_output varchar(4000); BEGIN a_output := ''CREATE FUNCTION cs_find_referrer_type(varchar,varchar,varchar) RETURNS VARCHAR AS '''' DECLARE v_host ALIAS FOR $1; v_domain ALIAS FOR $2; v_url ALIAS FOR $3; BEGIN ''; -- -- Notice how we scan through the results of a query in a FOR loop -- using the FOR construct. -- FOR referrer_keys IN SELECT * FROM cs_referrer_keys ORDER BY try_order LOOP a_output := a_output || '' IF v_'' || referrer_keys.kind || '' LIKE '''''''''' || referrer_keys.key_string || '''''''''' THEN RETURN '''''' || referrer_keys.referrer_type || ''''''; END IF;''; END LOOP; a_output := a_output || '' RETURN NULL; END; '''' LANGUAGE ''''plpgsql'''';''; -- This works because we are not substituting any variables -- Otherwise it would fail. Look at PERFORM for another way to run functions EXECUTE a_output; END; ' LANGUAGE 'plpgsql'; -- This comment WILL BE preserved. -- And so will this one. -- +goose StatementEnd -- +goose Down ================================================ FILE: internal/sqlparser/testdata/valid-up/test04/01.up.golden.sql ================================================ CREATE TABLE ssh_keys ( id integer NOT NULL, "publicKey" text ); ================================================ FILE: internal/sqlparser/testdata/valid-up/test04/02.up.golden.sql ================================================ INSERT INTO ssh_keys (id, "publicKey") VALUES (1, '-----BEGIN RSA PUBLIC KEY----- MIIBCgKCAQEAqAo9QORIXMPMa/qv8908Z2sH2+Xa/wITYTJQ2ojTZlgsQiQf85ifw3dgvVZK M7Zifl2NyVVCPb0hELr2JJla1u/1CgiuqDpcjP2cCu2YxB/JGyCvcon+3tETUz3Ri9NGzHCZ fkuWRZjkUvy7nfPLjzM+t6SEvY4lbn3ihLPumZjwgvuCY3vDZY8V1/NMoP8MKATGR+S7D7gv I6KD9jkiSsTJMiotb/dRkXE3bG0nmjchhhLzMG551G8IZEpWBHDqEisCIl8yCd9YZV69BZTu L48zPl/CFvA+KJJ6LklxfwWeVDQ+ve2OIW0B1uLhR/MsoYbDQztbgIayg6ieMO/KlQIDAQAB -----END RSA PUBLIC KEY----- '); ================================================ FILE: internal/sqlparser/testdata/valid-up/test04/03.up.golden.sql ================================================ INSERT INTO ssh_keys (id, "publicKey") VALUES (2, '-----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABsQAAAAdzc2gtZH NzAAAAgQDKYlGRv+Ul4WYKN3F83zchQC24ZV17EJCKwakASf//u2p1+K4iuTQbPgcUWpET w4HkCRlOVwYkM4ZL2QncUDyEX3o0UDEgrnhWhLJJR2yCMSsqTNffME3X7YdP2LcE0OM9ZP 9vb5+TwLr6c4edwlu3QYc4VStWSstdR9DD7vun9QAAABUA+DRjW9u+5IEPHyx+FhEoKsRm 8aEAAACAU2auArSDuTVbJBE/oP6x+4vrud2qFtALsuFaLPfqN5gVBofRJlIOpQobyi022R 6SPtX/DEbWkVuHfiyxkjb0Gb0obBzGM+RNqjOF6OE9sh7OuBfLaWW44OZasg1VXzkRcoWv 7jClfKYi2Q/LxHGhZoqy1uYlHPYP5CmCpiELrUMAAACAfp0KpTVyYQjO2nLPuBhnsepfxQ kT+FDqDBp1rZfB4uKx4q466Aq0jeev1OeQEYZpj3+q4b2XX54zXDwvJLuiD9WSmC7jvT0+ EUmF55PHW4inloG9pMUzeQnx3k8WDcRJbcAMalpoCCsb0jEPIiyBGBtQu0gOoLL+N+G2Cl U+/FEAAAHgOKSlwjikpcIAAAAHc3NoLWRzcwAAAIEAymJRkb/lJeFmCjdxfN83IUAtuGVd exCQisGpAEn//7tqdfiuIrk0Gz4HFFqRE8OB5AkZTlcGJDOGS9kJ3FA8hF96NFAxIK54Vo SySUdsgjErKkzX3zBN1+2HT9i3BNDjPWT/b2+fk8C6+nOHncJbt0GHOFUrVkrLXUfQw+77 p/UAAAAVAPg0Y1vbvuSBDx8sfhYRKCrEZvGhAAAAgFNmrgK0g7k1WyQRP6D+sfuL67ndqh bQC7LhWiz36jeYFQaH0SZSDqUKG8otNtkekj7V/wxG1pFbh34ssZI29Bm9KGwcxjPkTaoz hejhPbIezrgXy2lluODmWrINVV85EXKFr+4wpXymItkPy8RxoWaKstbmJRz2D+QpgqYhC6 1DAAAAgH6dCqU1cmEIztpyz7gYZ7HqX8UJE/hQ6gwada2XweLiseKuOugKtI3nr9TnkBGG aY9/quG9l1+eM1w8LyS7og/Vkpgu4709PhFJheeTx1uIp5aBvaTFM3kJ8d5PFg3ESW3ADG paaAgrG9IxDyIsgRgbULtIDqCy/jfhtgpVPvxRAAAAFQCPXzpVtY5yJTN1zBo9pTGeg+f3 EgAAAAZub25hbWUBAgME -----END OPENSSH PRIVATE KEY----- '); ================================================ FILE: internal/sqlparser/testdata/valid-up/test04/input.sql ================================================ -- +goose Up CREATE TABLE ssh_keys ( id integer NOT NULL, "publicKey" text ); -- +goose StatementBegin INSERT INTO ssh_keys (id, "publicKey") VALUES (1, '-----BEGIN RSA PUBLIC KEY----- MIIBCgKCAQEAqAo9QORIXMPMa/qv8908Z2sH2+Xa/wITYTJQ2ojTZlgsQiQf85ifw3dgvVZK M7Zifl2NyVVCPb0hELr2JJla1u/1CgiuqDpcjP2cCu2YxB/JGyCvcon+3tETUz3Ri9NGzHCZ fkuWRZjkUvy7nfPLjzM+t6SEvY4lbn3ihLPumZjwgvuCY3vDZY8V1/NMoP8MKATGR+S7D7gv I6KD9jkiSsTJMiotb/dRkXE3bG0nmjchhhLzMG551G8IZEpWBHDqEisCIl8yCd9YZV69BZTu L48zPl/CFvA+KJJ6LklxfwWeVDQ+ve2OIW0B1uLhR/MsoYbDQztbgIayg6ieMO/KlQIDAQAB -----END RSA PUBLIC KEY----- '); -- +goose StatementEnd INSERT INTO ssh_keys (id, "publicKey") VALUES (2, '-----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABsQAAAAdzc2gtZH NzAAAAgQDKYlGRv+Ul4WYKN3F83zchQC24ZV17EJCKwakASf//u2p1+K4iuTQbPgcUWpET w4HkCRlOVwYkM4ZL2QncUDyEX3o0UDEgrnhWhLJJR2yCMSsqTNffME3X7YdP2LcE0OM9ZP 9vb5+TwLr6c4edwlu3QYc4VStWSstdR9DD7vun9QAAABUA+DRjW9u+5IEPHyx+FhEoKsRm 8aEAAACAU2auArSDuTVbJBE/oP6x+4vrud2qFtALsuFaLPfqN5gVBofRJlIOpQobyi022R 6SPtX/DEbWkVuHfiyxkjb0Gb0obBzGM+RNqjOF6OE9sh7OuBfLaWW44OZasg1VXzkRcoWv 7jClfKYi2Q/LxHGhZoqy1uYlHPYP5CmCpiELrUMAAACAfp0KpTVyYQjO2nLPuBhnsepfxQ kT+FDqDBp1rZfB4uKx4q466Aq0jeev1OeQEYZpj3+q4b2XX54zXDwvJLuiD9WSmC7jvT0+ EUmF55PHW4inloG9pMUzeQnx3k8WDcRJbcAMalpoCCsb0jEPIiyBGBtQu0gOoLL+N+G2Cl U+/FEAAAHgOKSlwjikpcIAAAAHc3NoLWRzcwAAAIEAymJRkb/lJeFmCjdxfN83IUAtuGVd exCQisGpAEn//7tqdfiuIrk0Gz4HFFqRE8OB5AkZTlcGJDOGS9kJ3FA8hF96NFAxIK54Vo SySUdsgjErKkzX3zBN1+2HT9i3BNDjPWT/b2+fk8C6+nOHncJbt0GHOFUrVkrLXUfQw+77 p/UAAAAVAPg0Y1vbvuSBDx8sfhYRKCrEZvGhAAAAgFNmrgK0g7k1WyQRP6D+sfuL67ndqh bQC7LhWiz36jeYFQaH0SZSDqUKG8otNtkekj7V/wxG1pFbh34ssZI29Bm9KGwcxjPkTaoz hejhPbIezrgXy2lluODmWrINVV85EXKFr+4wpXymItkPy8RxoWaKstbmJRz2D+QpgqYhC6 1DAAAAgH6dCqU1cmEIztpyz7gYZ7HqX8UJE/hQ6gwada2XweLiseKuOugKtI3nr9TnkBGG aY9/quG9l1+eM1w8LyS7og/Vkpgu4709PhFJheeTx1uIp5aBvaTFM3kJ8d5PFg3ESW3ADG paaAgrG9IxDyIsgRgbULtIDqCy/jfhtgpVPvxRAAAAFQCPXzpVtY5yJTN1zBo9pTGeg+f3 EgAAAAZub25hbWUBAgME -----END OPENSSH PRIVATE KEY----- '); -- +goose Down ================================================ FILE: internal/sqlparser/testdata/valid-up/test05/01.up.golden.sql ================================================ CREATE TABLE ssh_keys ( id integer NOT NULL, "publicKey" text -- insert comment there ); ================================================ FILE: internal/sqlparser/testdata/valid-up/test05/02.up.golden.sql ================================================ CREATE TABLE ssh_keys_backup ( id integer NOT NULL, -- insert comment here "publicKey" text -- insert comment there ); ================================================ FILE: internal/sqlparser/testdata/valid-up/test05/input.sql ================================================ -- +goose Up CREATE TABLE ssh_keys ( id integer NOT NULL, "publicKey" text -- insert comment there ); -- insert comment there -- This is a dangling comment -- Another comment -- Foo comment CREATE TABLE ssh_keys_backup ( id integer NOT NULL, -- insert comment here "publicKey" text -- insert comment there ); -- +goose Down ================================================ FILE: internal/sqlparser/testdata/valid-up/test06/01.up.golden.sql ================================================ CREATE TABLE article ( id text, content text); ================================================ FILE: internal/sqlparser/testdata/valid-up/test06/02.up.golden.sql ================================================ INSERT INTO article (id, content) VALUES ('id_0001', E'# My markdown doc first paragraph second paragraph'); ================================================ FILE: internal/sqlparser/testdata/valid-up/test06/03.up.golden.sql ================================================ INSERT INTO article (id, content) VALUES ('id_0002', E'# My second markdown doc first paragraph -- with a comment -- with an indent comment second paragraph'); ================================================ FILE: internal/sqlparser/testdata/valid-up/test06/04.up.golden.sql ================================================ CREATE FUNCTION do_something(sql TEXT) RETURNS INTEGER AS $$ BEGIN -- initiate technology PERFORM something_or_other(sql); -- increase technology PERFORM some_other_thing(sql); -- technology was successful RETURN 1; END; $$ LANGUAGE plpgsql; -- 3 this comment WILL BE preserved -- 4 this comment WILL BE preserved ================================================ FILE: internal/sqlparser/testdata/valid-up/test06/05.up.golden.sql ================================================ INSERT INTO post (id, title, body) VALUES ('id_01', 'my_title', ' this is an insert statement including empty lines. empty (blank) lines can be meaningful. leave the lines to keep the text syntax. '); ================================================ FILE: internal/sqlparser/testdata/valid-up/test06/input.sql ================================================ -- +goose Up CREATE TABLE article ( id text, content text); INSERT INTO article (id, content) VALUES ('id_0001', E'# My markdown doc first paragraph second paragraph'); INSERT INTO article (id, content) VALUES ('id_0002', E'# My second markdown doc first paragraph -- with a comment -- with an indent comment second paragraph'); -- +goose StatementBegin -- 1 this comment will NOT be preserved -- 2 this comment will NOT be preserved CREATE FUNCTION do_something(sql TEXT) RETURNS INTEGER AS $$ BEGIN -- initiate technology PERFORM something_or_other(sql); -- increase technology PERFORM some_other_thing(sql); -- technology was successful RETURN 1; END; $$ LANGUAGE plpgsql; -- 3 this comment WILL BE preserved -- 4 this comment WILL BE preserved -- +goose StatementEnd INSERT INTO post (id, title, body) VALUES ('id_01', 'my_title', ' this is an insert statement including empty lines. empty (blank) lines can be meaningful. leave the lines to keep the text syntax. '); -- +goose Down TRUNCATE TABLE post; ================================================ FILE: internal/sqlparser/testdata/valid-up/test07/01.up.golden.sql ================================================ CREATE INDEX ON public.users (user_id); ================================================ FILE: internal/sqlparser/testdata/valid-up/test07/input.sql ================================================ -- +goose Up -- +goose StatementBegin CREATE INDEX ON public.users (user_id); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DROP INDEX IF EXISTS users_user_id_idx; -- +goose StatementEnd ================================================ FILE: internal/sqlparser/testdata/valid-up/test08/01.up.golden.sql ================================================ CREATE TABLE `table_a` ( `column_1` DATETIME DEFAULT NOW(), `column_2` DATETIME DEFAULT NOW(), `column_3` DATETIME DEFAULT NOW() ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; ================================================ FILE: internal/sqlparser/testdata/valid-up/test08/02.up.golden.sql ================================================ CREATE TABLE `table_b` ( `column_1` DATETIME DEFAULT NOW(), `column_2` DATETIME DEFAULT NOW(), `column_3` DATETIME DEFAULT NOW() ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; ================================================ FILE: internal/sqlparser/testdata/valid-up/test08/03.up.golden.sql ================================================ CREATE TABLE `table_c` ( `column_1` DATETIME DEFAULT NOW(), `column_2` DATETIME DEFAULT NOW(), `column_3` DATETIME DEFAULT NOW() ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; ================================================ FILE: internal/sqlparser/testdata/valid-up/test08/04.up.golden.sql ================================================ /*!80031 ALTER TABLE `table_a` MODIFY `column_1` TEXT NOT NULL */; ================================================ FILE: internal/sqlparser/testdata/valid-up/test08/05.up.golden.sql ================================================ /*!80031 ALTER TABLE `table_b` MODIFY `column_2` TEXT NOT NULL */; ================================================ FILE: internal/sqlparser/testdata/valid-up/test08/06.up.golden.sql ================================================ /*!80033 ALTER TABLE `table_c` MODIFY `column_3` TEXT NOT NULL */; ================================================ FILE: internal/sqlparser/testdata/valid-up/test08/input.sql ================================================ -- +goose Up CREATE TABLE `table_a` ( `column_1` DATETIME DEFAULT NOW(), `column_2` DATETIME DEFAULT NOW(), `column_3` DATETIME DEFAULT NOW() ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; CREATE TABLE `table_b` ( `column_1` DATETIME DEFAULT NOW(), `column_2` DATETIME DEFAULT NOW(), `column_3` DATETIME DEFAULT NOW() ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; CREATE TABLE `table_c` ( `column_1` DATETIME DEFAULT NOW(), `column_2` DATETIME DEFAULT NOW(), `column_3` DATETIME DEFAULT NOW() ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; /*!80031 ALTER TABLE `table_a` MODIFY `column_1` TEXT NOT NULL */; /*!80031 ALTER TABLE `table_b` MODIFY `column_2` TEXT NOT NULL */; /*!80033 ALTER TABLE `table_c` MODIFY `column_3` TEXT NOT NULL */; ================================================ FILE: internal/sqlparser/testdata/valid-up/test09/01.up.golden.sql ================================================ create table t ( id int ); update rows set value = now() -- missing semicolon. valid statement because wrapped in goose annotation, but will fail when executed. ================================================ FILE: internal/sqlparser/testdata/valid-up/test09/input.sql ================================================ -- +goose Up -- +goose StatementBegin create table t ( id int ); update rows set value = now() -- missing semicolon. valid statement because wrapped in goose annotation, but will fail when executed. -- +goose StatementEnd -- +goose Down DROP TABLE IF EXISTS t; ================================================ FILE: internal/testing/go.mod ================================================ module github.com/pressly/goose/v3/internal/testing go 1.25.0 require ( cloud.google.com/go/spanner v1.88.0 github.com/ClickHouse/clickhouse-go/v2 v2.43.0 github.com/go-sql-driver/mysql v1.9.3 github.com/googleapis/go-sql-spanner v1.24.0 github.com/jackc/pgx/v5 v5.8.0 github.com/ory/dockertest/v3 v3.12.0 github.com/pressly/goose/v3 v3.26.0 github.com/stretchr/testify v1.11.1 github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc github.com/ydb-platform/ydb-go-sdk/v3 v3.127.0 golang.org/x/sync v0.19.0 ) require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect cloud.google.com/go/longrunning v0.8.0 // indirect cloud.google.com/go/monitoring v1.24.3 // indirect dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.2.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/ClickHouse/ch-go v0.71.0 // indirect github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.6.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/containerd/continuity v0.4.5 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/cli v29.2.1+incompatible // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/moby/api v1.53.0 // indirect github.com/moby/moby/client v0.2.2 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/runc v1.3.3 // indirect github.com/paulmach/orb v0.12.0 // indirect github.com/pierrec/lz4/v4 v4.1.25 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/ydb-platform/ydb-go-genproto v0.0.0-20260128080146-c4ed16b24b37 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.40.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/sdk v1.40.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/net v0.50.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/api v0.267.0 // indirect google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: internal/testing/go.sum ================================================ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= cloud.google.com/go/spanner v1.88.0 h1:HS+5TuEYZOVOXj9K+0EtrbTw7bKBLrMe3vgGsbnehmU= cloud.google.com/go/spanner v1.88.0/go.mod h1:MzulBwuuYwQUVdkZXBBFapmXee3N+sQrj2T/yup6uEE= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= github.com/ClickHouse/clickhouse-go/v2 v2.43.0 h1:fUR05TrF1GyvLDa/mAQjkx7KbgwdLRffs2n9O3WobtE= github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g= github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.6.0 h1:BzsL0qE7LvtTEtXG7Dt5NS1EP0CQwI21HZfj9aGghhw= github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.6.0/go.mod h1:I7kE2kM3qCr9QPT4cU4cCFYkEpVyVr16YOGUHzy+nR0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/cli v29.2.1+incompatible h1:n3Jt0QVCN65eiVBoUTZQM9mcQICCJt3akW4pKAbKdJg= github.com/docker/cli v29.2.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ= github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/googleapis/go-sql-spanner v1.24.0 h1:K36siIx0KEka6Xttu2Rti9cBGK7mYQIoo6gUrW87uTU= github.com/googleapis/go-sql-spanner v1.24.0/go.mod h1:ltBracyoOyIYJjTQcDxuYmJDfPgknsQMs63liLSF4AA= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/moby/api v1.53.0 h1:PihqG1ncw4W+8mZs69jlwGXdaYBeb5brF6BL7mPIS/w= github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= github.com/moby/moby/client v0.2.2 h1:Pt4hRMCAIlyjL3cr8M5TrXCwKzguebPAc2do2ur7dEM= github.com/moby/moby/client v0.2.2/go.mod h1:2EkIPVNCqR05CMIzL1mfA07t0HvVUUOl85pasRz/GmQ= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/runc v1.3.3 h1:qlmBbbhu+yY0QM7jqfuat7M1H3/iXjju3VkP9lkFQr4= github.com/opencontainers/runc v1.3.3/go.mod h1:D7rL72gfWxVs9cJ2/AayxB0Hlvn9g0gaF1R7uunumSI= github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw= github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE= github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s= github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rekby/fixenv v0.6.1 h1:jUFiSPpajT4WY2cYuc++7Y1zWrnCxnovGCIX72PZniM= github.com/rekby/fixenv v0.6.1/go.mod h1:/b5LRc06BYJtslRtHKxsPWFT/ySpHV+rWvzTg+XWk4c= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc h1:lzi/5fg2EfinRlh3v//YyIhnc4tY7BTqazQGwb1ar+0= github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/ydb-platform/ydb-go-genproto v0.0.0-20260128080146-c4ed16b24b37 h1:kUXMT/fM/DpDT66WQgRUf3I8VOAWjypkMf52W5PChwA= github.com/ydb-platform/ydb-go-genproto v0.0.0-20260128080146-c4ed16b24b37/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= github.com/ydb-platform/ydb-go-sdk/v3 v3.127.0 h1:OfHS9ZkZgCy6y/CJ9N8123DXrgaY2BPxWsQiQ8e3wC8= github.com/ydb-platform/ydb-go-sdk/v3 v3.127.0/go.mod h1:stS1mQYjbJvwwYaYzKyFY9eMiuVXWWXQA6T+SpOLg9c= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA= go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE= google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc= google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI= google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= ================================================ FILE: internal/testing/integration/README.md ================================================ # Integration tests This directory contains integration tests for the [pressly/goose/v3][goose_module] Go module. An integration test is a test that runs against a real database (docker container) and exercises the same driver used by the CLI. ## Why is this a separate module? There are separate `go.mod` and `go.sum` files in this directory to allow for the use of different dependencies. We leverage [multi-module workspaces](https://go.dev/doc/tutorial/workspaces) to glue things together. Namely, we want to avoid dependencies on docker, sql drivers, and other dependencies **that are** not necessary for the core functionality of the goose library. ## Overview There are separate migration files for each database that we support, see the [migrations directory][migrations_dir]. Databases typically have different SQL syntax and features, so the migration files are different. A good set of migrations should be representative of the types of migrations users will write typically write. This should include: - Creating and dropping tables - Adding and removing columns - Creating and dropping indexes - Inserting and deleting data - Complex SQL statements that require special handling with `StatementBegin` and `StatementEnd` annotations - Statements that must run outside a transaction, annotated with `-- +goose NO TRANSACTION` There is a common test function that applies migrations up, down and then up again. The gold standard is the PostgreSQL migration files. We try to make other migration files as close to the PostgreSQL files as possible, but this is not always possible or desirable. Lastly, some tests will assert for database state after migrations are applied. To add a new `.sql` file, you can use the following command: ``` goose -s -dir testdata/migrations/ create sql ``` - Update the database name (e.g. `postgres`) - Update the filename name (e.g. `b`) as needed ## Limitation Note, the integration tests are not exhaustive. They are meantto ensure that the goose library works with the various databases that we support and the chosen drivers. We do not test every possible combination of operations, nor do we test every possible edge case. We rely on the unit tests in the goose package to cover library-specific logic. [goose_module]: https://pkg.go.dev/github.com/pressly/goose/v3 [migrations_dir]: ./testdata/migrations ================================================ FILE: internal/testing/integration/database_test.go ================================================ package integration import ( "testing" "time" "github.com/pressly/goose/v3" "github.com/pressly/goose/v3/database" "github.com/pressly/goose/v3/internal/testing/testdb" "github.com/stretchr/testify/require" ) func TestPostgres(t *testing.T) { t.Parallel() db, cleanup, err := testdb.NewPostgres() require.NoError(t, err) t.Cleanup(cleanup) require.NoError(t, db.Ping()) testDatabase(t, database.DialectPostgres, db, "testdata/migrations/postgres") } func TestSpanner(t *testing.T) { t.Parallel() db, cleanup, err := testdb.NewSpanner() require.NoError(t, err) t.Cleanup(cleanup) require.NoError(t, db.Ping()) testDatabase(t, database.DialectSpanner, db, "testdata/migrations/spanner", goose.WithIsolateDDL(true)) } func TestClickhouse(t *testing.T) { t.Parallel() db, cleanup, err := testdb.NewClickHouse() require.NoError(t, err) t.Cleanup(cleanup) require.NoError(t, db.Ping()) testDatabase(t, database.DialectClickHouse, db, "testdata/migrations/clickhouse") type result struct { customerID string `db:"customer_id"` timestamp time.Time `db:"time_stamp"` clickEventType string `db:"click_event_type"` countryCode string `db:"country_code"` sourceID int64 `db:"source_id"` } rows, err := db.Query(`SELECT * FROM clickstream ORDER BY customer_id`) require.NoError(t, err) var results []result for rows.Next() { var r result err = rows.Scan(&r.customerID, &r.timestamp, &r.clickEventType, &r.countryCode, &r.sourceID) require.NoError(t, err) results = append(results, r) } require.Equal(t, len(results), 3) require.NoError(t, rows.Close()) require.NoError(t, rows.Err()) parseTime := func(t *testing.T, s string) time.Time { t.Helper() tm, err := time.Parse("2006-01-02", s) require.NoError(t, err) return tm } want := []result{ {"customer1", parseTime(t, "2021-10-02"), "add_to_cart", "US", 568239}, {"customer2", parseTime(t, "2021-10-30"), "remove_from_cart", "", 0}, {"customer3", parseTime(t, "2021-11-07"), "checkout", "", 307493}, } for i, result := range results { require.Equal(t, result.customerID, want[i].customerID) require.Equal(t, result.timestamp, want[i].timestamp) require.Equal(t, result.clickEventType, want[i].clickEventType) if result.countryCode != "" && want[i].countryCode != "" { require.Equal(t, result.countryCode, want[i].countryCode) } require.Equal(t, result.sourceID, want[i].sourceID) } } func TestClickhouseRemote(t *testing.T) { t.Parallel() db, cleanup, err := testdb.NewClickHouse() require.NoError(t, err) t.Cleanup(cleanup) require.NoError(t, db.Ping()) testDatabase(t, database.DialectClickHouse, db, "testdata/migrations/clickhouse-remote") // assert that the taxi_zone_dictionary table has been created and populated var count int err = db.QueryRow(`SELECT count(*) FROM taxi_zone_dictionary`).Scan(&count) require.NoError(t, err) require.Equal(t, 265, count) } func TestMySQL(t *testing.T) { t.Parallel() db, cleanup, err := testdb.NewMariaDB() require.NoError(t, err) t.Cleanup(cleanup) require.NoError(t, db.Ping()) testDatabase(t, database.DialectMySQL, db, "testdata/migrations/mysql") } func TestTurso(t *testing.T) { t.Parallel() db, cleanup, err := testdb.NewTurso() require.NoError(t, err) t.Cleanup(cleanup) require.NoError(t, db.Ping()) testDatabase(t, database.DialectTurso, db, "testdata/migrations/turso") } func TestYDB(t *testing.T) { t.Parallel() db, cleanup, err := testdb.NewYdb() require.NoError(t, err) t.Cleanup(cleanup) require.NoError(t, db.Ping()) testDatabase(t, database.DialectYdB, db, "testdata/migrations/ydb") } func TestStarrocks(t *testing.T) { t.Parallel() // t.Skip("Starrocks is flaky on CI, see https://github.com/pressly/goose/issues/881") db, cleanup, err := testdb.NewStarrocks() require.NoError(t, err) t.Cleanup(cleanup) require.NoError(t, db.Ping()) testDatabase(t, database.DialectStarrocks, db, "testdata/migrations/starrocks", goose.WithIsolateDDL(true)) } ================================================ FILE: internal/testing/integration/integration.go ================================================ package integration import ( "context" "database/sql" "errors" "os" "path/filepath" "testing" "github.com/pressly/goose/v3" "github.com/pressly/goose/v3/database" "github.com/stretchr/testify/require" ) type collected struct { fullpath string version int64 } func collectMigrations(t *testing.T, dir string) []collected { t.Helper() files, err := os.ReadDir(dir) require.NoError(t, err) all := make([]collected, 0, len(files)) for _, f := range files { require.False(t, f.IsDir()) v, err := goose.NumericComponent(f.Name()) require.NoError(t, err) all = append(all, collected{ fullpath: filepath.Base(f.Name()), version: v, }) } return all } func testDatabase(t *testing.T, dialect database.Dialect, db *sql.DB, migrationsDir string, opts ...goose.ProviderOption) { t.Helper() ctx := context.Background() // collect all migration files from the testdata directory wantFiles := collectMigrations(t, migrationsDir) // initialize a new goose provider p, err := goose.NewProvider(dialect, db, os.DirFS(migrationsDir), opts...) require.NoError(t, err) require.Equal(t, len(wantFiles), len(p.ListSources()), "number of migrations") // run all up migrations results, err := p.Up(ctx) require.NoError(t, err) require.Equal(t, len(wantFiles), len(results), "number of migrations applied") for i, r := range results { require.Equal(t, wantFiles[i].fullpath, r.Source.Path, "migration file") require.Equal(t, wantFiles[i].version, r.Source.Version, "migration version") } // check the current version currentVersion, err := p.GetDBVersion(ctx) require.NoError(t, err) require.Equal(t, len(wantFiles), int(currentVersion), "current version") // run all down migrations results, err = p.DownTo(ctx, 0) require.NoError(t, err) require.Equal(t, len(wantFiles), len(results), "number of migrations rolled back") // check the current version currentVersion, err = p.GetDBVersion(ctx) require.NoError(t, err) require.Equal(t, 0, int(currentVersion), "current version") // run all up migrations one by one for i := range len(wantFiles) { result, err := p.UpByOne(ctx) require.NoError(t, err) if errors.Is(err, goose.ErrNoNextVersion) { break } require.Equal(t, wantFiles[i].fullpath, result.Source.Path, "migration file") } // check the current version currentVersion, err = p.GetDBVersion(ctx) require.NoError(t, err) require.Equal(t, len(wantFiles), int(currentVersion), "current version") } ================================================ FILE: internal/testing/integration/locking/postgres_locking_test.go ================================================ package locking_test import ( "context" "database/sql" "errors" "fmt" "hash/crc32" "math/rand/v2" "os" "sort" "sync" "testing" "testing/fstest" "time" "github.com/pressly/goose/v3" "github.com/pressly/goose/v3/internal/testing/testdb" "github.com/pressly/goose/v3/lock" "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" ) func TestPostgresSessionLocker(t *testing.T) { t.Parallel() if testing.Short() { t.Skip("skipping test in short mode.") } db, cleanup, err := testdb.NewPostgres() require.NoError(t, err) t.Cleanup(cleanup) // Do not run subtests in parallel, because they are using the same database. t.Run("lock_and_unlock", func(t *testing.T) { const ( lockID int64 = 123456789 ) locker, err := lock.NewPostgresSessionLocker( lock.WithLockID(lockID), lock.WithLockTimeout(1, 4), // 4 second timeout lock.WithUnlockTimeout(1, 4), // 4 second timeout ) require.NoError(t, err) ctx := context.Background() conn, err := db.Conn(ctx) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, conn.Close()) }) err = locker.SessionLock(ctx, conn) require.NoError(t, err) // Check that the lock was acquired. exists, err := existsPgLock(ctx, db, lockID) require.NoError(t, err) require.True(t, exists) // Check that the lock is released. err = locker.SessionUnlock(ctx, conn) require.NoError(t, err) exists, err = existsPgLock(ctx, db, lockID) require.NoError(t, err) require.False(t, exists) }) t.Run("lock_close_conn_unlock", func(t *testing.T) { locker, err := lock.NewPostgresSessionLocker( lock.WithLockTimeout(1, 4), // 4 second timeout lock.WithUnlockTimeout(1, 4), // 4 second timeout ) require.NoError(t, err) ctx := context.Background() conn, err := db.Conn(ctx) require.NoError(t, err) err = locker.SessionLock(ctx, conn) require.NoError(t, err) exists, err := existsPgLock(ctx, db, lock.DefaultLockID) require.NoError(t, err) require.True(t, exists) // Simulate a connection close. err = conn.Close() require.NoError(t, err) // Check an error is returned when unlocking, because the connection is already closed. err = locker.SessionUnlock(ctx, conn) require.Error(t, err) require.True(t, errors.Is(err, sql.ErrConnDone)) }) t.Run("multiple_connections", func(t *testing.T) { const ( workers = 5 ) ch := make(chan error) var wg sync.WaitGroup for i := 0; i < workers; i++ { wg.Add(1) go func() { defer wg.Done() ctx := context.Background() conn, err := db.Conn(ctx) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, conn.Close()) }) // Exactly one connection should acquire the lock. While the other connections // should fail to acquire the lock and timeout. locker, err := lock.NewPostgresSessionLocker( lock.WithLockTimeout(1, 4), // 4 second timeout lock.WithUnlockTimeout(1, 4), // 4 second timeout ) require.NoError(t, err) // NOTE, we are not unlocking the lock, because we want to test that the lock is // released when the connection is closed. ch <- locker.SessionLock(ctx, conn) }() } go func() { wg.Wait() close(ch) }() var errors []error for err := range ch { if err != nil { errors = append(errors, err) } } require.Equal(t, len(errors), workers-1) // One worker succeeds, the rest fail. for _, err := range errors { require.Error(t, err) require.Equal(t, err.Error(), "failed to acquire lock") } exists, err := existsPgLock(context.Background(), db, lock.DefaultLockID) require.NoError(t, err) require.True(t, exists) }) t.Run("unlock_with_different_connection_error", func(t *testing.T) { randomLockID := rand.Int64() ctx := context.Background() locker, err := lock.NewPostgresSessionLocker( lock.WithLockID(randomLockID), lock.WithLockTimeout(1, 4), // 4 second timeout lock.WithUnlockTimeout(1, 4), // 4 second timeout ) require.NoError(t, err) conn1, err := db.Conn(ctx) require.NoError(t, err) err = locker.SessionLock(ctx, conn1) require.NoError(t, err) t.Cleanup(func() { // Defer the unlock with the same connection. err = locker.SessionUnlock(ctx, conn1) require.NoError(t, err) require.NoError(t, conn1.Close()) }) exists, err := existsPgLock(ctx, db, randomLockID) require.NoError(t, err) require.True(t, exists) // Unlock with a different connection. conn2, err := db.Conn(ctx) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, conn2.Close()) }) // Check an error is returned when unlocking with a different connection. err = locker.SessionUnlock(ctx, conn2) require.Error(t, err) }) } func TestPostgresProviderLocking(t *testing.T) { t.Parallel() if testing.Short() { t.Skip("skipping test in short mode.") } // The migrations are written in such a way they cannot be applied in parallel, they will fail // 99.9999% of the time. This test ensures that the advisory session lock mode works as // expected. // TODO(mf): small improvement here is to use the SAME postgres instance but different databases // created from a template. This will speed up the test. db, cleanup, err := testdb.NewPostgres() require.NoError(t, err) t.Cleanup(cleanup) newProvider := func() *goose.Provider { sessionLocker, err := lock.NewPostgresSessionLocker( lock.WithLockTimeout(5, 60), // Timeout 5min. Try every 5s up to 60 times. ) require.NoError(t, err) p, err := goose.NewProvider( goose.DialectPostgres, db, os.DirFS("../testdata/migrations/postgres"), goose.WithSessionLocker(sessionLocker), // Use advisory session lock mode. ) require.NoError(t, err) return p } provider1 := newProvider() provider2 := newProvider() sources := provider1.ListSources() maxVersion := sources[len(sources)-1].Version // Since the lock mode is advisory session, only one of these providers is expected to apply ALL // the migrations. The other provider should apply NO migrations. The test MUST fail if both // providers apply migrations. t.Run("up", func(t *testing.T) { var g errgroup.Group var res1, res2 int g.Go(func() error { ctx := context.Background() results, err := provider1.Up(ctx) require.NoError(t, err) res1 = len(results) currentVersion, err := provider1.GetDBVersion(ctx) require.NoError(t, err) require.Equal(t, currentVersion, maxVersion) return nil }) g.Go(func() error { ctx := context.Background() results, err := provider2.Up(ctx) require.NoError(t, err) res2 = len(results) currentVersion, err := provider2.GetDBVersion(ctx) require.NoError(t, err) require.Equal(t, currentVersion, maxVersion) return nil }) require.NoError(t, g.Wait()) // One of the providers should have applied all migrations and the other should have applied // no migrations, but with no error. if res1 == 0 && res2 == 0 { t.Fatal("both providers applied no migrations") } if res1 > 0 && res2 > 0 { t.Fatal("both providers applied migrations") } }) // Reset the database and run the same test with the advisory lock mode, but apply migrations // one-by-one. { _, err := provider1.DownTo(context.Background(), 0) require.NoError(t, err) currentVersion, err := provider1.GetDBVersion(context.Background()) require.NoError(t, err) require.Equal(t, currentVersion, int64(0)) } t.Run("up_by_one", func(t *testing.T) { var g errgroup.Group var ( mu sync.Mutex applied []int64 ) g.Go(func() error { for { result, err := provider1.UpByOne(context.Background()) if err != nil { if errors.Is(err, goose.ErrNoNextVersion) { return nil } return err } require.NoError(t, err) require.NotNil(t, result) mu.Lock() applied = append(applied, result.Source.Version) mu.Unlock() } }) g.Go(func() error { for { result, err := provider2.UpByOne(context.Background()) if err != nil { if errors.Is(err, goose.ErrNoNextVersion) { return nil } return err } require.NoError(t, err) require.NotNil(t, result) mu.Lock() applied = append(applied, result.Source.Version) mu.Unlock() } }) require.NoError(t, g.Wait()) require.Equal(t, len(applied), len(sources)) sort.Slice(applied, func(i, j int) bool { return applied[i] < applied[j] }) // Each migration should have been applied up exactly once. for i := 0; i < len(sources); i++ { require.Equal(t, applied[i], sources[i].Version) } }) // Restore the database state by applying all migrations and run the same test with the advisory // lock mode, but apply down migrations in parallel. { _, err := provider1.Up(context.Background()) require.NoError(t, err) currentVersion, err := provider1.GetDBVersion(context.Background()) require.NoError(t, err) require.Equal(t, currentVersion, maxVersion) } t.Run("down_to", func(t *testing.T) { var g errgroup.Group var res1, res2 int g.Go(func() error { ctx := context.Background() results, err := provider1.DownTo(ctx, 0) require.NoError(t, err) res1 = len(results) currentVersion, err := provider1.GetDBVersion(ctx) require.NoError(t, err) require.Equal(t, int64(0), currentVersion) return nil }) g.Go(func() error { ctx := context.Background() results, err := provider2.DownTo(ctx, 0) require.NoError(t, err) res2 = len(results) currentVersion, err := provider2.GetDBVersion(ctx) require.NoError(t, err) require.Equal(t, int64(0), currentVersion) return nil }) require.NoError(t, g.Wait()) if res1 == 0 && res2 == 0 { t.Fatal("both providers applied no migrations") } if res1 > 0 && res2 > 0 { t.Fatal("both providers applied migrations") } }) // Restore the database state by applying all migrations and run the same test with the advisory // lock mode, but apply down migrations one-by-one. { _, err := provider1.Up(context.Background()) require.NoError(t, err) currentVersion, err := provider1.GetDBVersion(context.Background()) require.NoError(t, err) require.Equal(t, currentVersion, maxVersion) } t.Run("down_by_one", func(t *testing.T) { var g errgroup.Group var ( mu sync.Mutex applied []int64 ) g.Go(func() error { for { result, err := provider1.Down(context.Background()) if err != nil { if errors.Is(err, goose.ErrNoNextVersion) { return nil } return err } require.NoError(t, err) require.NotNil(t, result) mu.Lock() applied = append(applied, result.Source.Version) mu.Unlock() } }) g.Go(func() error { for { result, err := provider2.Down(context.Background()) if err != nil { if errors.Is(err, goose.ErrNoNextVersion) { return nil } return err } require.NoError(t, err) require.NotNil(t, result) mu.Lock() applied = append(applied, result.Source.Version) mu.Unlock() } }) require.NoError(t, g.Wait()) require.Equal(t, len(applied), len(sources)) sort.Slice(applied, func(i, j int) bool { return applied[i] < applied[j] }) // Each migration should have been applied down exactly once. Since this is sequential the // applied down migrations should be in reverse order. for i := len(sources) - 1; i >= 0; i-- { require.Equal(t, applied[i], sources[i].Version) } }) } func TestPostgresPending(t *testing.T) { t.Parallel() if testing.Short() { t.Skip("skipping test in short mode.") } const testDir = "../testdata/migrations/postgres" db, cleanup, err := testdb.NewPostgres() require.NoError(t, err) t.Cleanup(cleanup) files, err := os.ReadDir(testDir) require.NoError(t, err) workers := 15 run := func(t *testing.T, want bool, wantCurrent, wantTarget int) { t.Helper() var g errgroup.Group boolCh := make(chan bool, workers) for i := 0; i < workers; i++ { g.Go(func() error { p, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS(testDir)) require.NoError(t, err) hasPending, err := p.HasPending(context.Background()) require.NoError(t, err) boolCh <- hasPending current, target, err := p.GetVersions(context.Background()) require.NoError(t, err) require.Equal(t, current, int64(wantCurrent)) require.Equal(t, target, int64(wantTarget)) return nil }) } require.NoError(t, g.Wait()) close(boolCh) // expect all values to be true for hasPending := range boolCh { require.Equal(t, hasPending, want) } } t.Run("concurrent_has_pending", func(t *testing.T) { run(t, true, 0, len(files)) }) // apply all migrations p, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../testdata/migrations/postgres")) require.NoError(t, err) _, err = p.Up(context.Background()) require.NoError(t, err) t.Run("concurrent_no_pending", func(t *testing.T) { run(t, false, len(files), len(files)) }) // Add a new migration file lastVersion := len(files) newVersion := fmt.Sprintf("%d_new_migration.sql", lastVersion+1) fsys := fstest.MapFS{ newVersion: &fstest.MapFile{Data: []byte(` -- +goose Up SELECT pg_sleep_for('4 seconds'); `)}, } lockID := int64(crc32.Checksum([]byte(t.Name()), crc32.MakeTable(crc32.IEEE))) // Create a new provider with the new migration file sessionLocker, err := lock.NewPostgresSessionLocker(lock.WithLockTimeout(1, 10), lock.WithLockID(lockID)) // Timeout 5min. Try every 1s up to 10 times. require.NoError(t, err) newProvider, err := goose.NewProvider(goose.DialectPostgres, db, fsys, goose.WithSessionLocker(sessionLocker)) require.NoError(t, err) require.Equal(t, len(newProvider.ListSources()), 1) oldProvider := p require.Equal(t, len(oldProvider.ListSources()), len(files)) var g errgroup.Group g.Go(func() error { hasPending, err := newProvider.HasPending(context.Background()) if err != nil { return err } require.True(t, hasPending) current, target, err := newProvider.GetVersions(context.Background()) if err != nil { return err } require.EqualValues(t, current, lastVersion) require.EqualValues(t, target, lastVersion+1) return nil }) g.Go(func() error { hasPending, err := oldProvider.HasPending(context.Background()) if err != nil { return err } require.False(t, hasPending) current, target, err := oldProvider.GetVersions(context.Background()) if err != nil { return err } require.EqualValues(t, current, lastVersion) require.EqualValues(t, target, lastVersion) return nil }) require.NoError(t, g.Wait()) // A new provider is running in the background with a session lock to simulate a long running // migration. If older instances come up, they should not have any pending migrations and not be // affected by the long running migration. Test the following scenario: // https://github.com/pressly/goose/pull/507#discussion_r1266498077 g.Go(func() error { _, err := newProvider.Up(context.Background()) return err }) time.Sleep(1 * time.Second) isLocked, err := existsPgLock(context.Background(), db, lockID) require.NoError(t, err) require.True(t, isLocked) hasPending, err := oldProvider.HasPending(context.Background()) require.NoError(t, err) require.False(t, hasPending) current, target, err := oldProvider.GetVersions(context.Background()) require.NoError(t, err) require.EqualValues(t, current, lastVersion) require.EqualValues(t, target, lastVersion) // Wait for the long running migration to finish require.NoError(t, g.Wait()) // Check that the new migration was applied hasPending, err = newProvider.HasPending(context.Background()) require.NoError(t, err) require.False(t, hasPending) current, target, err = newProvider.GetVersions(context.Background()) require.NoError(t, err) require.EqualValues(t, current, lastVersion+1) require.EqualValues(t, target, lastVersion+1) // The max version should be the new migration currentVersion, err := newProvider.GetDBVersion(context.Background()) require.NoError(t, err) require.EqualValues(t, currentVersion, lastVersion+1) } func existsPgLock(ctx context.Context, db *sql.DB, lockID int64) (bool, error) { q := `SELECT EXISTS(SELECT 1 FROM pg_locks WHERE locktype='advisory' AND ((classid::bigint<<32)|objid::bigint)=$1)` row := db.QueryRowContext(ctx, q, lockID) var exists bool if err := row.Scan(&exists); err != nil { return false, err } return exists, nil } ================================================ FILE: internal/testing/integration/locking/postgres_table_locking_test.go ================================================ package locking_test import ( "context" "log/slog" "math/rand/v2" "strings" "testing" "time" "github.com/pressly/goose/v3" "github.com/pressly/goose/v3/internal/testing/testdb" "github.com/pressly/goose/v3/lock" "github.com/pressly/goose/v3/lock/locktesting" "github.com/pressly/goose/v3/testdata" "github.com/stretchr/testify/require" ) func TestConcurrentTableLocking(t *testing.T) { t.Parallel() if testing.Short() { t.Skip("skipping test in short mode") } db, cleanup, err := testdb.NewPostgres() require.NoError(t, err) t.Cleanup(cleanup) // All lockers must compete for the SAME lock ID lockID := rand.Int64() newLocker := func(t *testing.T) lock.Locker { locker, err := lock.NewPostgresTableLocker( lock.WithTableLockID(lockID), // Same lock ID for all lockers!! lock.WithTableHeartbeatInterval(200*time.Millisecond), // This value is important - it controls how long a locker will keep retrying to acquire // the lock and must be shorter than the overall lock timeout below. lock.WithTableLockTimeout(50*time.Millisecond, 2), // 200ms total wait time ) require.NoError(t, err) return locker } locktesting.TestConcurrentLocking(t, db, newLocker, 1*time.Second) } func TestSequentialTableLocking(t *testing.T) { t.Parallel() if testing.Short() { t.Skip("skipping test in short mode") } db, cleanup, err := testdb.NewPostgres() require.NoError(t, err) t.Cleanup(cleanup) lockID := rand.Int64() // Create two lockers - first has long lease, second has short retry timeout locker1, err := lock.NewPostgresTableLocker( lock.WithTableLockID(lockID), lock.WithTableLeaseDuration(2*time.Second), // Long lease to ensure it doesn't expire lock.WithTableHeartbeatInterval(200*time.Millisecond), ) require.NoError(t, err) locker2, err := lock.NewPostgresTableLocker( lock.WithTableLockID(lockID), lock.WithTableLeaseDuration(2*time.Second), lock.WithTableHeartbeatInterval(200*time.Millisecond), lock.WithTableLockTimeout(50*time.Millisecond, 4), // Only 200ms total timeout ) require.NoError(t, err) ctx := context.Background() // First locker acquires the lock err = locker1.Lock(ctx, db) require.NoError(t, err) t.Log("Locker 1 acquired lock") // Second locker should fail to acquire the lock (will timeout after 200ms of retries) ctx2, cancel := context.WithTimeout(ctx, 400*time.Millisecond) defer cancel() err = locker2.Lock(ctx2, db) require.Error(t, err) t.Log("Locker 2 correctly failed to acquire lock") // First locker releases the lock err = locker1.Unlock(ctx, db) require.NoError(t, err) t.Log("Locker 1 released lock") // Now second locker should be able to acquire the lock err = locker2.Lock(ctx, db) require.NoError(t, err) t.Log("Locker 2 acquired lock after locker 1 released") // Clean up err = locker2.Unlock(ctx, db) require.NoError(t, err) t.Log("Locker 2 released lock") } func TestLockerImplementations(t *testing.T) { t.Parallel() if testing.Short() { t.Skip("skipping integration test in short mode") } t.Run("postgres_table_locker_unique", func(t *testing.T) { t.Parallel() db, cleanup, err := testdb.NewPostgres() require.NoError(t, err) t.Cleanup(cleanup) // Use the same lock ID for all providers so they compete for the same table row sharedLockID := rand.Int64() locktesting.TestProviderLocking(t, func(t *testing.T) *goose.Provider { t.Helper() // Create a UNIQUE table-based locker instance per provider, but same lock ID locker, err := lock.NewPostgresTableLocker( lock.WithTableLockID(sharedLockID), lock.WithTableLockTimeout(200*time.Millisecond, 25), // 25 retries, 5s total ) require.NoError(t, err) p, err := goose.NewProvider( goose.DialectPostgres, db, testdata.MustMigrationsFS(), goose.WithLocker(locker), ) require.NoError(t, err) return p }) }) t.Run("postgres_table_locker_shared", func(t *testing.T) { t.Parallel() db, cleanup, err := testdb.NewPostgres() require.NoError(t, err) t.Cleanup(cleanup) // Create a SHARED table-based locker per provider locker, err := lock.NewPostgresTableLocker( lock.WithTableLockID(rand.Int64()), lock.WithTableLockTimeout(200*time.Millisecond, 25), // 25 retries, 5s total ) require.NoError(t, err) locktesting.TestProviderLocking(t, func(t *testing.T) *goose.Provider { t.Helper() p, err := goose.NewProvider( goose.DialectPostgres, db, testdata.MustMigrationsFS(), goose.WithLocker(locker), ) require.NoError(t, err) return p }) }) t.Run("postgres_session_locker_unique", func(t *testing.T) { t.Parallel() db, cleanup, err := testdb.NewPostgres() require.NoError(t, err) t.Cleanup(cleanup) // Use the same lock ID for all providers so they compete for the same advisory lock sharedLockID := rand.Int64() locktesting.TestProviderLocking(t, func(t *testing.T) *goose.Provider { t.Helper() // Each provider gets a UNIQUE session locker instance, but same lock ID // This simulates multiple pods with separate locker instances competing for same advisory lock sessionLocker, err := lock.NewPostgresSessionLocker( lock.WithLockID(sharedLockID), // Same lock ID for all providers lock.WithLockTimeout(1, 10), // 10 retries, 10s total ) require.NoError(t, err) p, err := goose.NewProvider( goose.DialectPostgres, db, testdata.MustMigrationsFS(), goose.WithSessionLocker(sessionLocker), ) require.NoError(t, err) return p }) }) t.Run("postgres_session_locker_shared", func(t *testing.T) { t.Parallel() db, cleanup, err := testdb.NewPostgres() require.NoError(t, err) t.Cleanup(cleanup) // Create a shared session locker (advisory lock) for all providers sessionLocker, err := lock.NewPostgresSessionLocker( lock.WithLockID(rand.Int64()), lock.WithLockTimeout(1, 10), // 10 retries, 10s total ) require.NoError(t, err) locktesting.TestProviderLocking(t, func(t *testing.T) *goose.Provider { t.Helper() p, err := goose.NewProvider( goose.DialectPostgres, db, testdata.MustMigrationsFS(), goose.WithSessionLocker(sessionLocker), ) require.NoError(t, err) return p }) }) } func TestPostgresTableLockerIntegration(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } db, cleanup, err := testdb.NewPostgres() require.NoError(t, err) t.Cleanup(cleanup) t.Run("basic_lock_unlock", func(t *testing.T) { t.Parallel() // Create a table locker with very short timeouts for testing locker, err := lock.NewPostgresTableLocker( lock.WithTableName("test_locks"), lock.WithTableLockID(rand.Int64()), lock.WithTableLeaseDuration(5*time.Second), lock.WithTableHeartbeatInterval(1*time.Second), lock.WithTableLockTimeout(100*time.Millisecond, 2), // Very short timeout ) require.NoError(t, err) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() err = locker.Lock(ctx, db) require.NoError(t, err) err = locker.Unlock(ctx, db) require.NoError(t, err) }) t.Run("cleanup_stale_locks", func(t *testing.T) { t.Parallel() lockID := rand.Int64() // Create a locker with very short lease to test cleanup functionality locker, err := lock.NewPostgresTableLocker( lock.WithTableLockID(lockID), lock.WithTableLeaseDuration(100*time.Millisecond), // Very short lease lock.WithTableHeartbeatInterval(50*time.Millisecond), ) require.NoError(t, err) ctx := context.Background() // Acquire the lock err = locker.Lock(ctx, db) require.NoError(t, err) // Let the lease expire by waiting longer than lease duration time.Sleep(200 * time.Millisecond) // Create a second locker that should be able to acquire the lock // because the first one's lease has expired locker2, err := lock.NewPostgresTableLocker( lock.WithTableLockID(lockID), // Same lock ID lock.WithTableLeaseDuration(5*time.Second), lock.WithTableHeartbeatInterval(1*time.Second), ) require.NoError(t, err) // This should succeed because cleanup of stale locks allows it ctx2, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() err = locker2.Lock(ctx2, db) require.NoError(t, err) // Clean up err = locker2.Unlock(ctx, db) require.NoError(t, err) }) t.Run("with_logger", func(t *testing.T) { t.Parallel() var logOutput strings.Builder logger := slog.New(slog.NewTextHandler(&logOutput, &slog.HandlerOptions{ Level: slog.LevelDebug, })) // Create a table locker with logging enabled locker, err := lock.NewPostgresTableLocker( lock.WithTableName("test_locks_with_logger"), lock.WithTableLockID(rand.Int64()), lock.WithTableLeaseDuration(2*time.Second), lock.WithTableHeartbeatInterval(500*time.Millisecond), lock.WithTableLogger(logger), ) require.NoError(t, err) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // Test that lock operations generate log messages err = locker.Lock(ctx, db) require.NoError(t, err) // Wait a moment for heartbeat time.Sleep(1 * time.Second) err = locker.Unlock(ctx, db) require.NoError(t, err) // Check that we got some log output logs := logOutput.String() require.Contains(t, logs, "successfully acquired lock") require.Contains(t, logs, "successfully released lock") require.Contains(t, logs, "heartbeat updated lease") }) } ================================================ FILE: internal/testing/integration/testdata/migrations/clickhouse/00001_a.sql ================================================ -- +goose Up CREATE TABLE IF NOT EXISTS trips ( `trip_id` UInt32, `vendor_id` Enum8('1' = 1, '2' = 2, '3' = 3, '4' = 4, 'CMT' = 5, 'VTS' = 6, 'DDS' = 7, 'B02512' = 10, 'B02598' = 11, 'B02617' = 12, 'B02682' = 13, 'B02764' = 14, '' = 15), `pickup_date` Date, `pickup_datetime` DateTime, `dropoff_date` Date, `dropoff_datetime` DateTime, `store_and_fwd_flag` UInt8, `rate_code_id` UInt8, `pickup_longitude` Float64, `pickup_latitude` Float64, `dropoff_longitude` Float64, `dropoff_latitude` Float64, `passenger_count` UInt8, `trip_distance` Float64, `fare_amount` Float32, `extra` Float32, `mta_tax` Float32, `tip_amount` Float32, `tolls_amount` Float32, `ehail_fee` Float32, `improvement_surcharge` Float32, `total_amount` Float32, `payment_type` Enum8('UNK' = 0, 'CSH' = 1, 'CRE' = 2, 'NOC' = 3, 'DIS' = 4), `trip_type` UInt8, `pickup` FixedString(25), `dropoff` FixedString(25), `cab_type` Enum8('yellow' = 1, 'green' = 2, 'uber' = 3), `pickup_nyct2010_gid` Int8, `pickup_ctlabel` Float32, `pickup_borocode` Int8, `pickup_ct2010` String, `pickup_boroct2010` FixedString(7), `pickup_cdeligibil` String, `pickup_ntacode` FixedString(4), `pickup_ntaname` String, `pickup_puma` UInt16, `dropoff_nyct2010_gid` UInt8, `dropoff_ctlabel` Float32, `dropoff_borocode` UInt8, `dropoff_ct2010` String, `dropoff_boroct2010` FixedString(7), `dropoff_cdeligibil` String, `dropoff_ntacode` FixedString(4), `dropoff_ntaname` String, `dropoff_puma` UInt16 ) ENGINE = MergeTree PARTITION BY toYYYYMM(pickup_date) ORDER BY pickup_datetime; -- +goose Down DROP TABLE IF EXISTS trips; ================================================ FILE: internal/testing/integration/testdata/migrations/clickhouse/00002_b.sql ================================================ -- +goose Up CREATE TABLE IF NOT EXISTS clickstream ( customer_id String, time_stamp Date, click_event_type String, country_code FixedString(2), source_id UInt64 ) ENGINE = MergeTree() ORDER BY (time_stamp); -- +goose Down DROP TABLE IF EXISTS clickstream; ================================================ FILE: internal/testing/integration/testdata/migrations/clickhouse/00003_c.sql ================================================ -- +goose Up INSERT INTO clickstream VALUES ('customer1', '2021-10-02', 'add_to_cart', 'US', 568239 ); INSERT INTO clickstream (customer_id, time_stamp, click_event_type) VALUES ('customer2', '2021-10-30', 'remove_from_cart' ); INSERT INTO clickstream (* EXCEPT(country_code)) VALUES ('customer3', '2021-11-07', 'checkout', 307493 ); -- +goose Down ================================================ FILE: internal/testing/integration/testdata/migrations/clickhouse-remote/00001_a.sql ================================================ -- +goose Up CREATE DICTIONARY taxi_zone_dictionary ( LocationID UInt16 DEFAULT 0, Borough String, Zone String, service_zone String ) PRIMARY KEY LocationID SOURCE(HTTP( url 'https://datasets-documentation.s3.eu-west-3.amazonaws.com/nyc-taxi/taxi_zone_lookup.csv' format 'CSVWithNames' )) LIFETIME(0) LAYOUT(HASHED()); -- +goose Down DROP DICTIONARY IF EXISTS taxi_zone_dictionary; ================================================ FILE: internal/testing/integration/testdata/migrations/clickhouse-remote-backup/taxi_zone_lookup.csv ================================================ "LocationID","Borough","Zone","service_zone" 1,"EWR","Newark Airport","EWR" 2,"Queens","Jamaica Bay","Boro Zone" 3,"Bronx","Allerton/Pelham Gardens","Boro Zone" 4,"Manhattan","Alphabet City","Yellow Zone" 5,"Staten Island","Arden Heights","Boro Zone" 6,"Staten Island","Arrochar/Fort Wadsworth","Boro Zone" 7,"Queens","Astoria","Boro Zone" 8,"Queens","Astoria Park","Boro Zone" 9,"Queens","Auburndale","Boro Zone" 10,"Queens","Baisley Park","Boro Zone" 11,"Brooklyn","Bath Beach","Boro Zone" 12,"Manhattan","Battery Park","Yellow Zone" 13,"Manhattan","Battery Park City","Yellow Zone" 14,"Brooklyn","Bay Ridge","Boro Zone" 15,"Queens","Bay Terrace/Fort Totten","Boro Zone" 16,"Queens","Bayside","Boro Zone" 17,"Brooklyn","Bedford","Boro Zone" 18,"Bronx","Bedford Park","Boro Zone" 19,"Queens","Bellerose","Boro Zone" 20,"Bronx","Belmont","Boro Zone" 21,"Brooklyn","Bensonhurst East","Boro Zone" 22,"Brooklyn","Bensonhurst West","Boro Zone" 23,"Staten Island","Bloomfield/Emerson Hill","Boro Zone" 24,"Manhattan","Bloomingdale","Yellow Zone" 25,"Brooklyn","Boerum Hill","Boro Zone" 26,"Brooklyn","Borough Park","Boro Zone" 27,"Queens","Breezy Point/Fort Tilden/Riis Beach","Boro Zone" 28,"Queens","Briarwood/Jamaica Hills","Boro Zone" 29,"Brooklyn","Brighton Beach","Boro Zone" 30,"Queens","Broad Channel","Boro Zone" 31,"Bronx","Bronx Park","Boro Zone" 32,"Bronx","Bronxdale","Boro Zone" 33,"Brooklyn","Brooklyn Heights","Boro Zone" 34,"Brooklyn","Brooklyn Navy Yard","Boro Zone" 35,"Brooklyn","Brownsville","Boro Zone" 36,"Brooklyn","Bushwick North","Boro Zone" 37,"Brooklyn","Bushwick South","Boro Zone" 38,"Queens","Cambria Heights","Boro Zone" 39,"Brooklyn","Canarsie","Boro Zone" 40,"Brooklyn","Carroll Gardens","Boro Zone" 41,"Manhattan","Central Harlem","Boro Zone" 42,"Manhattan","Central Harlem North","Boro Zone" 43,"Manhattan","Central Park","Yellow Zone" 44,"Staten Island","Charleston/Tottenville","Boro Zone" 45,"Manhattan","Chinatown","Yellow Zone" 46,"Bronx","City Island","Boro Zone" 47,"Bronx","Claremont/Bathgate","Boro Zone" 48,"Manhattan","Clinton East","Yellow Zone" 49,"Brooklyn","Clinton Hill","Boro Zone" 50,"Manhattan","Clinton West","Yellow Zone" 51,"Bronx","Co-Op City","Boro Zone" 52,"Brooklyn","Cobble Hill","Boro Zone" 53,"Queens","College Point","Boro Zone" 54,"Brooklyn","Columbia Street","Boro Zone" 55,"Brooklyn","Coney Island","Boro Zone" 56,"Queens","Corona","Boro Zone" 57,"Queens","Corona","Boro Zone" 58,"Bronx","Country Club","Boro Zone" 59,"Bronx","Crotona Park","Boro Zone" 60,"Bronx","Crotona Park East","Boro Zone" 61,"Brooklyn","Crown Heights North","Boro Zone" 62,"Brooklyn","Crown Heights South","Boro Zone" 63,"Brooklyn","Cypress Hills","Boro Zone" 64,"Queens","Douglaston","Boro Zone" 65,"Brooklyn","Downtown Brooklyn/MetroTech","Boro Zone" 66,"Brooklyn","DUMBO/Vinegar Hill","Boro Zone" 67,"Brooklyn","Dyker Heights","Boro Zone" 68,"Manhattan","East Chelsea","Yellow Zone" 69,"Bronx","East Concourse/Concourse Village","Boro Zone" 70,"Queens","East Elmhurst","Boro Zone" 71,"Brooklyn","East Flatbush/Farragut","Boro Zone" 72,"Brooklyn","East Flatbush/Remsen Village","Boro Zone" 73,"Queens","East Flushing","Boro Zone" 74,"Manhattan","East Harlem North","Boro Zone" 75,"Manhattan","East Harlem South","Boro Zone" 76,"Brooklyn","East New York","Boro Zone" 77,"Brooklyn","East New York/Pennsylvania Avenue","Boro Zone" 78,"Bronx","East Tremont","Boro Zone" 79,"Manhattan","East Village","Yellow Zone" 80,"Brooklyn","East Williamsburg","Boro Zone" 81,"Bronx","Eastchester","Boro Zone" 82,"Queens","Elmhurst","Boro Zone" 83,"Queens","Elmhurst/Maspeth","Boro Zone" 84,"Staten Island","Eltingville/Annadale/Prince's Bay","Boro Zone" 85,"Brooklyn","Erasmus","Boro Zone" 86,"Queens","Far Rockaway","Boro Zone" 87,"Manhattan","Financial District North","Yellow Zone" 88,"Manhattan","Financial District South","Yellow Zone" 89,"Brooklyn","Flatbush/Ditmas Park","Boro Zone" 90,"Manhattan","Flatiron","Yellow Zone" 91,"Brooklyn","Flatlands","Boro Zone" 92,"Queens","Flushing","Boro Zone" 93,"Queens","Flushing Meadows-Corona Park","Boro Zone" 94,"Bronx","Fordham South","Boro Zone" 95,"Queens","Forest Hills","Boro Zone" 96,"Queens","Forest Park/Highland Park","Boro Zone" 97,"Brooklyn","Fort Greene","Boro Zone" 98,"Queens","Fresh Meadows","Boro Zone" 99,"Staten Island","Freshkills Park","Boro Zone" 100,"Manhattan","Garment District","Yellow Zone" 101,"Queens","Glen Oaks","Boro Zone" 102,"Queens","Glendale","Boro Zone" 103,"Manhattan","Governor's Island/Ellis Island/Liberty Island","Yellow Zone" 104,"Manhattan","Governor's Island/Ellis Island/Liberty Island","Yellow Zone" 105,"Manhattan","Governor's Island/Ellis Island/Liberty Island","Yellow Zone" 106,"Brooklyn","Gowanus","Boro Zone" 107,"Manhattan","Gramercy","Yellow Zone" 108,"Brooklyn","Gravesend","Boro Zone" 109,"Staten Island","Great Kills","Boro Zone" 110,"Staten Island","Great Kills Park","Boro Zone" 111,"Brooklyn","Green-Wood Cemetery","Boro Zone" 112,"Brooklyn","Greenpoint","Boro Zone" 113,"Manhattan","Greenwich Village North","Yellow Zone" 114,"Manhattan","Greenwich Village South","Yellow Zone" 115,"Staten Island","Grymes Hill/Clifton","Boro Zone" 116,"Manhattan","Hamilton Heights","Boro Zone" 117,"Queens","Hammels/Arverne","Boro Zone" 118,"Staten Island","Heartland Village/Todt Hill","Boro Zone" 119,"Bronx","Highbridge","Boro Zone" 120,"Manhattan","Highbridge Park","Boro Zone" 121,"Queens","Hillcrest/Pomonok","Boro Zone" 122,"Queens","Hollis","Boro Zone" 123,"Brooklyn","Homecrest","Boro Zone" 124,"Queens","Howard Beach","Boro Zone" 125,"Manhattan","Hudson Sq","Yellow Zone" 126,"Bronx","Hunts Point","Boro Zone" 127,"Manhattan","Inwood","Boro Zone" 128,"Manhattan","Inwood Hill Park","Boro Zone" 129,"Queens","Jackson Heights","Boro Zone" 130,"Queens","Jamaica","Boro Zone" 131,"Queens","Jamaica Estates","Boro Zone" 132,"Queens","JFK Airport","Airports" 133,"Brooklyn","Kensington","Boro Zone" 134,"Queens","Kew Gardens","Boro Zone" 135,"Queens","Kew Gardens Hills","Boro Zone" 136,"Bronx","Kingsbridge Heights","Boro Zone" 137,"Manhattan","Kips Bay","Yellow Zone" 138,"Queens","LaGuardia Airport","Airports" 139,"Queens","Laurelton","Boro Zone" 140,"Manhattan","Lenox Hill East","Yellow Zone" 141,"Manhattan","Lenox Hill West","Yellow Zone" 142,"Manhattan","Lincoln Square East","Yellow Zone" 143,"Manhattan","Lincoln Square West","Yellow Zone" 144,"Manhattan","Little Italy/NoLiTa","Yellow Zone" 145,"Queens","Long Island City/Hunters Point","Boro Zone" 146,"Queens","Long Island City/Queens Plaza","Boro Zone" 147,"Bronx","Longwood","Boro Zone" 148,"Manhattan","Lower East Side","Yellow Zone" 149,"Brooklyn","Madison","Boro Zone" 150,"Brooklyn","Manhattan Beach","Boro Zone" 151,"Manhattan","Manhattan Valley","Yellow Zone" 152,"Manhattan","Manhattanville","Boro Zone" 153,"Manhattan","Marble Hill","Boro Zone" 154,"Brooklyn","Marine Park/Floyd Bennett Field","Boro Zone" 155,"Brooklyn","Marine Park/Mill Basin","Boro Zone" 156,"Staten Island","Mariners Harbor","Boro Zone" 157,"Queens","Maspeth","Boro Zone" 158,"Manhattan","Meatpacking/West Village West","Yellow Zone" 159,"Bronx","Melrose South","Boro Zone" 160,"Queens","Middle Village","Boro Zone" 161,"Manhattan","Midtown Center","Yellow Zone" 162,"Manhattan","Midtown East","Yellow Zone" 163,"Manhattan","Midtown North","Yellow Zone" 164,"Manhattan","Midtown South","Yellow Zone" 165,"Brooklyn","Midwood","Boro Zone" 166,"Manhattan","Morningside Heights","Boro Zone" 167,"Bronx","Morrisania/Melrose","Boro Zone" 168,"Bronx","Mott Haven/Port Morris","Boro Zone" 169,"Bronx","Mount Hope","Boro Zone" 170,"Manhattan","Murray Hill","Yellow Zone" 171,"Queens","Murray Hill-Queens","Boro Zone" 172,"Staten Island","New Dorp/Midland Beach","Boro Zone" 173,"Queens","North Corona","Boro Zone" 174,"Bronx","Norwood","Boro Zone" 175,"Queens","Oakland Gardens","Boro Zone" 176,"Staten Island","Oakwood","Boro Zone" 177,"Brooklyn","Ocean Hill","Boro Zone" 178,"Brooklyn","Ocean Parkway South","Boro Zone" 179,"Queens","Old Astoria","Boro Zone" 180,"Queens","Ozone Park","Boro Zone" 181,"Brooklyn","Park Slope","Boro Zone" 182,"Bronx","Parkchester","Boro Zone" 183,"Bronx","Pelham Bay","Boro Zone" 184,"Bronx","Pelham Bay Park","Boro Zone" 185,"Bronx","Pelham Parkway","Boro Zone" 186,"Manhattan","Penn Station/Madison Sq West","Yellow Zone" 187,"Staten Island","Port Richmond","Boro Zone" 188,"Brooklyn","Prospect-Lefferts Gardens","Boro Zone" 189,"Brooklyn","Prospect Heights","Boro Zone" 190,"Brooklyn","Prospect Park","Boro Zone" 191,"Queens","Queens Village","Boro Zone" 192,"Queens","Queensboro Hill","Boro Zone" 193,"Queens","Queensbridge/Ravenswood","Boro Zone" 194,"Manhattan","Randalls Island","Yellow Zone" 195,"Brooklyn","Red Hook","Boro Zone" 196,"Queens","Rego Park","Boro Zone" 197,"Queens","Richmond Hill","Boro Zone" 198,"Queens","Ridgewood","Boro Zone" 199,"Bronx","Rikers Island","Boro Zone" 200,"Bronx","Riverdale/North Riverdale/Fieldston","Boro Zone" 201,"Queens","Rockaway Park","Boro Zone" 202,"Manhattan","Roosevelt Island","Boro Zone" 203,"Queens","Rosedale","Boro Zone" 204,"Staten Island","Rossville/Woodrow","Boro Zone" 205,"Queens","Saint Albans","Boro Zone" 206,"Staten Island","Saint George/New Brighton","Boro Zone" 207,"Queens","Saint Michaels Cemetery/Woodside","Boro Zone" 208,"Bronx","Schuylerville/Edgewater Park","Boro Zone" 209,"Manhattan","Seaport","Yellow Zone" 210,"Brooklyn","Sheepshead Bay","Boro Zone" 211,"Manhattan","SoHo","Yellow Zone" 212,"Bronx","Soundview/Bruckner","Boro Zone" 213,"Bronx","Soundview/Castle Hill","Boro Zone" 214,"Staten Island","South Beach/Dongan Hills","Boro Zone" 215,"Queens","South Jamaica","Boro Zone" 216,"Queens","South Ozone Park","Boro Zone" 217,"Brooklyn","South Williamsburg","Boro Zone" 218,"Queens","Springfield Gardens North","Boro Zone" 219,"Queens","Springfield Gardens South","Boro Zone" 220,"Bronx","Spuyten Duyvil/Kingsbridge","Boro Zone" 221,"Staten Island","Stapleton","Boro Zone" 222,"Brooklyn","Starrett City","Boro Zone" 223,"Queens","Steinway","Boro Zone" 224,"Manhattan","Stuy Town/Peter Cooper Village","Yellow Zone" 225,"Brooklyn","Stuyvesant Heights","Boro Zone" 226,"Queens","Sunnyside","Boro Zone" 227,"Brooklyn","Sunset Park East","Boro Zone" 228,"Brooklyn","Sunset Park West","Boro Zone" 229,"Manhattan","Sutton Place/Turtle Bay North","Yellow Zone" 230,"Manhattan","Times Sq/Theatre District","Yellow Zone" 231,"Manhattan","TriBeCa/Civic Center","Yellow Zone" 232,"Manhattan","Two Bridges/Seward Park","Yellow Zone" 233,"Manhattan","UN/Turtle Bay South","Yellow Zone" 234,"Manhattan","Union Sq","Yellow Zone" 235,"Bronx","University Heights/Morris Heights","Boro Zone" 236,"Manhattan","Upper East Side North","Yellow Zone" 237,"Manhattan","Upper East Side South","Yellow Zone" 238,"Manhattan","Upper West Side North","Yellow Zone" 239,"Manhattan","Upper West Side South","Yellow Zone" 240,"Bronx","Van Cortlandt Park","Boro Zone" 241,"Bronx","Van Cortlandt Village","Boro Zone" 242,"Bronx","Van Nest/Morris Park","Boro Zone" 243,"Manhattan","Washington Heights North","Boro Zone" 244,"Manhattan","Washington Heights South","Boro Zone" 245,"Staten Island","West Brighton","Boro Zone" 246,"Manhattan","West Chelsea/Hudson Yards","Yellow Zone" 247,"Bronx","West Concourse","Boro Zone" 248,"Bronx","West Farms/Bronx River","Boro Zone" 249,"Manhattan","West Village","Yellow Zone" 250,"Bronx","Westchester Village/Unionport","Boro Zone" 251,"Staten Island","Westerleigh","Boro Zone" 252,"Queens","Whitestone","Boro Zone" 253,"Queens","Willets Point","Boro Zone" 254,"Bronx","Williamsbridge/Olinville","Boro Zone" 255,"Brooklyn","Williamsburg (North Side)","Boro Zone" 256,"Brooklyn","Williamsburg (South Side)","Boro Zone" 257,"Brooklyn","Windsor Terrace","Boro Zone" 258,"Queens","Woodhaven","Boro Zone" 259,"Bronx","Woodlawn/Wakefield","Boro Zone" 260,"Queens","Woodside","Boro Zone" 261,"Manhattan","World Trade Center","Yellow Zone" 262,"Manhattan","Yorkville East","Yellow Zone" 263,"Manhattan","Yorkville West","Yellow Zone" 264,"Unknown","NV","N/A" 265,"Unknown","NA","N/A" ================================================ FILE: internal/testing/integration/testdata/migrations/mysql/00001_table.sql ================================================ -- +goose Up -- +goose StatementBegin CREATE TABLE owners ( owner_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, owner_name VARCHAR(255) NOT NULL, owner_type ENUM('user', 'organization') NOT NULL ); CREATE TABLE IF NOT EXISTS repos ( repo_id BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, repo_full_name VARCHAR(255) NOT NULL, repo_owner_id BIGINT UNSIGNED NOT NULL, PRIMARY KEY (repo_id), FOREIGN KEY (repo_owner_id) REFERENCES owners(owner_id) ON DELETE CASCADE ); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DROP TABLE IF EXISTS repos; DROP TABLE IF EXISTS owners; -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/mysql/00002_insert.sql ================================================ -- +goose Up -- +goose StatementBegin INSERT INTO owners (owner_name, owner_type) VALUES ('lucas', 'user'), ('space', 'organization'); -- +goose StatementEnd INSERT INTO owners (owner_name, owner_type) VALUES ('james', 'user'), ('pressly', 'organization'); INSERT INTO repos (repo_full_name, repo_owner_id) VALUES ('james/rover', (SELECT owner_id FROM owners WHERE owner_name = 'james')), ('pressly/goose', (SELECT owner_id FROM owners WHERE owner_name = 'pressly')); -- +goose Down -- +goose StatementBegin DELETE FROM owners; -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/mysql/00003_alter.sql ================================================ -- +goose Up -- +goose StatementBegin ALTER TABLE repos ADD COLUMN IF NOT EXISTS homepage_url TEXT; ALTER TABLE repos ADD COLUMN is_private BOOLEAN NOT NULL DEFAULT FALSE; -- +goose StatementEnd -- +goose Down -- +goose StatementBegin ALTER TABLE repos DROP COLUMN IF EXISTS homepage_url; ALTER TABLE repos DROP COLUMN IF EXISTS is_private; -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/mysql/00004_empty.sql ================================================ -- +goose Up ================================================ FILE: internal/testing/integration/testdata/migrations/mysql/00005_no_tx.sql ================================================ -- +goose NO TRANSACTION -- +goose Up CREATE UNIQUE INDEX owners_owner_name_idx ON owners(owner_name); -- +goose Down DROP INDEX IF EXISTS owners_owner_name_idx ON owners; ================================================ FILE: internal/testing/integration/testdata/migrations/mysql/00006_complex.sql ================================================ -- +goose up -- +goose statementbegin CREATE OR REPLACE PROCEDURE insert_repository( IN p_repo_full_name VARCHAR(255), IN p_owner_name VARCHAR(255), IN p_owner_type VARCHAR(20) ) BEGIN DECLARE v_owner_id BIGINT; DECLARE v_repo_id BIGINT; -- Check if the owner already exists SELECT owner_id INTO v_owner_id FROM owners WHERE owner_name = p_owner_name AND owner_type = p_owner_type; -- If the owner does not exist, insert a new owner IF v_owner_id IS NULL THEN INSERT INTO owners (owner_name, owner_type) VALUES (p_owner_name, p_owner_type); SET v_owner_id = LAST_INSERT_ID(); END IF; -- Insert the repository using the obtained owner_id INSERT INTO repos (repo_full_name, repo_owner_id) VALUES (p_repo_full_name, v_owner_id); -- No explicit return needed in procedures END; -- +goose statementend -- +goose down DROP PROCEDURE IF EXISTS insert_repository; ================================================ FILE: internal/testing/integration/testdata/migrations/postgres/00001_table.sql ================================================ -- +goose Up -- +goose StatementBegin CREATE TYPE owner_type as ENUM('user', 'organization'); CREATE TABLE owners ( owner_id BIGSERIAL PRIMARY KEY, owner_name text NOT NULL, owner_type owner_type NOT NULL ); CREATE TABLE IF NOT EXISTS repos ( repo_id BIGSERIAL NOT NULL, repo_full_name text NOT NULL, repo_owner_id bigint NOT NULL REFERENCES owners(owner_id) ON DELETE CASCADE, PRIMARY KEY (repo_id) ); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DROP TABLE IF EXISTS repos; DROP TABLE IF EXISTS owners; DROP TYPE owner_type; -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/postgres/00002_insert.sql ================================================ -- +goose Up -- +goose StatementBegin INSERT INTO owners(owner_name, owner_type) VALUES ('lucas', 'user'), ('space', 'organization'); -- +goose StatementEnd INSERT INTO owners(owner_name, owner_type) VALUES ('james', 'user'), ('pressly', 'organization'); INSERT INTO repos(repo_full_name, repo_owner_id) VALUES ('james/rover', 3), ('pressly/goose', 4); -- +goose Down -- +goose StatementBegin DELETE FROM owners; -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/postgres/00003_alter.sql ================================================ -- +goose Up -- +goose StatementBegin ALTER TABLE repos ADD COLUMN IF NOT EXISTS homepage_url text, ADD COLUMN is_private boolean NOT NULL DEFAULT false; -- +goose StatementEnd -- +goose Down -- +goose StatementBegin ALTER TABLE repos DROP COLUMN IF EXISTS homepage_url, DROP COLUMN is_private; -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/postgres/00004_empty.sql ================================================ -- +goose Up ================================================ FILE: internal/testing/integration/testdata/migrations/postgres/00005_no_tx.sql ================================================ -- +goose NO TRANSACTION -- +goose Up CREATE UNIQUE INDEX CONCURRENTLY ON owners(owner_name); -- +goose Down DROP INDEX IF EXISTS owners_owner_name_idx; ================================================ FILE: internal/testing/integration/testdata/migrations/postgres/00006_complex.sql ================================================ -- +goose up -- +goose statementbegin CREATE OR REPLACE FUNCTION insert_repository( p_repo_full_name TEXT, p_owner_name TEXT, p_owner_type OWNER_TYPE ) RETURNS VOID AS $$ DECLARE v_owner_id BIGINT; v_repo_id BIGINT; BEGIN -- Check if the owner already exists SELECT owner_id INTO v_owner_id FROM owners WHERE owner_name = p_owner_name AND owner_type = p_owner_type; -- If the owner does not exist, insert a new owner IF v_owner_id IS NULL THEN INSERT INTO owners (owner_name, owner_type) VALUES (p_owner_name, p_owner_type) RETURNING owner_id INTO v_owner_id; END IF; -- Insert the repository using the obtained owner_id INSERT INTO repos (repo_full_name, repo_owner_id) VALUES (p_repo_full_name, v_owner_id) RETURNING repo_id INTO v_repo_id; -- Commit the transaction COMMIT; END; $$ LANGUAGE plpgsql; -- +goose statementend -- +goose down DROP FUNCTION IF EXISTS insert_repository(TEXT, TEXT, OWNER_TYPE); ================================================ FILE: internal/testing/integration/testdata/migrations/spanner/00001_table.sql ================================================ -- +goose NO TRANSACTION -- +goose Up -- +goose StatementBegin CREATE TABLE owners ( owner_id INT64 NOT NULL, owner_name STRING(255) NOT NULL, owner_type STRING(50) NOT NULL, ) PRIMARY KEY(owner_id) -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DROP TABLE owners -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/spanner/00002_insert.sql ================================================ -- +goose Up -- +goose StatementBegin INSERT INTO owners (owner_id, owner_name, owner_type) VALUES (1, 'lucas', 'user'), (2, 'space', 'organization'), (3, 'james', 'user'), (4, 'pressly', 'organization'); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DELETE FROM owners WHERE owner_id IS NOT NULL -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/spanner/00003_alter.sql ================================================ -- +goose NO TRANSACTION -- +goose Up -- +goose StatementBegin ALTER TABLE owners ADD COLUMN homepage_url STRING(255) -- +goose StatementEnd -- +goose Down -- +goose StatementBegin ALTER TABLE owners DROP COLUMN homepage_url -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/spanner/00004_empty.sql ================================================ -- +goose Up -- no-op -- +goose Down -- no-op ================================================ FILE: internal/testing/integration/testdata/migrations/spanner/00005_no_tx.sql ================================================ -- +goose NO TRANSACTION -- +goose Up -- +goose StatementBegin CREATE UNIQUE NULL_FILTERED INDEX owners_owner_name_idx ON owners (owner_name) -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DROP INDEX owners_owner_name_idx -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/spanner/00006_view.sql ================================================ -- +goose NO TRANSACTION -- +goose Up -- +goose StatementBegin CREATE VIEW view_owners SQL SECURITY INVOKER AS SELECT owners.owner_id, owners.owner_name, owners.owner_type FROM owners -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DROP VIEW view_owners -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/starrocks/00001_a.sql ================================================ -- +goose Up -- +goose StatementBegin CREATE SCHEMA IF NOT EXISTS testing; -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DROP SCHEMA IF EXISTS testing; -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/starrocks/00002_b.sql ================================================ -- +goose Up -- +goose StatementBegin CREATE TABLE testing.test_migrations_1 ( version_id bigint NOT NULL, id bigint NOT NULL AUTO_INCREMENT, is_applied boolean NOT NULL, tstamp datetime NULL default CURRENT_TIMESTAMP ) PRIMARY KEY (version_id,id) DISTRIBUTED BY HASH (id) ORDER BY (version_id); -- +goose StatementEnd -- +goose StatementBegin CREATE TABLE testing.test_migrations_2 ( version_id bigint NOT NULL, id bigint NOT NULL AUTO_INCREMENT, is_applied boolean NOT NULL, tstamp datetime NULL default CURRENT_TIMESTAMP ) PRIMARY KEY (version_id,id) DISTRIBUTED BY HASH (id) ORDER BY (version_id); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DROP TABLE IF EXISTS testing.test_migrations_1; -- +goose StatementEnd -- +goose StatementBegin DROP TABLE IF EXISTS testing.test_migrations_2; -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/starrocks/00003_c.sql ================================================ -- +goose Up -- +goose StatementBegin INSERT INTO testing.test_migrations_1 (version_id, is_applied) VALUES (1, true); -- +goose StatementEnd -- +goose StatementBegin INSERT INTO testing.test_migrations_1 (version_id, is_applied) VALUES (2, true); -- +goose StatementEnd -- +goose StatementBegin INSERT INTO testing.test_migrations_1 (version_id, is_applied) VALUES (3, true); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DELETE FROM testing.test_migrations_1 WHERE version_id < 10; -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/turso/00001_table.sql ================================================ -- +goose Up -- +goose StatementBegin CREATE TABLE owners ( owner_id INTEGER PRIMARY KEY AUTOINCREMENT, owner_name TEXT NOT NULL, owner_type TEXT CHECK(owner_type IN ('user', 'organization')) NOT NULL ); CREATE TABLE IF NOT EXISTS repos ( repo_id INTEGER PRIMARY KEY AUTOINCREMENT, repo_full_name TEXT NOT NULL, repo_owner_id INTEGER NOT NULL REFERENCES owners(owner_id) ON DELETE CASCADE ); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DROP TABLE IF EXISTS repos; DROP TABLE IF EXISTS owners; -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/turso/00002_insert.sql ================================================ -- +goose Up -- +goose StatementBegin INSERT INTO owners(owner_name, owner_type) VALUES ('lucas', 'user'), ('space', 'organization'), ('james', 'user'), ('pressly', 'organization'); -- +goose StatementEnd INSERT INTO repos(repo_full_name, repo_owner_id) VALUES ('james/rover', (SELECT owner_id FROM owners WHERE owner_name = 'james')), ('pressly/goose', (SELECT owner_id FROM owners WHERE owner_name = 'pressly')); -- +goose Down -- +goose StatementBegin DELETE FROM owners; -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/turso/00003_alter.sql ================================================ -- +goose Up -- +goose StatementBegin ALTER TABLE repos ADD COLUMN homepage_url TEXT; ALTER TABLE repos ADD COLUMN is_private BOOLEAN DEFAULT 0; -- +goose StatementEnd -- +goose Down -- +goose StatementBegin ALTER TABLE repos DROP COLUMN homepage_url; ALTER TABLE repos DROP COLUMN is_private; -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/turso/00004_empty.sql ================================================ -- +goose Up ================================================ FILE: internal/testing/integration/testdata/migrations/turso/00005_no_tx.sql ================================================ -- +goose NO TRANSACTION -- +goose Up CREATE UNIQUE INDEX IF NOT EXISTS idx_owners_owner_name ON owners(owner_name); -- +goose Down DROP INDEX IF EXISTS idx_owners_owner_name; ================================================ FILE: internal/testing/integration/testdata/migrations/ydb/00001_a.sql ================================================ -- +goose Up -- +goose StatementBegin CREATE TABLE owners ( owner_id Uint64, owner_name Utf8, owner_type Utf8, PRIMARY KEY (owner_id) ); CREATE TABLE repos ( repo_id Uint64, repo_owner_id Uint64, repo_full_name Utf8, PRIMARY KEY (repo_id) ); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DROP TABLE repos; DROP TABLE owners; -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/ydb/00002_b.sql ================================================ -- +goose Up -- +goose StatementBegin INSERT INTO owners(owner_id, owner_name, owner_type) VALUES (1, 'lucas', 'user'), (2, 'space', 'organization'); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DELETE FROM owners; -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/ydb/00003_c.sql ================================================ -- +goose Up -- +goose StatementBegin INSERT INTO owners(owner_id, owner_name, owner_type) VALUES (3, 'james', 'user'), (4, 'pressly', 'organization'); INSERT INTO repos(repo_id, repo_full_name, repo_owner_id) VALUES (1, 'james/rover', 3), (2, 'pressly/goose', 4); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DELETE FROM owners WHERE (owner_id = 3 OR owner_id = 4); DELETE FROM repos WHERE (repo_id = 1 OR repo_id = 2); -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/ydb/00004_d.sql ================================================ -- +goose Up -- +goose StatementBegin ALTER TABLE repos ADD COLUMN homepage_url Utf8, ADD COLUMN is_private Bool; -- +goose StatementEnd -- +goose Down -- +goose StatementBegin ALTER TABLE repos DROP COLUMN homepage_url, DROP COLUMN is_private; -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/ydb/00005_e.sql ================================================ -- +goose Up -- +goose StatementBegin -- NOTE: intentionally left blank to verify migration logic. SELECT 'up SQL query'; -- +goose StatementEnd -- +goose Down -- +goose StatementBegin -- NOTE: intentionally left blank to verify migration logic. SELECT 'down SQL query'; -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/ydb/00006_f.sql ================================================ -- +goose Up -- +goose StatementBegin CREATE TABLE stargazers ( stargazer_repo_id Uint64, stargazer_owner_id UInt64, stargazer_starred_at Timestamp, stargazer_location Utf8, PRIMARY KEY (stargazer_repo_id, stargazer_owner_id) ); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DROP TABLE stargazers; -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/ydb/00007_g.sql ================================================ -- +goose Up -- +goose StatementBegin CREATE TABLE issues ( issue_id Uint64, issue_created_by Uint64, issue_repo_id Uint64, issue_created_at Timestamp, issue_description Utf8, PRIMARY KEY (issue_id) ); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DROP TABLE issues; -- +goose StatementEnd ================================================ FILE: internal/testing/integration/testdata/migrations/ydb/00008_h.sql ================================================ -- +goose Up -- +goose StatementBegin ALTER TABLE stargazers DROP COLUMN stargazer_location; -- +goose StatementEnd -- +goose Down -- +goose StatementBegin ALTER TABLE stargazers ADD COLUMN stargazer_location Utf8; -- +goose StatementEnd ================================================ FILE: internal/testing/testdb/clickhouse.go ================================================ package testdb import ( "crypto/tls" "database/sql" "fmt" "log" "strconv" "time" "github.com/ClickHouse/clickhouse-go/v2" "github.com/ory/dockertest/v3" "github.com/ory/dockertest/v3/docker" ) const ( // https://hub.docker.com/r/clickhouse/clickhouse-server/ CLICKHOUSE_IMAGE = "clickhouse/clickhouse-server" CLICKHOUSE_VERSION = "24-alpine" CLICKHOUSE_DB = "clickdb" CLICKHOUSE_USER = "clickuser" CLICKHOUSE_PASSWORD = "password1" CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT = "1" ) func newClickHouse(opts ...OptionsFunc) (*sql.DB, func(), error) { option := &options{} for _, f := range opts { f(option) } // Uses a sensible default on windows (tcp/http) and linux/osx (socket). pool, err := dockertest.NewPool("") if err != nil { return nil, nil, err } runOptions := &dockertest.RunOptions{ Repository: CLICKHOUSE_IMAGE, Tag: CLICKHOUSE_VERSION, Env: []string{ "CLICKHOUSE_DB=" + CLICKHOUSE_DB, "CLICKHOUSE_USER=" + CLICKHOUSE_USER, "CLICKHOUSE_PASSWORD=" + CLICKHOUSE_PASSWORD, "CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=" + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT, }, Labels: map[string]string{"goose_test": "1"}, PortBindings: make(map[docker.Port][]docker.PortBinding), } // Port 8123 is used for HTTP, but we're using the TCP protocol endpoint (port 9000). // Ref: https://clickhouse.com/docs/en/interfaces/http/ // Ref: https://clickhouse.com/docs/en/interfaces/tcp/ if option.bindPort > 0 { runOptions.PortBindings[docker.Port("9000/tcp")] = []docker.PortBinding{ {HostPort: strconv.Itoa(option.bindPort)}, } } container, err := pool.RunWithOptions( runOptions, func(config *docker.HostConfig) { // Set AutoRemove to true so that stopped container goes away by itself. config.AutoRemove = true config.RestartPolicy = docker.RestartPolicy{Name: "no"} }, ) if err != nil { return nil, nil, err } cleanup := func() { if option.debug { // User must manually delete the Docker container. return } if err := pool.Purge(container); err != nil { log.Printf("failed to purge resource: %v", err) } } // Fetch port assigned to container address := fmt.Sprintf("%s:%s", "localhost", container.GetPort("9000/tcp")) var db *sql.DB // Exponential backoff-retry, because the application in the container // might not be ready to accept connections yet. if err := pool.Retry(func() error { db = clickHouseOpenDB(address, nil, option.debug) return db.Ping() }); err != nil { return nil, cleanup, fmt.Errorf("could not connect to docker database: %w", err) } return db, cleanup, nil } func clickHouseOpenDB(address string, tlsConfig *tls.Config, debug bool) *sql.DB { db := clickhouse.OpenDB(&clickhouse.Options{ Addr: []string{address}, Auth: clickhouse.Auth{ Database: CLICKHOUSE_DB, Username: CLICKHOUSE_USER, Password: CLICKHOUSE_PASSWORD, }, TLS: tlsConfig, Settings: clickhouse.Settings{ "max_execution_time": 60, }, DialTimeout: 5 * time.Second, Compression: &clickhouse.Compression{ Method: clickhouse.CompressionLZ4, }, Debug: debug, }) db.SetMaxIdleConns(5) db.SetMaxOpenConns(10) db.SetConnMaxLifetime(time.Hour) return db } ================================================ FILE: internal/testing/testdb/container_healthcheck.go ================================================ package testdb import ( "context" "time" "github.com/ory/dockertest/v3" "github.com/ory/dockertest/v3/docker/types" ) // containerWaitHealthy waits until docker container with specified id is healthy func containerWaitHealthy(ctx context.Context, pool *dockertest.Pool, id string) error { for { select { case <-ctx.Done(): return ctx.Err() default: attemptCtx, attemptCancel := context.WithTimeout(ctx, time.Second) status, err := containerHealthStatus(attemptCtx, pool, id) attemptCancel() if err != nil { return err } if status == types.Healthy { return nil } } } } func containerHealthStatus(ctx context.Context, pool *dockertest.Pool, id string) (string, error) { currentContainer, err := pool.Client.InspectContainerWithContext(id, ctx) if err != nil { return "", err } return currentContainer.State.Health.Status, nil } ================================================ FILE: internal/testing/testdb/mariadb.go ================================================ package testdb import ( "database/sql" "fmt" "log" "strconv" "time" _ "github.com/go-sql-driver/mysql" "github.com/ory/dockertest/v3" "github.com/ory/dockertest/v3/docker" ) const ( // https://hub.docker.com/_/mariadb MARIADB_IMAGE = "mariadb" MARIADB_VERSION = "11" MARIADB_DB = "testdb" MARIADB_USER = "tester" MARIADB_PASSWORD = "password1" ) func newMariaDB(opts ...OptionsFunc) (*sql.DB, func(), error) { option := &options{} for _, f := range opts { f(option) } // Uses a sensible default on windows (tcp/http) and linux/osx (socket). pool, err := dockertest.NewPool("") if err != nil { return nil, nil, fmt.Errorf("failed to connect to docker: %v", err) } options := &dockertest.RunOptions{ Repository: MARIADB_IMAGE, Tag: MARIADB_VERSION, Env: []string{ "MARIADB_USER=" + MARIADB_USER, "MARIADB_PASSWORD=" + MARIADB_PASSWORD, "MARIADB_ROOT_PASSWORD=" + MARIADB_PASSWORD, "MARIADB_DATABASE=" + MARIADB_DB, }, Labels: map[string]string{"goose_test": "1"}, PortBindings: make(map[docker.Port][]docker.PortBinding), } if option.bindPort > 0 { options.PortBindings[docker.Port("3306/tcp")] = []docker.PortBinding{ {HostPort: strconv.Itoa(option.bindPort)}, } } container, err := pool.RunWithOptions( options, func(config *docker.HostConfig) { // Set AutoRemove to true so that stopped container goes away by itself. config.AutoRemove = true // config.PortBindings = options.PortBindings config.RestartPolicy = docker.RestartPolicy{Name: "no"} }, ) if err != nil { return nil, nil, fmt.Errorf("failed to create docker container: %v", err) } cleanup := func() { if option.debug { // User must manually delete the Docker container. return } if err := pool.Purge(container); err != nil { log.Printf("failed to purge resource: %v", err) } } // MySQL DSN: username:password@protocol(address)/dbname?param=value dsn := fmt.Sprintf("%s:%s@(%s:%s)/%s?parseTime=true&multiStatements=true", MARIADB_USER, MARIADB_PASSWORD, "localhost", container.GetPort("3306/tcp"), // Fetch port dynamically assigned to container MARIADB_DB, ) var db *sql.DB // Exponential backoff-retry, because the application in the container // might not be ready to accept connections yet. Add an extra sleep // because mariadb containers take much longer to startup. time.Sleep(5 * time.Second) if err := pool.Retry(func() error { var err error db, err = sql.Open("mysql", dsn) if err != nil { return err } return db.Ping() }, ); err != nil { return nil, cleanup, fmt.Errorf("could not connect to docker database: %v", err) } return db, cleanup, nil } ================================================ FILE: internal/testing/testdb/options.go ================================================ package testdb type options struct { bindPort int debug bool } type OptionsFunc func(o *options) func WithBindPort(n int) OptionsFunc { return func(o *options) { o.bindPort = n } } func WithDebug(b bool) OptionsFunc { return func(o *options) { o.debug = b } } ================================================ FILE: internal/testing/testdb/postgres.go ================================================ package testdb import ( "database/sql" "fmt" "log" "strconv" _ "github.com/jackc/pgx/v5/stdlib" "github.com/ory/dockertest/v3" "github.com/ory/dockertest/v3/docker" ) const ( // https://hub.docker.com/_/postgres POSTGRES_IMAGE = "postgres" POSTGRES_VERSION = "16-alpine" POSTGRES_DB = "testdb" POSTGRES_USER = "postgres" POSTGRES_PASSWORD = "password1" ) func newPostgres(opts ...OptionsFunc) (*sql.DB, func(), error) { option := &options{} for _, f := range opts { f(option) } // Uses a sensible default on windows (tcp/http) and linux/osx (socket). pool, err := dockertest.NewPool("") if err != nil { return nil, nil, fmt.Errorf("failed to connect to docker: %v", err) } options := &dockertest.RunOptions{ Repository: POSTGRES_IMAGE, Tag: POSTGRES_VERSION, Env: []string{ "POSTGRES_USER=" + POSTGRES_USER, "POSTGRES_PASSWORD=" + POSTGRES_PASSWORD, "POSTGRES_DB=" + POSTGRES_DB, "listen_addresses = '*'", }, Labels: map[string]string{"goose_test": "1"}, PortBindings: make(map[docker.Port][]docker.PortBinding), } if option.bindPort > 0 { options.PortBindings[docker.Port("5432/tcp")] = []docker.PortBinding{ {HostPort: strconv.Itoa(option.bindPort)}, } } container, err := pool.RunWithOptions( options, func(config *docker.HostConfig) { // Set AutoRemove to true so that stopped container goes away by itself. config.AutoRemove = true config.RestartPolicy = docker.RestartPolicy{Name: "no"} }, ) if err != nil { return nil, nil, fmt.Errorf("failed to create docker container: %v", err) } cleanup := func() { if option.debug { // User must manually delete the Docker container. return } if err := pool.Purge(container); err != nil { log.Printf("failed to purge resource: %v", err) } } psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", "localhost", container.GetPort("5432/tcp"), // Fetch port dynamically assigned to container POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, ) var db *sql.DB // Exponential backoff-retry, because the application in the container // might not be ready to accept connections yet. if err := pool.Retry( func() error { var err error db, err = sql.Open("pgx", psqlInfo) if err != nil { return err } return db.Ping() }, ); err != nil { return nil, cleanup, fmt.Errorf("could not connect to docker database: %v", err) } return db, cleanup, nil } ================================================ FILE: internal/testing/testdb/spanner.go ================================================ package testdb import ( "context" "database/sql" "fmt" "log" "os" "strings" "time" _ "github.com/googleapis/go-sql-spanner" // Spanner driver "github.com/ory/dockertest/v3" "github.com/ory/dockertest/v3/docker" database "cloud.google.com/go/spanner/admin/database/apiv1" dbpb "cloud.google.com/go/spanner/admin/database/apiv1/databasepb" instance "cloud.google.com/go/spanner/admin/instance/apiv1" inspb "cloud.google.com/go/spanner/admin/instance/apiv1/instancepb" ) const ( SPANNER_IMAGE = "gcr.io/cloud-spanner-emulator/emulator" SPANNER_VERSION = "latest" SPANNER_PROJECT = "test-project" SPANNER_INSTANCE = "test-instance" SPANNER_DATABASE = "test-db" ) func newSpanner(opts ...OptionsFunc) (*sql.DB, func(), error) { option := &options{} for _, f := range opts { f(option) } pool, err := dockertest.NewPool("") if err != nil { return nil, nil, fmt.Errorf("failed to connect to docker: %v", err) } resource, err := pool.RunWithOptions( &dockertest.RunOptions{ Repository: SPANNER_IMAGE, Tag: SPANNER_VERSION, ExposedPorts: []string{ "9010/tcp", "9020/tcp", }, Labels: map[string]string{"goose_test": "1"}, }, func(config *docker.HostConfig) { config.AutoRemove = true config.RestartPolicy = docker.RestartPolicy{Name: "no"} }, ) if err != nil { return nil, nil, fmt.Errorf("failed to start spanner emulator container: %v", err) } hostPort := resource.GetPort("9010/tcp") emulatorHost := fmt.Sprintf("localhost:%s", hostPort) os.Setenv("SPANNER_EMULATOR_HOST", emulatorHost) // Provision instance + database inside emulator err = pool.Retry(func() error { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() if err := createSpannerResources(ctx); err != nil { return err } dsn := fmt.Sprintf("projects/%s/instances/%s/databases/%s", SPANNER_PROJECT, SPANNER_INSTANCE, SPANNER_DATABASE) db, err := sql.Open("spanner", dsn) if err != nil { return err } defer db.Close() return db.PingContext(ctx) }) if err != nil { _ = pool.Purge(resource) return nil, nil, fmt.Errorf("could not initialize spanner emulator: %v", err) } dsn := fmt.Sprintf("projects/%s/instances/%s/databases/%s", SPANNER_PROJECT, SPANNER_INSTANCE, SPANNER_DATABASE) db, err := sql.Open("spanner", dsn) if err != nil { return nil, nil, fmt.Errorf("failed to open spanner DB: %v", err) } cleanup := func() { if err := db.Close(); err != nil { log.Printf("failed to close spanner db: %v", err) } if err := pool.Purge(resource); err != nil { log.Printf("failed to purge spanner emulator container: %v", err) } } return db, cleanup, nil } func createSpannerResources(ctx context.Context) error { instClient, err := instance.NewInstanceAdminClient(ctx) if err != nil { return fmt.Errorf("create instance client failed: %w", err) } defer instClient.Close() dbClient, err := database.NewDatabaseAdminClient(ctx) if err != nil { return fmt.Errorf("create database client failed: %w", err) } defer dbClient.Close() instReq := &inspb.CreateInstanceRequest{ Parent: "projects/" + SPANNER_PROJECT, InstanceId: SPANNER_INSTANCE, Instance: &inspb.Instance{ Config: "projects/" + SPANNER_PROJECT + "/instanceConfigs/emulator-config", DisplayName: "Test Instance", NodeCount: 1, }, } if _, err = instClient.CreateInstance(ctx, instReq); err != nil && !strings.Contains(err.Error(), "AlreadyExists") { return fmt.Errorf("create instance failed: %w", err) } dbReq := &dbpb.CreateDatabaseRequest{ Parent: fmt.Sprintf("projects/%s/instances/%s", SPANNER_PROJECT, SPANNER_INSTANCE), CreateStatement: "CREATE DATABASE `" + SPANNER_DATABASE + "`", } if _, err = dbClient.CreateDatabase(ctx, dbReq); err != nil && !strings.Contains(err.Error(), "AlreadyExists") { return fmt.Errorf("create database failed: %w", err) } return nil } ================================================ FILE: internal/testing/testdb/starrocks.go ================================================ package testdb import ( "database/sql" "fmt" "log" "strconv" "time" _ "github.com/go-sql-driver/mysql" "github.com/ory/dockertest/v3" "github.com/ory/dockertest/v3/docker" ) const ( // https://hub.docker.com/r/starrocks/allin1-ubuntu STARROCKS_IMAGE = "starrocks/allin1-ubuntu" STARROCKS_VERSION = "3.5.11" STARROCKS_USER = "root" STARROCKS_INIT_DB = "migrations" ) func newStarrocks(opts ...OptionsFunc) (*sql.DB, func(), error) { option := &options{} for _, f := range opts { f(option) } // Uses a sensible default on windows (tcp/http) and linux/osx (socket). pool, err := dockertest.NewPool("") if err != nil { return nil, nil, fmt.Errorf("failed to connect to docker: %v", err) } options := &dockertest.RunOptions{ Repository: STARROCKS_IMAGE, Tag: STARROCKS_VERSION, Labels: map[string]string{"goose_test": "1"}, PortBindings: make(map[docker.Port][]docker.PortBinding), ExposedPorts: []string{"9030/tcp"}, } if option.bindPort > 0 { options.PortBindings[docker.Port("9030/tcp")] = []docker.PortBinding{ {HostPort: strconv.Itoa(option.bindPort)}, } } container, err := pool.RunWithOptions( options, func(config *docker.HostConfig) { // Set AutoRemove to true so that stopped container goes away by itself. config.AutoRemove = true config.RestartPolicy = docker.RestartPolicy{Name: "no"} }, ) if err != nil { return nil, nil, fmt.Errorf("failed to create docker container: %v", err) } cleanup := func() { if option.debug { // User must manually delete the Docker container. return } if err := pool.Purge(container); err != nil { log.Printf("failed to purge resource: %v", err) } } dsn := fmt.Sprintf("%s:%s@(%s:%s)/%s?parseTime=true&interpolateParams=true", STARROCKS_USER, "", "localhost", container.GetPort("9030/tcp"), // Fetch port dynamically assigned to container, "", ) var db *sql.DB // Exponential backoff-retry, because the application in the container // might not be ready to accept connections yet. Add an extra sleep // because container take much longer to startup. pool.MaxWait = time.Minute * 2 if err := pool.Retry(func() error { var err error db, err = sql.Open("mysql", dsn) if err != nil { return err } _, err = db.Exec("CREATE DATABASE IF NOT EXISTS " + STARROCKS_INIT_DB) if err != nil { return fmt.Errorf("could not create initial database: %v", err) } _, err = db.Exec("USE " + STARROCKS_INIT_DB) if err != nil { return fmt.Errorf("could not set default initial database: %v", err) } return db.Ping() }, ); err != nil { return nil, cleanup, fmt.Errorf("could not connect to docker database: %v", err) } return db, cleanup, nil } ================================================ FILE: internal/testing/testdb/testdb.go ================================================ package testdb import "database/sql" // NewClickHouse starts a ClickHouse docker container. Returns db connection and a docker cleanup function. func NewClickHouse(options ...OptionsFunc) (db *sql.DB, cleanup func(), err error) { return newClickHouse(options...) } // NewPostgres starts a PostgreSQL docker container. Returns db connection and a docker cleanup function. func NewPostgres(options ...OptionsFunc) (db *sql.DB, cleanup func(), err error) { return newPostgres(options...) } // NewSpanner starts a Spanner docker container. Returns db connection and a docker cleanup function. func NewSpanner(options ...OptionsFunc) (db *sql.DB, cleanup func(), err error) { return newSpanner(options...) } // NewMariaDB starts a MariaDB docker container. Returns a db connection and a docker cleanup function. func NewMariaDB(options ...OptionsFunc) (db *sql.DB, cleanup func(), err error) { return newMariaDB(options...) } // NewYdb starts a YDB docker container. Returns db connection and a docker cleanup function. func NewYdb(options ...OptionsFunc) (db *sql.DB, cleanup func(), err error) { return newYdb(options...) } // NewStarrocks starts a Starrocks docker container. Returns db connection and a docker cleanup function. func NewStarrocks(options ...OptionsFunc) (db *sql.DB, cleanup func(), err error) { return newStarrocks(options...) } ================================================ FILE: internal/testing/testdb/turso.go ================================================ package testdb import ( "database/sql" "fmt" "log" "strconv" "github.com/ory/dockertest/v3" "github.com/ory/dockertest/v3/docker" _ "github.com/tursodatabase/libsql-client-go/libsql" ) const ( // ghcr.io/tursodatabase/libsql-server:v0.23.7 TURSO_IMAGE = "ghcr.io/tursodatabase/libsql-server" TURSO_VERSION = "v0.24.7" TURSO_PORT = "8080" ) // NewTurso starts a Turso docker container. Returns db connection and a docker cleanup function. func NewTurso(options ...OptionsFunc) (db *sql.DB, cleanup func(), err error) { return newTurso(options...) } func newTurso(opts ...OptionsFunc) (*sql.DB, func(), error) { option := &options{} for _, f := range opts { f(option) } // Uses a sensible default on windows (tcp/http) and linux/osx (socket). pool, err := dockertest.NewPool("") if err != nil { return nil, nil, err } runOptions := &dockertest.RunOptions{ Repository: TURSO_IMAGE, Tag: TURSO_VERSION, Labels: map[string]string{"goose_test": "1"}, PortBindings: make(map[docker.Port][]docker.PortBinding), } if option.debug { runOptions.Env = append(runOptions.Env, "RUST=trace") } else { runOptions.Env = append(runOptions.Env, "RUST=error") } if option.bindPort > 0 { runOptions.PortBindings[TURSO_PORT+"/tcp"] = []docker.PortBinding{ {HostPort: strconv.Itoa(option.bindPort)}, } } container, err := pool.RunWithOptions( runOptions, func(config *docker.HostConfig) { // Set AutoRemove to true so that stopped container goes away by itself. config.AutoRemove = true config.RestartPolicy = docker.RestartPolicy{Name: "no"} }, ) if err != nil { return nil, nil, err } cleanup := func() { if option.debug { // User must manually delete the Docker container. return } if err := pool.Purge(container); err != nil { log.Printf("failed to purge resource: %v", err) } } // Fetch port assigned to container var db *sql.DB // Exponential backoff-retry, because the application in the container // might not be ready to accept connections yet. if err := pool.Retry(func() error { db, err = tursoOpenDB(container) return err }); err != nil { return nil, cleanup, fmt.Errorf("could not connect to docker database: %w", err) } return db, cleanup, nil } func tursoOpenDB(container *dockertest.Resource) (*sql.DB, error) { address := fmt.Sprintf("http://127.0.0.1:%s", container.GetPort(TURSO_PORT+"/tcp")) db, err := sql.Open("libsql", address) if err != nil { return db, err } // let's do a ping to be sure we are connected var result int err = db.QueryRow("SELECT 1").Scan(&result) if err != nil { return nil, err } return db, nil } ================================================ FILE: internal/testing/testdb/ydb.go ================================================ package testdb import ( "context" "database/sql" "fmt" "log" "os" "strconv" "time" "github.com/ory/dockertest/v3" "github.com/ory/dockertest/v3/docker" "github.com/ydb-platform/ydb-go-sdk/v3" "github.com/ydb-platform/ydb-go-sdk/v3/balancers" ydblog "github.com/ydb-platform/ydb-go-sdk/v3/log" "github.com/ydb-platform/ydb-go-sdk/v3/trace" ) const ( YDB_IMAGE = "ghcr.io/ydb-platform/local-ydb" YDB_VERSION = "24.1" YDB_PORT = "2136" YDB_UI_PORT = "8765" YDB_DATABASE = "local" ) func newYdb(opts ...OptionsFunc) (*sql.DB, func(), error) { option := &options{} for _, f := range opts { f(option) } // Uses a sensible default on windows (tcp/http) and linux/osx (socket). pool, err := dockertest.NewPool("") if err != nil { return nil, nil, err } runOptions := &dockertest.RunOptions{ Repository: YDB_IMAGE, Tag: YDB_VERSION, Env: []string{ "YDB_USE_IN_MEMORY_PDISKS=true", "YDB_LOCAL_SURVIVE_RESTART=true", "GRPC_PORT=" + YDB_PORT, "MON_PORT=" + YDB_UI_PORT, }, Labels: map[string]string{"goose_test": "1"}, PortBindings: map[docker.Port][]docker.PortBinding{}, Mounts: []string{os.TempDir() + ":/ydb_certs"}, Hostname: "localhost", } if option.debug { runOptions.Env = append(runOptions.Env, "YDB_DEFAULT_LOG_LEVEL=NOTICE") } else { runOptions.Env = append(runOptions.Env, "YDB_DEFAULT_LOG_LEVEL=ERROR") } if option.bindPort > 0 { runOptions.PortBindings[YDB_PORT+"/tcp"] = []docker.PortBinding{ {HostPort: strconv.Itoa(option.bindPort)}, } } container, err := pool.RunWithOptions( runOptions, func(config *docker.HostConfig) { // Set AutoRemove to true so that stopped container goes away by itself. config.AutoRemove = true config.RestartPolicy = docker.RestartPolicy{Name: "no"} config.Init = true }, ) if err != nil { return nil, nil, err } cleanup := func() { if option.debug { // User must manually delete the Docker container. return } if err := pool.Purge(container); err != nil { log.Printf("failed to purge resource: %v", err) } } // Fetch port assigned to container dsn := fmt.Sprintf("grpc://%s:%s/%s", "localhost", container.GetPort(YDB_PORT+"/tcp"), YDB_DATABASE, ) var db *sql.DB // Exponential backoff-retry, because the application in the container // might not be ready to accept connections yet. if err := pool.Retry(func() (err error) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() if err := containerWaitHealthy(ctx, pool, container.Container.ID); err != nil { return err } opts := []ydb.Option{ ydb.WithBalancer(balancers.SingleConn()), } if option.debug { opts = append(opts, ydb.WithLogger(ydblog.Default(os.Stdout), trace.DetailsAll, ydblog.WithLogQuery())) } nativeDriver, err := ydb.Open(ctx, dsn, opts...) if err != nil { return err } defer func() { if err != nil { _ = nativeDriver.Close(context.Background()) } }() connector, err := ydb.Connector(nativeDriver, ydb.WithDefaultQueryMode(ydb.ScriptingQueryMode), ydb.WithFakeTx(ydb.ScriptingQueryMode), ydb.WithAutoDeclare(), ydb.WithNumericArgs(), ) if err != nil { return err } defer func() { if err != nil { _ = connector.Close() } }() db = sql.OpenDB(connector) db.SetMaxIdleConns(5) db.SetMaxOpenConns(10) db.SetConnMaxLifetime(time.Hour) err = db.Ping() if err != nil { return err } return nil }); err != nil { cleanup() return nil, nil, fmt.Errorf("could not connect to docker database: %w", err) } return db, cleanup, nil } ================================================ FILE: lock/internal/store/postgres.go ================================================ package store import ( "context" "database/sql" "errors" "fmt" "strings" "time" "go.uber.org/multierr" ) // NewPostgres creates a new Postgres-based [LockStore]. func NewPostgres(tableName string) (LockStore, error) { if tableName == "" { return nil, errors.New("table name must not be empty") } return &postgresStore{ tableName: tableName, }, nil } var _ LockStore = (*postgresStore)(nil) type postgresStore struct { tableName string } func (s *postgresStore) TableExists( ctx context.Context, db *sql.DB, ) (bool, error) { var query string schemaName, tableName := parseTableIdentifier(s.tableName) if schemaName != "" { q := `SELECT EXISTS ( SELECT 1 FROM pg_tables WHERE schemaname = '%s' AND tablename = '%s' )` query = fmt.Sprintf(q, schemaName, tableName) } else { q := `SELECT EXISTS ( SELECT 1 FROM pg_tables WHERE (current_schema() IS NULL OR schemaname = current_schema()) AND tablename = '%s' )` query = fmt.Sprintf(q, tableName) } var exists bool if err := db.QueryRowContext(ctx, query).Scan( &exists, ); err != nil { return false, fmt.Errorf("table exists: %w", err) } return exists, nil } func (s *postgresStore) CreateLockTable( ctx context.Context, db *sql.DB, ) error { exists, err := s.TableExists(ctx, db) if err != nil { return fmt.Errorf("check lock table existence: %w", err) } if exists { return nil } query := fmt.Sprintf(`CREATE TABLE %s ( lock_id bigint NOT NULL PRIMARY KEY, locked boolean NOT NULL DEFAULT false, locked_at timestamptz NULL, locked_by text NULL, lease_expires_at timestamptz NULL, updated_at timestamptz NULL )`, s.tableName) if _, err := db.ExecContext(ctx, query); err != nil { // Double-check if another process created it concurrently if exists, checkErr := s.TableExists(ctx, db); checkErr == nil && exists { // Another process created it, that's fine! return nil } return fmt.Errorf("create lock table %q: %w", s.tableName, err) } return nil } func (s *postgresStore) AcquireLock( ctx context.Context, db *sql.DB, lockID int64, lockedBy string, leaseDuration time.Duration, ) (*AcquireLockResult, error) { query := fmt.Sprintf(`INSERT INTO %s (lock_id, locked, locked_at, locked_by, lease_expires_at, updated_at) VALUES ($1, true, now(), $2, now() + $3::interval, now()) ON CONFLICT (lock_id) DO UPDATE SET locked = true, locked_at = now(), locked_by = $2, lease_expires_at = now() + $3::interval, updated_at = now() WHERE %s.locked = false OR %s.lease_expires_at < now() RETURNING locked_by, lease_expires_at`, s.tableName, s.tableName, s.tableName) // Convert duration to PostgreSQL interval format leaseDurationStr := formatDurationAsInterval(leaseDuration) var returnedLockedBy string var leaseExpiresAt time.Time err := db.QueryRowContext(ctx, query, lockID, lockedBy, leaseDurationStr, ).Scan( &returnedLockedBy, &leaseExpiresAt, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { // TODO(mf): should we return a special error type here? return nil, fmt.Errorf("acquire lock %d: already held by another instance", lockID) } return nil, fmt.Errorf("acquire lock %d: %w", lockID, err) } // Verify we got the lock by checking the returned locked_by matches our instance ID if returnedLockedBy != lockedBy { return nil, fmt.Errorf("acquire lock %d: acquired by %s instead of %s", lockID, returnedLockedBy, lockedBy) } return &AcquireLockResult{ LockedBy: returnedLockedBy, LeaseExpiresAt: leaseExpiresAt, }, nil } func (s *postgresStore) ReleaseLock( ctx context.Context, db *sql.DB, lockID int64, lockedBy string, ) (*ReleaseLockResult, error) { // Release lock only if it's held by the current instance query := fmt.Sprintf(`UPDATE %s SET locked = false, locked_at = NULL, locked_by = NULL, lease_expires_at = NULL, updated_at = now() WHERE lock_id = $1 AND locked_by = $2 RETURNING lock_id`, s.tableName) var returnedLockID int64 err := db.QueryRowContext(ctx, query, lockID, lockedBy, ).Scan( &returnedLockID, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { // TODO(mf): should we return a special error type here? return nil, fmt.Errorf("release lock %d: not held by this instance", lockID) } return nil, fmt.Errorf("release lock %d: %w", lockID, err) } // Verify the correct lock was released if returnedLockID != lockID { return nil, fmt.Errorf("release lock %d: returned lock ID %d does not match", lockID, returnedLockID) } return &ReleaseLockResult{ LockID: returnedLockID, }, nil } func (s *postgresStore) UpdateLease( ctx context.Context, db *sql.DB, lockID int64, lockedBy string, leaseDuration time.Duration, ) (*UpdateLeaseResult, error) { // Update lease expiration time for heartbeat, only if we own the lock query := fmt.Sprintf(`UPDATE %s SET lease_expires_at = now() + $1::interval, updated_at = now() WHERE lock_id = $2 AND locked_by = $3 AND locked = true RETURNING lease_expires_at`, s.tableName) // Convert duration to PostgreSQL interval format intervalStr := formatDurationAsInterval(leaseDuration) var leaseExpiresAt time.Time err := db.QueryRowContext(ctx, query, intervalStr, lockID, lockedBy, ).Scan( &leaseExpiresAt, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("failed to update lease for lock %d: not held by this instance", lockID) } return nil, fmt.Errorf("failed to update lease for lock %d: %w", lockID, err) } return &UpdateLeaseResult{ LeaseExpiresAt: leaseExpiresAt, }, nil } func (s *postgresStore) CheckLockStatus( ctx context.Context, db *sql.DB, lockID int64, ) (*LockStatus, error) { query := fmt.Sprintf(`SELECT locked, locked_by, lease_expires_at, updated_at FROM %s WHERE lock_id = $1`, s.tableName) var status LockStatus err := db.QueryRowContext(ctx, query, lockID, ).Scan( &status.Locked, &status.LockedBy, &status.LeaseExpiresAt, &status.UpdatedAt, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("lock %d not found", lockID) } return nil, fmt.Errorf("check lock status for %d: %w", lockID, err) } return &status, nil } func (s *postgresStore) CleanupStaleLocks(ctx context.Context, db *sql.DB) (_ []int64, retErr error) { query := fmt.Sprintf(`UPDATE %s SET locked = false, locked_at = NULL, locked_by = NULL, lease_expires_at = NULL, updated_at = now() WHERE locked = true AND lease_expires_at < now() RETURNING lock_id`, s.tableName) rows, err := db.QueryContext(ctx, query) if err != nil { return nil, fmt.Errorf("cleanup stale locks: %w", err) } defer func() { retErr = multierr.Append(retErr, rows.Close()) }() var cleanedLocks []int64 for rows.Next() { var lockID int64 if err := rows.Scan(&lockID); err != nil { return nil, fmt.Errorf("scan cleaned lock ID: %w", err) } cleanedLocks = append(cleanedLocks, lockID) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate over cleaned locks: %w", err) } return cleanedLocks, nil } // formatDurationAsInterval converts a time.Duration to PostgreSQL interval format func formatDurationAsInterval(d time.Duration) string { return fmt.Sprintf("%d seconds", int(d.Seconds())) } func parseTableIdentifier(name string) (schema, table string) { schema, table, found := strings.Cut(name, ".") if !found { return "", name } return schema, table } ================================================ FILE: lock/internal/store/store.go ================================================ package store import ( "context" "database/sql" "time" ) // LockStore defines the interface for storing and managing database locks. type LockStore interface { // CreateLockTable creates the lock table if it doesn't exist. Implementations should ensure // that this operation is idempotent. CreateLockTable(ctx context.Context, db *sql.DB) error // TableExists checks if the lock table exists. TableExists(ctx context.Context, db *sql.DB) (bool, error) // AcquireLock attempts to acquire a lock for the given lockID. AcquireLock(ctx context.Context, db *sql.DB, lockID int64, lockedBy string, leaseDuration time.Duration) (*AcquireLockResult, error) // ReleaseLock releases a lock held by the current instance. ReleaseLock(ctx context.Context, db *sql.DB, lockID int64, lockedBy string) (*ReleaseLockResult, error) // UpdateLease updates the lease expiration time for a lock (heartbeat). UpdateLease(ctx context.Context, db *sql.DB, lockID int64, lockedBy string, leaseDuration time.Duration) (*UpdateLeaseResult, error) // CheckLockStatus checks the current status of a lock. CheckLockStatus(ctx context.Context, db *sql.DB, lockID int64) (*LockStatus, error) // CleanupStaleLocks removes any locks that have expired using server time. Returns the list of // lock IDs that were cleaned up, if any. CleanupStaleLocks(ctx context.Context, db *sql.DB) ([]int64, error) } // LockStatus represents the current status of a lock. type LockStatus struct { Locked bool LockedBy *string LeaseExpiresAt *time.Time UpdatedAt *time.Time } // AcquireLockResult contains the result of a lock acquisition attempt. type AcquireLockResult struct { LockedBy string LeaseExpiresAt time.Time } // ReleaseLockResult contains the result of a lock release. type ReleaseLockResult struct { LockID int64 } // UpdateLeaseResult contains the result of a lease update. type UpdateLeaseResult struct { LeaseExpiresAt time.Time } ================================================ FILE: lock/internal/table/config.go ================================================ package table import ( "log/slog" "time" ) // Config holds configuration for table locker. type Config struct { TableName string LockID int64 LeaseDuration time.Duration HeartbeatInterval time.Duration LockTimeout ProbeConfig UnlockTimeout ProbeConfig // Optional logger for lock operations Logger *slog.Logger // Optional custom retry policy for database errors RetryPolicy RetryPolicyFunc } // ProbeConfig holds retry configuration. type ProbeConfig struct { IntervalDuration time.Duration FailureThreshold uint64 } ================================================ FILE: lock/internal/table/locker.go ================================================ package table import ( "cmp" "context" "crypto/rand" "database/sql" "encoding/hex" "fmt" "log/slog" "os" "sync" "time" "github.com/pressly/goose/v3/lock/internal/store" "github.com/sethvargo/go-retry" ) // RetryPolicyFunc inspects an error and returns whether the caller should retry the operation. This // allows for database-specific error handling without hardcoding driver-specific logic. type RetryPolicyFunc func(err error) bool // Locker implements table-based locking for databases. This implementation is safe for concurrent // use by multiple goroutines. type Locker struct { store store.LockStore tableName string lockID int64 instanceID string leaseDuration time.Duration heartbeatInterval time.Duration retryLock retry.Backoff retryUnlock retry.Backoff logger *slog.Logger retryPolicy RetryPolicyFunc // Application-level coordination mu sync.Mutex // Heartbeat management heartbeatCancel context.CancelFunc heartbeatDone chan struct{} } // New creates a new table-based locker. func New(lockStore store.LockStore, cfg Config) *Locker { // Generate instance identifier hostname, _ := os.Hostname() hostname = cmp.Or(hostname, "unknown-hostname") instanceID := fmt.Sprintf("%s-%d-%s", hostname, os.Getpid(), randomHex(4)) logger := cfg.Logger if logger == nil { logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) } return &Locker{ store: lockStore, tableName: cfg.TableName, lockID: cfg.LockID, instanceID: instanceID, leaseDuration: cfg.LeaseDuration, heartbeatInterval: cfg.HeartbeatInterval, logger: logger, retryPolicy: cfg.RetryPolicy, retryLock: retry.WithMaxRetries( cfg.LockTimeout.FailureThreshold, // Add +/- 25% jitter to reduce thundering herd retry.WithJitterPercent(25, retry.NewConstant(cfg.LockTimeout.IntervalDuration)), ), retryUnlock: retry.WithMaxRetries( cfg.UnlockTimeout.FailureThreshold, // Add +/- 25% jitter to reduce thundering herd retry.WithJitterPercent(25, retry.NewConstant(cfg.UnlockTimeout.IntervalDuration)), ), } } // Lock acquires the database lock. This method is safe for concurrent use - the mutex is held until // Unlock() is called. Only one goroutine can hold the lock at a time across the entire lifecycle. func (l *Locker) Lock(ctx context.Context, db *sql.DB) error { l.mu.Lock() // NOTE: mutex is NOT defer unlocked here, it remains held until Unlock() is called explicitly // or a specific error occurs below! // Ensure the lock table exists if err := l.store.CreateLockTable(ctx, db); err != nil { l.mu.Unlock() return fmt.Errorf("ensure lock table exists: %w", err) } err := retry.Do(ctx, l.retryLock, func(ctx context.Context) error { _, err := l.store.AcquireLock(ctx, db, l.lockID, l.instanceID, l.leaseDuration) if err != nil { // Clean up any stale locks before retrying if _, cleanupErr := l.store.CleanupStaleLocks(ctx, db); cleanupErr != nil { l.logger.WarnContext(ctx, "failed to cleanup stale locks", slog.Int64("lock_table", l.lockID), slog.Any("error", cleanupErr), ) // Continue with retry, cleanup failure shouldn't block acquisition attempts } if l.shouldRetry(err) { return retry.RetryableError(fmt.Errorf("acquire retryable lock: %w", err)) } return fmt.Errorf("acquire lock: %w", err) } return nil }) if err != nil { l.mu.Unlock() l.logger.WarnContext(ctx, "failed to acquire lock after retries", slog.Int64("lock_id", l.lockID), slog.String("instance_id", l.instanceID), slog.Any("error", err), ) return fmt.Errorf("acquire lock %d after retries: %w", l.lockID, err) } l.logger.DebugContext(ctx, "successfully acquired lock", slog.Int64("lock_id", l.lockID), slog.String("instance_id", l.instanceID), slog.Duration("lease_duration", l.leaseDuration), ) // Start heartbeat to maintain the lease l.startHeartbeat(ctx, db) // Mutex remains held - will be released in Unlock() return nil } // Unlock releases the database lock. This method must be called exactly once after a successful // Lock() call. func (l *Locker) Unlock(ctx context.Context, db *sql.DB) error { // NOTE: The mutex was acquired in Lock() and is still held defer l.mu.Unlock() // Use a context that can't be cancelled to ensure we always attempt to unlock even if the // caller's context is cancelled. The call can control the retry behavior via the configured // timeouts. ctx = context.WithoutCancel(ctx) // Stop heartbeat first l.stopHeartbeat() err := retry.Do(ctx, l.retryUnlock, func(ctx context.Context) error { _, err := l.store.ReleaseLock(ctx, db, l.lockID, l.instanceID) if err != nil { if l.shouldRetry(err) { return retry.RetryableError(fmt.Errorf("release retryable lock: %w", err)) } return fmt.Errorf("release lock: %w", err) } return nil }) if err != nil { l.logger.WarnContext(ctx, "failed to release lock", slog.Int64("lock_id", l.lockID), slog.String("instance_id", l.instanceID), slog.Any("error", err), ) return err } l.logger.DebugContext(ctx, "successfully released lock", slog.Int64("lock_id", l.lockID), slog.String("instance_id", l.instanceID), ) return nil } // startHeartbeat starts the heartbeat goroutine (called from within Lock with mutex held) func (l *Locker) startHeartbeat(parentCtx context.Context, db *sql.DB) { // If there's already a heartbeat running, stop it first l.stopHeartbeat() // Create a new context for the heartbeat ctx, cancel := context.WithCancel(parentCtx) l.heartbeatCancel = cancel l.heartbeatDone = make(chan struct{}) go func() { defer close(l.heartbeatDone) ticker := time.NewTicker(l.heartbeatInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: result, err := l.store.UpdateLease(ctx, db, l.lockID, l.instanceID, l.leaseDuration) if err != nil { // TODO(mf): should we add a retry policy here? l.logger.WarnContext(ctx, "heartbeat failed to update lease", slog.Int64("lock_id", l.lockID), slog.String("instance_id", l.instanceID), slog.Any("error", err), ) continue } l.logger.DebugContext(ctx, "heartbeat updated lease", slog.Int64("lock_id", l.lockID), slog.String("instance_id", l.instanceID), slog.Time("lease_expires_at", result.LeaseExpiresAt), ) } } }() } // stopHeartbeat stops the heartbeat goroutine (called from within Unlock with mutex held). func (l *Locker) stopHeartbeat() { if l.heartbeatCancel != nil { l.heartbeatCancel() <-l.heartbeatDone l.heartbeatCancel = nil l.heartbeatDone = nil } } // shouldRetry determines whether an error is retryable based on the configured retry policy. If no // retry policy is configured, it defaults to always retrying. func (l *Locker) shouldRetry(err error) bool { if l.retryPolicy != nil { return l.retryPolicy(err) } return true } func randomHex(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return fmt.Sprintf("%0*x", n*2, time.Now().UnixNano()) } return hex.EncodeToString(b) } ================================================ FILE: lock/locker.go ================================================ // Package lock defines the Locker interface and implements the locking logic. package lock import ( "context" "database/sql" "errors" ) var ( // ErrLockNotImplemented is returned when the database does not support locking. ErrLockNotImplemented = errors.New("lock not implemented") // ErrUnlockNotImplemented is returned when the database does not support unlocking. ErrUnlockNotImplemented = errors.New("unlock not implemented") ) // SessionLocker is the interface to lock and unlock the database for the duration of a session. The // session is defined as the duration of a single connection and both methods must be called on the // same connection. type SessionLocker interface { SessionLock(ctx context.Context, conn *sql.Conn) error SessionUnlock(ctx context.Context, conn *sql.Conn) error } // Locker is the interface to lock and unlock the database. // // Unlike [SessionLocker], the Lock and Unlock methods are called on a [*sql.DB] and do not require // the same connection to be used for both methods. type Locker interface { Lock(ctx context.Context, db *sql.DB) error Unlock(ctx context.Context, db *sql.DB) error } ================================================ FILE: lock/locktesting/locktesting.go ================================================ package locktesting import ( "context" "database/sql" "fmt" "sync" "testing" "time" "github.com/pressly/goose/v3" "github.com/pressly/goose/v3/lock" "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" ) // TestProviderLocking is a reusable test helper that verifies locking behavior. It creates the a // bunch of providers with the same locker configuration and verifies that only one provider can // apply migrations at a time. // // The test verifies the core locking contract: // // 1. Only one provider should apply migrations when running concurrently // 2. The other providers should apply zero migrations (blocked by lock) // 3. All providers should complete without errors // 4. Final database state should be consistent func TestProviderLocking( t *testing.T, newProvider func(*testing.T) *goose.Provider, ) { t.Helper() // Number of concurrent providers to test const count = 5 // Create providers providers := make([]*goose.Provider, count) for i := range count { providers[i] = newProvider(t) } // Sanity check - ensure providers have migration sources sources := providers[0].ListSources() require.NotEmpty(t, sources, "no migration sources found - check provider fsys") maxVersion := sources[len(sources)-1].Version // Ensure all providers have the same sources for _, p := range providers { require.Equal(t, sources, p.ListSources(), "providers have different migration sources") } // Since locking is enabled, only one of these providers should apply ALL the migrations. The // other providers should apply NO migrations. var g errgroup.Group results := make([]int, count) for i := range count { g.Go(func() error { ctx := context.Background() migrationResults, err := providers[i].Up(ctx) if err != nil { return err } results[i] = len(migrationResults) // Useful for debugging: // // t.Logf("Provider %d applied %d migrations", i, len(migrationResults)) currentVersion, err := providers[i].GetDBVersion(ctx) if err != nil { return err } if currentVersion != maxVersion { return fmt.Errorf("provider %d: expected version %d, got %d", i, maxVersion, currentVersion) } return nil }) } require.NoError(t, g.Wait()) // Verify locking behavior: exactly one provider should have done all the work var ( providersWithWork = 0 providerWithAllWork = -1 // Index of provider that did all the work ) for i, res := range results { if res > 0 { providersWithWork++ if res == len(sources) { providerWithAllWork = i } } } // Verify exactly one provider did work require.Equal(t, 1, providersWithWork, "exactly one provider should apply migrations - locking is not working") // Verify that provider did all the work require.NotEqual(t, -1, providerWithAllWork, "one provider should have applied all migrations - locking is not working") // Verify all others did no work for i, res := range results { if i != providerWithAllWork { require.Equal(t, 0, res, "provider%d should have applied 0 migrations", i) } } } // TestConcurrentLocking is a reusable test helper that verifies concurrent locker behavior. It // creates the specified number of lockers using the factory function and verifies that only one // locker can acquire the lock at a time. // // IMPORTANT: The newLocker function MUST create lockers that compete for the SAME lock resource. // For table-based lockers, this means using the same lock ID. For advisory locks, the same lock ID. // If each locker targets a different resource, multiple lockers will succeed (which breaks the // test). // // The test verifies the core locking contract: // // 1. Only one locker should successfully acquire the lock when running concurrently // 2. The other lockers should fail to acquire the lock (blocked/timeout) // 3. All lockers should complete without hanging func TestConcurrentLocking( t *testing.T, db *sql.DB, newLocker func(*testing.T) lock.Locker, lockTimeout time.Duration, ) { t.Helper() ctx := context.Background() // TODO(mf): I wonder if there's a better way to do logging in tests that conditionally enables // it. Maybe using testing.T.Log? But that doesn't have levels. Maybe use a global flag to // enable debug logging in tests? // logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) // logger := slog.New(slog.DiscardHandler) // Number of concurrent lockers to test const count = 5 lockers := make([]lock.Locker, count) for i := range count { lockers[i] = newLocker(t) } // Use buffered channel to collect successful lock acquisitions successCh := make(chan int, count) var wg sync.WaitGroup // Start multiple goroutines trying to acquire the same lock for i := range count { wg.Go(func() { ctx, cancel := context.WithTimeout(ctx, lockTimeout) defer cancel() // Try to acquire the lock if err := lockers[i].Lock(ctx, db); err != nil { // logger.Debug("Locker failed to acquire lock", slog.Int("locker", i), slog.String("error", err.Error())) return } successCh <- i // logger.Debug("Locker acquired lock", slog.Int("locker", i)) // Hold the lock long enough for all other goroutines to exhaust their retries. This // ensures only ONE locker succeeds in the concurrent test time.Sleep(lockTimeout * 2) // Release the lock if err := lockers[i].Unlock(ctx, db); err != nil { t.Errorf("Locker %d failed to release lock: %v", i, err) } // } else { // logger.Debug("Locker released lock", slog.Int("locker", i)) // } }) } // Wait for all goroutines with timeout done := make(chan struct{}) go func() { wg.Wait() close(done) }() select { case <-done: // Test completed normally case <-time.After(lockTimeout + 5*time.Second): t.Fatal("Test timed out - lockers took too long") } // Collect results from channel close(successCh) var successful []int for id := range successCh { successful = append(successful, id) } require.Len(t, successful, 1, "Exactly one locker should acquire the lock") // logger.Debug("Concurrent locking test passed", slog.Int("winning_locker", successful[0])) } ================================================ FILE: lock/postgres.go ================================================ package lock import ( "context" "database/sql" "errors" "fmt" "time" "github.com/pressly/goose/v3/lock/internal/store" "github.com/pressly/goose/v3/lock/internal/table" "github.com/sethvargo/go-retry" ) // NewPostgresTableLocker returns a Locker that uses PostgreSQL table-based locking. It manages a // single lock row and keeps the lock alive automatically. // // Default behavior: // // - Lease (30s): How long the lock is valid if heartbeat stops // - Heartbeat (5s): How often the lock gets refreshed to keep it alive // - If the process dies, others can take the lock after lease expires // // Defaults: // // Table: "goose_lock" // Lock ID: 4097083626 (crc64 of "goose") // Lock retry: 5s intervals, 5min timeout // Unlock retry: 2s intervals, 1min timeout // // Lock and Unlock both retry on failure. Lock stays alive automatically until released. All // defaults can be overridden with options. func NewPostgresTableLocker(options ...TableLockerOption) (Locker, error) { config := table.Config{ TableName: DefaultLockTableName, LockID: DefaultLockID, LeaseDuration: 30 * time.Second, HeartbeatInterval: 5 * time.Second, LockTimeout: table.ProbeConfig{ IntervalDuration: 5 * time.Second, FailureThreshold: 60, // 5 minutes total }, UnlockTimeout: table.ProbeConfig{ IntervalDuration: 2 * time.Second, FailureThreshold: 30, // 1 minute total }, } for _, opt := range options { if err := opt.apply(&config); err != nil { return nil, err } } lockStore, err := store.NewPostgres(config.TableName) if err != nil { return nil, fmt.Errorf("create lock store: %w", err) } return table.New(lockStore, config), nil } // NewPostgresSessionLocker returns a SessionLocker that utilizes PostgreSQL's exclusive // session-level advisory lock mechanism. // // This function creates a SessionLocker that can be used to acquire and release a lock for // synchronization purposes. The lock acquisition is retried until it is successfully acquired or // until the failure threshold is reached. The default lock duration is set to 5 minutes, and the // default unlock duration is set to 1 minute. // // If you have long running migrations, you may want to increase the lock duration. // // See [SessionLockerOption] for options that can be used to configure the SessionLocker. func NewPostgresSessionLocker(opts ...SessionLockerOption) (SessionLocker, error) { cfg := sessionLockerConfig{ lockID: DefaultLockID, lockProbe: probe{ intervalDuration: 5 * time.Second, failureThreshold: 60, }, unlockProbe: probe{ intervalDuration: 2 * time.Second, failureThreshold: 30, }, } for _, opt := range opts { if err := opt.apply(&cfg); err != nil { return nil, err } } return &postgresSessionLocker{ lockID: cfg.lockID, retryLock: retry.WithMaxRetries( cfg.lockProbe.failureThreshold, retry.NewConstant(cfg.lockProbe.intervalDuration), ), retryUnlock: retry.WithMaxRetries( cfg.unlockProbe.failureThreshold, retry.NewConstant(cfg.unlockProbe.intervalDuration), ), }, nil } type postgresSessionLocker struct { lockID int64 retryLock retry.Backoff retryUnlock retry.Backoff } var _ SessionLocker = (*postgresSessionLocker)(nil) func (l *postgresSessionLocker) SessionLock(ctx context.Context, conn *sql.Conn) error { return retry.Do(ctx, l.retryLock, func(ctx context.Context) error { row := conn.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1)", l.lockID) var locked bool if err := row.Scan(&locked); err != nil { return fmt.Errorf("failed to execute pg_try_advisory_lock: %w", err) } if locked { // A session-level advisory lock was acquired. return nil } // A session-level advisory lock could not be acquired. This is likely because another // process has already acquired the lock. We will continue retrying until the lock is // acquired or the maximum number of retries is reached. return retry.RetryableError(errors.New("failed to acquire lock")) }) } func (l *postgresSessionLocker) SessionUnlock(ctx context.Context, conn *sql.Conn) error { return retry.Do(ctx, l.retryUnlock, func(ctx context.Context) error { var unlocked bool row := conn.QueryRowContext(ctx, "SELECT pg_advisory_unlock($1)", l.lockID) if err := row.Scan(&unlocked); err != nil { return fmt.Errorf("failed to execute pg_advisory_unlock: %w", err) } if unlocked { // A session-level advisory lock was released. return nil } /* docs(md): provide users with some documentation on how they can unlock the session manually. This is probably not an issue for 99.99% of users since pg_advisory_unlock_all() will release all session level advisory locks held by the current session. It is implicitly invoked at session end, even if the client disconnects ungracefully. Here is output from a session that has a lock held: SELECT pid, granted, ((classid::bigint << 32) | objid::bigint) AS goose_lock_id FROM pg_locks WHERE locktype = 'advisory'; | pid | granted | goose_lock_id | |-----|---------|---------------------| | 191 | t | 4097083626 | A forceful way to unlock the session is to terminate the backend with SIGTERM: SELECT pg_terminate_backend(191); Subsequent commands on the same connection will fail with: Query 1 ERROR: FATAL: terminating connection due to administrator command */ return retry.RetryableError(errors.New("failed to unlock session")) }) } ================================================ FILE: lock/session_locker_options.go ================================================ package lock import ( "errors" "time" ) const ( // DefaultLockID is the id used to lock the database for migrations. It is a crc64 hash of the // string "goose". This is used to ensure that the lock is unique to goose. // // crc32.Checksum([]byte("goose"), crc32.MakeTable(crc32.IEEE)) DefaultLockID int64 = 4097083626 ) // SessionLockerOption is used to configure a SessionLocker. type SessionLockerOption interface { apply(*sessionLockerConfig) error } // WithLockID sets the lock ID to use when locking the database. // // If WithLockID is not called, the DefaultLockID is used. func WithLockID(lockID int64) SessionLockerOption { return sessionLockerConfigFunc(func(c *sessionLockerConfig) error { c.lockID = lockID return nil }) } // WithLockTimeout sets the max duration to wait for the lock to be acquired. The total duration // will be the period times the failure threshold. // // By default, the lock timeout is 300s (5min), where the lock is retried every 5 seconds (period) // up to 60 times (failure threshold). // // The minimum period is 1 second, and the minimum failure threshold is 1. func WithLockTimeout(period, failureThreshold uint64) SessionLockerOption { return sessionLockerConfigFunc(func(c *sessionLockerConfig) error { if period < 1 { return errors.New("period must be greater than 0, minimum is 1") } if failureThreshold < 1 { return errors.New("failure threshold must be greater than 0, minimum is 1") } c.lockProbe = probe{ intervalDuration: time.Duration(period) * time.Second, failureThreshold: failureThreshold, } return nil }) } // WithUnlockTimeout sets the max duration to wait for the lock to be released. The total duration // will be the period times the failure threshold. // // By default, the lock timeout is 60s, where the lock is retried every 2 seconds (period) up to 30 // times (failure threshold). // // The minimum period is 1 second, and the minimum failure threshold is 1. func WithUnlockTimeout(period, failureThreshold uint64) SessionLockerOption { return sessionLockerConfigFunc(func(c *sessionLockerConfig) error { if period < 1 { return errors.New("period must be greater than 0, minimum is 1") } if failureThreshold < 1 { return errors.New("failure threshold must be greater than 0, minimum is 1") } c.unlockProbe = probe{ intervalDuration: time.Duration(period) * time.Second, failureThreshold: failureThreshold, } return nil }) } type sessionLockerConfig struct { lockID int64 lockProbe probe unlockProbe probe } // probe is used to configure how often and how many times to retry a lock or unlock operation. The // total timeout will be the period times the failure threshold. type probe struct { // How often (in seconds) to perform the probe. intervalDuration time.Duration // Number of times to retry the probe. failureThreshold uint64 } var _ SessionLockerOption = (sessionLockerConfigFunc)(nil) type sessionLockerConfigFunc func(*sessionLockerConfig) error func (f sessionLockerConfigFunc) apply(cfg *sessionLockerConfig) error { return f(cfg) } ================================================ FILE: lock/table_locker_options.go ================================================ package lock import ( "errors" "fmt" "log/slog" "time" "github.com/pressly/goose/v3/lock/internal/table" ) const ( // DefaultLockTableName is the default name of the lock table. DefaultLockTableName = "goose_lock" ) // TableLockerOption is used to configure a table-based locker. type TableLockerOption interface { apply(*table.Config) error } // WithTableName sets the name of the lock table. func WithTableName(tableName string) TableLockerOption { return tableLockerConfigFunc(func(c *table.Config) error { if tableName == "" { return errors.New("lock table name must not be empty") } c.TableName = tableName return nil }) } // WithTableLockID sets the lock ID to use for this locker instance. Different lock IDs allow for // multiple independent locks in the same table. func WithTableLockID(lockID int64) TableLockerOption { return tableLockerConfigFunc(func(c *table.Config) error { if lockID <= 0 { return fmt.Errorf("lock ID must be greater than zero: %d", lockID) } c.LockID = lockID return nil }) } // WithTableLeaseDuration sets how long a lock lease lasts. The lock will expire after this duration // if not renewed by heartbeat. func WithTableLeaseDuration(duration time.Duration) TableLockerOption { return tableLockerConfigFunc(func(c *table.Config) error { if duration <= 0 { return errors.New("lease duration must be positive") } c.LeaseDuration = duration return nil }) } // WithTableHeartbeatInterval sets how often to send heartbeat updates to renew the lease. This // should be significantly smaller than the lease duration. func WithTableHeartbeatInterval(interval time.Duration) TableLockerOption { return tableLockerConfigFunc(func(c *table.Config) error { if interval <= 0 { return errors.New("heartbeat interval must be positive") } c.HeartbeatInterval = interval return nil }) } // WithTableLockTimeout configures how long to retry acquiring a lock and how often to retry. func WithTableLockTimeout(intervalDuration time.Duration, failureThreshold uint64) TableLockerOption { return tableLockerConfigFunc(func(c *table.Config) error { if intervalDuration <= 0 { return errors.New("lock timeout interval duration must be positive") } if failureThreshold == 0 { return errors.New("lock timeout failure threshold must be positive") } c.LockTimeout = table.ProbeConfig{ IntervalDuration: intervalDuration, FailureThreshold: failureThreshold, } return nil }) } // WithTableUnlockTimeout configures how long to retry releasing a lock and how often to retry. func WithTableUnlockTimeout(intervalDuration time.Duration, failureThreshold uint64) TableLockerOption { return tableLockerConfigFunc(func(c *table.Config) error { if intervalDuration <= 0 { return errors.New("unlock timeout interval duration must be positive") } if failureThreshold == 0 { return errors.New("unlock timeout failure threshold must be positive") } c.UnlockTimeout = table.ProbeConfig{ IntervalDuration: intervalDuration, FailureThreshold: failureThreshold, } return nil }) } // WithTableLogger sets an optional logger for lock operations. If not provided, lock operations // will use a default logger that only logs errors to stderr. func WithTableLogger(logger *slog.Logger) TableLockerOption { return tableLockerConfigFunc(func(c *table.Config) error { c.Logger = logger return nil }) } // WithTableRetryPolicy sets an optional callback to classify database errors during table lock // operations. // // The provided function is invoked whenever a database operation fails. This includes Lock(), // Unlock(), and heartbeat/lease update operations. // // If the function returns true, the operation is retried according to the configured retry/backoff // policy. // // If it returns false, the operation fails immediately, bypassing any retries. // // This allows clients to implement custom logic for transient errors, driver-specific errors, or // application-specific failure handling. func WithTableRetryPolicy(retryPolicy func(error) bool) TableLockerOption { return tableLockerConfigFunc(func(c *table.Config) error { c.RetryPolicy = retryPolicy return nil }) } type tableLockerConfigFunc func(*table.Config) error func (f tableLockerConfigFunc) apply(cfg *table.Config) error { return f(cfg) } ================================================ FILE: lock/table_locker_options_test.go ================================================ package lock import ( "testing" "time" "github.com/stretchr/testify/require" ) func TestTableLockerOptions(t *testing.T) { // Test that options are applied correctly locker, err := NewPostgresTableLocker( WithTableName("custom_locks"), WithTableLockID(999), WithTableLeaseDuration(10*time.Second), WithTableHeartbeatInterval(3*time.Second), ) require.NoError(t, err) require.NotNil(t, locker) // Test invalid lease duration _, err = NewPostgresTableLocker(WithTableLeaseDuration(-1 * time.Second)) require.Error(t, err) // Test invalid heartbeat interval _, err = NewPostgresTableLocker(WithTableHeartbeatInterval(0)) require.Error(t, err) // Test empty table name _, err = NewPostgresTableLocker(WithTableName("")) require.Error(t, err) // Test invalid lock ID _, err = NewPostgresTableLocker(WithTableLockID(0)) require.Error(t, err) // Test invalid lock timeout interval duration _, err = NewPostgresTableLocker(WithTableLockTimeout(0, 10)) require.Error(t, err) // Test invalid lock timeout failure threshold _, err = NewPostgresTableLocker(WithTableLockTimeout(5*time.Second, 0)) require.Error(t, err) // Test invalid unlock timeout interval duration _, err = NewPostgresTableLocker(WithTableUnlockTimeout(0, 10)) require.Error(t, err) // Test invalid unlock timeout failure threshold _, err = NewPostgresTableLocker(WithTableUnlockTimeout(5*time.Second, 0)) require.Error(t, err) } ================================================ FILE: log.go ================================================ package goose import ( std "log" ) var log Logger = &stdLogger{} // Logger is standard logger interface type Logger interface { Fatalf(format string, v ...any) Printf(format string, v ...any) } // SetLogger sets the logger for package output func SetLogger(l Logger) { log = l } // stdLogger is a default logger that outputs to a stdlib's log.std logger. type stdLogger struct{} func (*stdLogger) Fatalf(format string, v ...any) { std.Fatalf(format, v...) } func (*stdLogger) Printf(format string, v ...any) { std.Printf(format, v...) } // NopLogger returns a logger that discards all logged output. func NopLogger() Logger { return &nopLogger{} } type nopLogger struct{} var _ Logger = (*nopLogger)(nil) func (*nopLogger) Fatalf(format string, v ...any) {} func (*nopLogger) Printf(format string, v ...any) {} ================================================ FILE: migrate.go ================================================ package goose import ( "context" "database/sql" "errors" "fmt" "io/fs" "math" "path" "sort" "strings" "time" "go.uber.org/multierr" ) var ( // ErrNoMigrationFiles when no migration files have been found. ErrNoMigrationFiles = errors.New("no migration files found") // ErrNoCurrentVersion when a current migration version is not found. ErrNoCurrentVersion = errors.New("no current version found") // ErrNoNextVersion when the next migration version is not found. ErrNoNextVersion = errors.New("no next version found") // MaxVersion is the maximum allowed version. MaxVersion int64 = math.MaxInt64 ) // Migrations slice. type Migrations []*Migration // helpers so we can use pkg sort func (ms Migrations) Len() int { return len(ms) } func (ms Migrations) Swap(i, j int) { ms[i], ms[j] = ms[j], ms[i] } func (ms Migrations) Less(i, j int) bool { if ms[i].Version == ms[j].Version { panic(fmt.Sprintf("goose: duplicate version %v detected:\n%v\n%v", ms[i].Version, ms[i].Source, ms[j].Source)) } return ms[i].Version < ms[j].Version } // Current gets the current migration. func (ms Migrations) Current(current int64) (*Migration, error) { for i, migration := range ms { if migration.Version == current { return ms[i], nil } } return nil, ErrNoCurrentVersion } // Next gets the next migration. func (ms Migrations) Next(current int64) (*Migration, error) { for i, migration := range ms { if migration.Version > current { return ms[i], nil } } return nil, ErrNoNextVersion } // Previous : Get the previous migration. func (ms Migrations) Previous(current int64) (*Migration, error) { for i := len(ms) - 1; i >= 0; i-- { if ms[i].Version < current { return ms[i], nil } } return nil, ErrNoNextVersion } // Last gets the last migration. func (ms Migrations) Last() (*Migration, error) { if len(ms) == 0 { return nil, ErrNoNextVersion } return ms[len(ms)-1], nil } // Versioned gets versioned migrations. func (ms Migrations) versioned() (Migrations, error) { var migrations Migrations // assume that the user will never have more than 19700101000000 migrations for _, m := range ms { // parse version as timestamp versionTime, err := time.Parse(timestampFormat, fmt.Sprintf("%d", m.Version)) if versionTime.Before(time.Unix(0, 0)) || err != nil { migrations = append(migrations, m) } } return migrations, nil } // Timestamped gets the timestamped migrations. func (ms Migrations) timestamped() (Migrations, error) { var migrations Migrations // assume that the user will never have more than 19700101000000 migrations for _, m := range ms { // parse version as timestamp versionTime, err := time.Parse(timestampFormat, fmt.Sprintf("%d", m.Version)) if err != nil { // probably not a timestamp continue } if versionTime.After(time.Unix(0, 0)) { migrations = append(migrations, m) } } return migrations, nil } func (ms Migrations) String() string { var str strings.Builder for _, m := range ms { fmt.Fprintln(&str, m) } return str.String() } func collectMigrationsFS( fsys fs.FS, dirpath string, current, target int64, registered map[int64]*Migration, ) (Migrations, error) { if _, err := fs.Stat(fsys, dirpath); err != nil { if errors.Is(err, fs.ErrNotExist) { return nil, fmt.Errorf("%s directory does not exist", dirpath) } return nil, err } var migrations Migrations // SQL migration files. sqlMigrationFiles, err := fs.Glob(fsys, path.Join(dirpath, "*.sql")) if err != nil { return nil, err } for _, file := range sqlMigrationFiles { v, err := NumericComponent(file) if err != nil { return nil, fmt.Errorf("could not parse SQL migration file %q: %w", file, err) } if versionFilter(v, current, target) { migrations = append(migrations, &Migration{ Version: v, Next: -1, Previous: -1, Source: file, }) } } // Go migration files. goMigrations, err := collectGoMigrations(fsys, dirpath, registered, current, target) if err != nil { return nil, err } migrations = append(migrations, goMigrations...) if len(migrations) == 0 { return nil, ErrNoMigrationFiles } return sortAndConnectMigrations(migrations), nil } // CollectMigrations returns all the valid looking migration scripts in the // migrations folder and go func registry, and key them by version. func CollectMigrations(dirpath string, current, target int64) (Migrations, error) { return collectMigrationsFS(baseFS, dirpath, current, target, registeredGoMigrations) } func sortAndConnectMigrations(migrations Migrations) Migrations { sort.Sort(migrations) // now that we're sorted in the appropriate direction, // populate next and previous for each migration for i, m := range migrations { prev := int64(-1) if i > 0 { prev = migrations[i-1].Version migrations[i-1].Next = m.Version } migrations[i].Previous = prev } return migrations } func versionFilter(v, current, target int64) bool { if target > current { return v > current && v <= target } if target < current { return v <= current && v > target } return false } // EnsureDBVersion retrieves the current version for this DB. // Create and initialize the DB version table if it doesn't exist. func EnsureDBVersion(db *sql.DB) (int64, error) { ctx := context.Background() return EnsureDBVersionContext(ctx, db) } // EnsureDBVersionContext retrieves the current version for this DB. // Create and initialize the DB version table if it doesn't exist. func EnsureDBVersionContext(ctx context.Context, db *sql.DB) (int64, error) { dbMigrations, err := store.ListMigrations(ctx, db, TableName()) if err != nil { createErr := createVersionTable(ctx, db) if createErr != nil { return 0, multierr.Append(err, createErr) } return 0, nil } // The most recent record for each migration specifies // whether it has been applied or rolled back. // The first version we find that has been applied is the current version. // // TODO(mf): for historic reasons, we continue to use the is_applied column, // but at some point we need to deprecate this logic and ideally remove // this column. // // For context, see: // https://github.com/pressly/goose/pull/131#pullrequestreview-178409168 // // The dbMigrations list is expected to be ordered by descending ID. But // in the future we should be able to query the last record only. skipLookup := make(map[int64]struct{}) for _, m := range dbMigrations { // Have we already marked this version to be skipped? if _, ok := skipLookup[m.VersionID]; ok { continue } // If version has been applied we are done. if m.IsApplied { return m.VersionID, nil } // Latest version of migration has not been applied. skipLookup[m.VersionID] = struct{}{} } return 0, ErrNoNextVersion } // createVersionTable creates the db version table and inserts the // initial 0 value into it. func createVersionTable(ctx context.Context, db *sql.DB) error { txn, err := db.BeginTx(ctx, nil) if err != nil { return err } if err := store.CreateVersionTable(ctx, txn, TableName()); err != nil { _ = txn.Rollback() return err } if err := store.InsertVersion(ctx, txn, TableName(), 0); err != nil { _ = txn.Rollback() return err } return txn.Commit() } // GetDBVersion is an alias for EnsureDBVersion, but returns -1 in error. func GetDBVersion(db *sql.DB) (int64, error) { ctx := context.Background() return GetDBVersionContext(ctx, db) } // GetDBVersionContext is an alias for EnsureDBVersion, but returns -1 in error. func GetDBVersionContext(ctx context.Context, db *sql.DB) (int64, error) { version, err := EnsureDBVersionContext(ctx, db) if err != nil { return -1, err } return version, nil } // collectGoMigrations collects Go migrations from the filesystem and merges them with registered // migrations. // // If Go migrations have been registered globally, with [goose.AddNamedMigration...], but there are // no corresponding .go files in the filesystem, add them to the migrations slice. // // If Go migrations have been registered, and there are .go files in the filesystem dirpath, ONLY // include those in the migrations slices. // // Lastly, if there are .go files in the filesystem but they have not been registered, raise an // error. This is to prevent users from accidentally adding valid looking Go files to the migrations // folder without registering them. func collectGoMigrations( fsys fs.FS, dirpath string, registeredGoMigrations map[int64]*Migration, current, target int64, ) (Migrations, error) { // Sanity check registered migrations have the correct version prefix. for _, m := range registeredGoMigrations { if _, err := NumericComponent(m.Source); err != nil { return nil, fmt.Errorf("could not parse go migration file %s: %w", m.Source, err) } } goFiles, err := fs.Glob(fsys, path.Join(dirpath, "*.go")) if err != nil { return nil, err } // If there are no Go files in the filesystem and no registered Go migrations, return early. if len(goFiles) == 0 && len(registeredGoMigrations) == 0 { return nil, nil } type source struct { fullpath string version int64 } // Find all Go files that have a version prefix and are within the requested range. var sources []source for _, fullpath := range goFiles { v, err := NumericComponent(fullpath) if err != nil { continue // Skip any files that don't have version prefix. } if strings.HasSuffix(fullpath, "_test.go") { continue // Skip Go test files. } if versionFilter(v, current, target) { sources = append(sources, source{ fullpath: fullpath, version: v, }) } } var ( migrations Migrations ) if len(sources) > 0 { for _, s := range sources { migration, ok := registeredGoMigrations[s.version] if ok { migrations = append(migrations, migration) } else { // TODO(mf): something that bothers me about this implementation is it will be // lazily evaluated and the error will only be raised if the user tries to run the // migration. It would be better to raise an error much earlier in the process. migrations = append(migrations, &Migration{ Version: s.version, Next: -1, Previous: -1, Source: s.fullpath, Registered: false, }) } } } else { // Some users may register Go migrations manually via AddNamedMigration_ functions but not // provide the corresponding .go files in the filesystem. In this case, we include them // wholesale in the migrations slice. // // This is a valid use case because users may want to build a custom binary that only embeds // the SQL migration files and some other mechanism for registering Go migrations. for _, migration := range registeredGoMigrations { v, err := NumericComponent(migration.Source) if err != nil { return nil, fmt.Errorf("could not parse go migration file %s: %w", migration.Source, err) } if versionFilter(v, current, target) { migrations = append(migrations, migration) } } } return migrations, nil } ================================================ FILE: migrate_test.go ================================================ package goose import ( "io/fs" "math" "os" "path/filepath" "testing" "github.com/stretchr/testify/require" ) func TestMigrationSort(t *testing.T) { t.Parallel() ms := Migrations{} // insert in any order ms = append(ms, newMigration(20120000, "test")) ms = append(ms, newMigration(20128000, "test")) ms = append(ms, newMigration(20129000, "test")) ms = append(ms, newMigration(20127000, "test")) ms = sortAndConnectMigrations(ms) sorted := []int64{20120000, 20127000, 20128000, 20129000} validateMigrationSort(t, ms, sorted) } func newMigration(v int64, src string) *Migration { return &Migration{Version: v, Previous: -1, Next: -1, Source: src} } func validateMigrationSort(t *testing.T, ms Migrations, sorted []int64) { t.Helper() for i, m := range ms { if sorted[i] != m.Version { t.Error("incorrect sorted version") } var next, prev int64 if i == 0 { prev = -1 next = ms[i+1].Version } else if i == len(ms)-1 { prev = ms[i-1].Version next = -1 } else { prev = ms[i-1].Version next = ms[i+1].Version } if m.Next != next { t.Errorf("mismatched Next. v: %v, got %v, wanted %v\n", m, m.Next, next) } if m.Previous != prev { t.Errorf("mismatched Previous v: %v, got %v, wanted %v\n", m, m.Previous, prev) } } t.Log(ms) } func TestCollectMigrations(t *testing.T) { // Not safe to run in parallel t.Run("no_migration_files_found", func(t *testing.T) { tmp := t.TempDir() err := os.MkdirAll(filepath.Join(tmp, "migrations-test"), 0755) require.NoError(t, err) _, err = collectMigrationsFS(os.DirFS(tmp), "migrations-test", 0, math.MaxInt64, nil) require.Error(t, err) require.Contains(t, err.Error(), "no migration files found") }) t.Run("filesystem_registered_with_single_dirpath", func(t *testing.T) { t.Cleanup(func() { clearMap(registeredGoMigrations) }) file1, file2 := "09081_a.go", "09082_b.go" file3, file4 := "19081_a.go", "19082_b.go" AddNamedMigrationContext(file1, nil, nil) AddNamedMigrationContext(file2, nil, nil) require.Len(t, registeredGoMigrations, 2) tmp := t.TempDir() dir := filepath.Join(tmp, "migrations", "dir1") err := os.MkdirAll(dir, 0755) require.NoError(t, err) createEmptyFile(t, dir, file1) createEmptyFile(t, dir, file2) createEmptyFile(t, dir, file3) createEmptyFile(t, dir, file4) fsys := os.DirFS(tmp) files, err := fs.ReadDir(fsys, "migrations/dir1") require.NoError(t, err) require.Len(t, files, 4) all, err := collectMigrationsFS(fsys, "migrations/dir1", 0, math.MaxInt64, registeredGoMigrations) require.NoError(t, err) require.Len(t, all, 4) require.EqualValues(t, 9081, all[0].Version) require.EqualValues(t, 9082, all[1].Version) require.EqualValues(t, 19081, all[2].Version) require.EqualValues(t, 19082, all[3].Version) }) t.Run("filesystem_registered_with_multiple_dirpath", func(t *testing.T) { t.Cleanup(func() { clearMap(registeredGoMigrations) }) file1, file2, file3 := "00001_a.go", "00002_b.go", "01111_c.go" AddNamedMigrationContext(file1, nil, nil) AddNamedMigrationContext(file2, nil, nil) AddNamedMigrationContext(file3, nil, nil) require.Len(t, registeredGoMigrations, 3) tmp := t.TempDir() dir1 := filepath.Join(tmp, "migrations", "dir1") dir2 := filepath.Join(tmp, "migrations", "dir2") err := os.MkdirAll(dir1, 0755) require.NoError(t, err) err = os.MkdirAll(dir2, 0755) require.NoError(t, err) createEmptyFile(t, dir1, file1) createEmptyFile(t, dir1, file2) createEmptyFile(t, dir2, file3) fsys := os.DirFS(tmp) // Validate if dirpath 1 is specified we get the two Go migrations in migrations/dir1 folder // even though 3 Go migrations have been registered. { all, err := collectMigrationsFS(fsys, "migrations/dir1", 0, math.MaxInt64, registeredGoMigrations) require.NoError(t, err) require.Len(t, all, 2) require.EqualValues(t, 1, all[0].Version) require.EqualValues(t, 2, all[1].Version) } // Validate if dirpath 2 is specified we only get the one Go migration in migrations/dir2 folder // even though 3 Go migrations have been registered. { all, err := collectMigrationsFS(fsys, "migrations/dir2", 0, math.MaxInt64, registeredGoMigrations) require.NoError(t, err) require.Len(t, all, 1) require.EqualValues(t, 1111, all[0].Version) } }) t.Run("empty_filesystem_registered_manually", func(t *testing.T) { t.Cleanup(func() { clearMap(registeredGoMigrations) }) AddNamedMigrationContext("00101_a.go", nil, nil) AddNamedMigrationContext("00102_b.go", nil, nil) require.Len(t, registeredGoMigrations, 2) tmp := t.TempDir() err := os.MkdirAll(filepath.Join(tmp, "migrations"), 0755) require.NoError(t, err) all, err := collectMigrationsFS(os.DirFS(tmp), "migrations", 0, math.MaxInt64, registeredGoMigrations) require.NoError(t, err) require.Len(t, all, 2) require.EqualValues(t, 101, all[0].Version) require.EqualValues(t, 102, all[1].Version) }) t.Run("unregistered_go_migrations", func(t *testing.T) { t.Cleanup(func() { clearMap(registeredGoMigrations) }) file1, file2, file3 := "00001_a.go", "00998_b.go", "00999_c.go" // Only register file1 and file3, somehow user forgot to init in the // valid looking file2 Go migration AddNamedMigrationContext(file1, nil, nil) AddNamedMigrationContext(file3, nil, nil) require.Len(t, registeredGoMigrations, 2) tmp := t.TempDir() dir1 := filepath.Join(tmp, "migrations", "dir1") err := os.MkdirAll(dir1, 0755) require.NoError(t, err) // Include the valid file2 with file1, file3. But remember, it has NOT been // registered. createEmptyFile(t, dir1, file1) createEmptyFile(t, dir1, file2) createEmptyFile(t, dir1, file3) all, err := collectMigrationsFS(os.DirFS(tmp), "migrations/dir1", 0, math.MaxInt64, registeredGoMigrations) require.NoError(t, err) require.Len(t, all, 3) require.EqualValues(t, 1, all[0].Version) require.True(t, all[0].Registered) require.EqualValues(t, 998, all[1].Version) // This migrations is marked unregistered and will lazily raise an error if/when this // migration is run require.False(t, all[1].Registered) require.EqualValues(t, 999, all[2].Version) require.True(t, all[2].Registered) }) t.Run("with_skipped_go_files", func(t *testing.T) { t.Cleanup(func() { clearMap(registeredGoMigrations) }) file1, file2, file3, file4 := "00001_a.go", "00002_b.sql", "00999_c_test.go", "embed.go" AddNamedMigrationContext(file1, nil, nil) require.Len(t, registeredGoMigrations, 1) tmp := t.TempDir() dir1 := filepath.Join(tmp, "migrations", "dir1") err := os.MkdirAll(dir1, 0755) require.NoError(t, err) createEmptyFile(t, dir1, file1) createEmptyFile(t, dir1, file2) createEmptyFile(t, dir1, file3) createEmptyFile(t, dir1, file4) all, err := collectMigrationsFS(os.DirFS(tmp), "migrations/dir1", 0, math.MaxInt64, registeredGoMigrations) require.NoError(t, err) require.Len(t, all, 2) require.EqualValues(t, 1, all[0].Version) require.True(t, all[0].Registered) require.EqualValues(t, 2, all[1].Version) require.False(t, all[1].Registered) }) t.Run("current_and_target", func(t *testing.T) { t.Cleanup(func() { clearMap(registeredGoMigrations) }) file1, file2, file3 := "01001_a.go", "01002_b.sql", "01003_c.go" AddNamedMigrationContext(file1, nil, nil) AddNamedMigrationContext(file3, nil, nil) require.Len(t, registeredGoMigrations, 2) tmp := t.TempDir() dir1 := filepath.Join(tmp, "migrations", "dir1") err := os.MkdirAll(dir1, 0755) require.NoError(t, err) createEmptyFile(t, dir1, file1) createEmptyFile(t, dir1, file2) createEmptyFile(t, dir1, file3) all, err := collectMigrationsFS(os.DirFS(tmp), "migrations/dir1", 1001, 1003, registeredGoMigrations) require.NoError(t, err) require.Len(t, all, 2) require.EqualValues(t, 1002, all[0].Version) require.EqualValues(t, 1003, all[1].Version) }) } func TestVersionFilter(t *testing.T) { t.Parallel() tests := []struct { v int64 current int64 target int64 want bool }{ {2, 1, 3, true}, // v is within the range {4, 1, 3, false}, // v is outside the range {2, 3, 1, true}, // v is within the reversed range {4, 3, 1, false}, // v is outside the reversed range {3, 1, 3, true}, // v is equal to target {1, 1, 3, false}, // v is equal to current, not within the range {1, 3, 1, false}, // v is equal to current, not within the reversed range // Always return false if current equal target {1, 2, 2, false}, {2, 2, 2, false}, {3, 2, 2, false}, } for _, tc := range tests { t.Run("", func(t *testing.T) { got := versionFilter(tc.v, tc.current, tc.target) if got != tc.want { t.Errorf("versionFilter(%d, %d, %d) = %v, want %v", tc.v, tc.current, tc.target, got, tc.want) } }) } } func createEmptyFile(t *testing.T, dir, name string) { t.Helper() path := filepath.Join(dir, name) f, err := os.Create(path) require.NoError(t, err) defer f.Close() } func clearMap(m map[int64]*Migration) { for k := range m { delete(m, k) } } ================================================ FILE: migration.go ================================================ package goose import ( "context" "database/sql" "errors" "fmt" "path/filepath" "strconv" "strings" "time" "github.com/pressly/goose/v3/internal/sqlparser" ) // NewGoMigration creates a new Go migration. // // Both up and down functions may be nil, in which case the migration will be recorded in the // versions table but no functions will be run. This is useful for recording (up) or deleting (down) // a version without running any functions. See [GoFunc] for more details. func NewGoMigration(version int64, up, down *GoFunc) *Migration { m := &Migration{ Type: TypeGo, Registered: true, Version: version, Next: -1, Previous: -1, goUp: &GoFunc{Mode: TransactionEnabled}, goDown: &GoFunc{Mode: TransactionEnabled}, construct: true, } updateMode := func(f *GoFunc) *GoFunc { // infer mode from function if f.Mode == 0 { if f.RunTx != nil && f.RunDB == nil { f.Mode = TransactionEnabled } if f.RunTx == nil && f.RunDB != nil { f.Mode = TransactionDisabled } // Always default to TransactionEnabled if both functions are nil. This is the most // common use case. if f.RunDB == nil && f.RunTx == nil { f.Mode = TransactionEnabled } } return f } // To maintain backwards compatibility, we set ALL legacy functions. In a future major version, // we will remove these fields in favor of [GoFunc]. // // Note, this function does not do any validation. Validation is lazily done when the migration // is registered. if up != nil { m.goUp = updateMode(up) if up.RunDB != nil { m.UpFnNoTxContext = up.RunDB // func(context.Context, *sql.DB) error m.UpFnNoTx = withoutContext(up.RunDB) // func(*sql.DB) error } if up.RunTx != nil { m.UseTx = true m.UpFnContext = up.RunTx // func(context.Context, *sql.Tx) error m.UpFn = withoutContext(up.RunTx) // func(*sql.Tx) error } } if down != nil { m.goDown = updateMode(down) if down.RunDB != nil { m.DownFnNoTxContext = down.RunDB // func(context.Context, *sql.DB) error m.DownFnNoTx = withoutContext(down.RunDB) // func(*sql.DB) error } if down.RunTx != nil { m.UseTx = true m.DownFnContext = down.RunTx // func(context.Context, *sql.Tx) error m.DownFn = withoutContext(down.RunTx) // func(*sql.Tx) error } } return m } // Migration struct represents either a SQL or Go migration. // // Avoid constructing migrations manually, use [NewGoMigration] function. type Migration struct { Type MigrationType Version int64 // Source is the path to the .sql script or .go file. It may be empty for Go migrations that // have been registered globally and don't have a source file. Source string UpFnContext, DownFnContext GoMigrationContext UpFnNoTxContext, DownFnNoTxContext GoMigrationNoTxContext // These fields will be removed in a future major version. They are here for backwards // compatibility and are an implementation detail. Registered bool UseTx bool Next int64 // next version, or -1 if none Previous int64 // previous version, -1 if none // We still save the non-context versions in the struct in case someone is using them. Goose // does not use these internally anymore in favor of the context-aware versions. These fields // will be removed in a future major version. UpFn GoMigration // Deprecated: use UpFnContext instead. DownFn GoMigration // Deprecated: use DownFnContext instead. UpFnNoTx GoMigrationNoTx // Deprecated: use UpFnNoTxContext instead. DownFnNoTx GoMigrationNoTx // Deprecated: use DownFnNoTxContext instead. noVersioning bool // These fields are used internally by goose and users are not expected to set them. Instead, // use [NewGoMigration] to create a new go migration. construct bool goUp, goDown *GoFunc sql sqlMigration } type sqlMigration struct { // The Parsed field is used to track whether the SQL migration has been parsed. It serves as an // optimization to avoid parsing migrations that may never be needed. Typically, migrations are // incremental, and users often run only the most recent ones, making parsing of prior // migrations unnecessary in most cases. Parsed bool // Parsed must be set to true before the following fields are used. UseTx bool Up []string Down []string } // GoFunc represents a Go migration function. type GoFunc struct { // Exactly one of these must be set, or both must be nil. RunTx func(ctx context.Context, tx *sql.Tx) error // -- OR -- RunDB func(ctx context.Context, db *sql.DB) error // Mode is the transaction mode for the migration. When one of the run functions is set, the // mode will be inferred from the function and the field is ignored. Users do not need to set // this field when supplying a run function. // // If both run functions are nil, the mode defaults to TransactionEnabled. The use case for nil // functions is to record a version in the version table without invoking a Go migration // function. // // The only time this field is required is if BOTH run functions are nil AND you want to // override the default transaction mode. Mode TransactionMode } // TransactionMode represents the possible transaction modes for a migration. type TransactionMode int const ( TransactionEnabled TransactionMode = iota + 1 TransactionDisabled ) func (m TransactionMode) String() string { switch m { case TransactionEnabled: return "transaction_enabled" case TransactionDisabled: return "transaction_disabled" default: return fmt.Sprintf("unknown transaction mode (%d)", m) } } // MigrationRecord struct. // // Deprecated: unused and will be removed in a future major version. type MigrationRecord struct { VersionID int64 TStamp time.Time IsApplied bool // was this a result of up() or down() } func (m *Migration) String() string { return fmt.Sprint(m.Source) } // Up runs an up migration. func (m *Migration) Up(db *sql.DB) error { ctx := context.Background() return m.UpContext(ctx, db) } // UpContext runs an up migration. func (m *Migration) UpContext(ctx context.Context, db *sql.DB) error { if err := m.run(ctx, db, true); err != nil { return err } return nil } // Down runs a down migration. func (m *Migration) Down(db *sql.DB) error { ctx := context.Background() return m.DownContext(ctx, db) } // DownContext runs a down migration. func (m *Migration) DownContext(ctx context.Context, db *sql.DB) error { if err := m.run(ctx, db, false); err != nil { return err } return nil } func (m *Migration) run(ctx context.Context, db *sql.DB, direction bool) error { switch filepath.Ext(m.Source) { case ".sql": f, err := baseFS.Open(m.Source) if err != nil { return fmt.Errorf("ERROR %v: failed to open SQL migration file: %w", filepath.Base(m.Source), err) } defer f.Close() statements, useTx, err := sqlparser.ParseSQLMigration(f, sqlparser.FromBool(direction), verbose) if err != nil { return fmt.Errorf("ERROR %v: failed to parse SQL migration file: %w", filepath.Base(m.Source), err) } start := time.Now() if err := runSQLMigration(ctx, db, statements, useTx, m.Version, direction, m.noVersioning); err != nil { return fmt.Errorf("ERROR %v: failed to run SQL migration: %w", filepath.Base(m.Source), err) } finish := truncateDuration(time.Since(start)) if len(statements) > 0 { log.Printf("OK %s (%s)", filepath.Base(m.Source), finish) } else { log.Printf("EMPTY %s (%s)", filepath.Base(m.Source), finish) } case ".go": if !m.Registered { return fmt.Errorf("ERROR %v: failed to run Go migration: Go functions must be registered and built into a custom binary (see https://github.com/pressly/goose/tree/master/examples/go-migrations)", m.Source) } start := time.Now() var empty bool if m.UseTx { // Run go-based migration inside a tx. fn := m.DownFnContext if direction { fn = m.UpFnContext } empty = (fn == nil) if err := runGoMigration( ctx, db, fn, m.Version, direction, !m.noVersioning, ); err != nil { return fmt.Errorf("ERROR go migration: %q: %w", filepath.Base(m.Source), err) } } else { // Run go-based migration outside a tx. fn := m.DownFnNoTxContext if direction { fn = m.UpFnNoTxContext } empty = (fn == nil) if err := runGoMigrationNoTx( ctx, db, fn, m.Version, direction, !m.noVersioning, ); err != nil { return fmt.Errorf("ERROR go migration no tx: %q: %w", filepath.Base(m.Source), err) } } finish := truncateDuration(time.Since(start)) if !empty { log.Printf("OK %s (%s)", filepath.Base(m.Source), finish) } else { log.Printf("EMPTY %s (%s)", filepath.Base(m.Source), finish) } } return nil } func runGoMigrationNoTx( ctx context.Context, db *sql.DB, fn GoMigrationNoTxContext, version int64, direction bool, recordVersion bool, ) error { if fn != nil { // Run go migration function. if err := fn(ctx, db); err != nil { return fmt.Errorf("failed to run go migration: %w", err) } } if recordVersion { return insertOrDeleteVersionNoTx(ctx, db, version, direction) } return nil } func runGoMigration( ctx context.Context, db *sql.DB, fn GoMigrationContext, version int64, direction bool, recordVersion bool, ) error { if fn == nil && !recordVersion { return nil } tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } if fn != nil { // Run go migration function. if err := fn(ctx, tx); err != nil { _ = tx.Rollback() return fmt.Errorf("failed to run go migration: %w", err) } } if recordVersion { if err := insertOrDeleteVersion(ctx, tx, version, direction); err != nil { _ = tx.Rollback() return fmt.Errorf("failed to update version: %w", err) } } if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } return nil } func insertOrDeleteVersion(ctx context.Context, tx *sql.Tx, version int64, direction bool) error { if direction { return store.InsertVersion(ctx, tx, TableName(), version) } return store.DeleteVersion(ctx, tx, TableName(), version) } func insertOrDeleteVersionNoTx(ctx context.Context, db *sql.DB, version int64, direction bool) error { if direction { return store.InsertVersionNoTx(ctx, db, TableName(), version) } return store.DeleteVersionNoTx(ctx, db, TableName(), version) } // NumericComponent parses the version from the migration file name. // // XXX_descriptivename.ext where XXX specifies the version number and ext specifies the type of // migration, either .sql or .go. func NumericComponent(filename string) (int64, error) { base := filepath.Base(filename) if ext := filepath.Ext(base); ext != ".go" && ext != ".sql" { return 0, errors.New("migration file does not have .sql or .go file extension") } before, _, ok := strings.Cut(base, "_") if !ok { return 0, errors.New("no filename separator '_' found") } n, err := strconv.ParseInt(before, 10, 64) if err != nil { return 0, fmt.Errorf("failed to parse version from migration file: %s: %w", base, err) } if n < 1 { return 0, errors.New("migration version must be greater than zero") } return n, nil } func truncateDuration(d time.Duration) time.Duration { for _, v := range []time.Duration{ time.Second, time.Millisecond, time.Microsecond, } { if d > v { return d.Round(v / time.Duration(100)) } } return d } // ref returns a string that identifies the migration. This is used for logging and error messages. func (m *Migration) ref() string { return fmt.Sprintf("(type:%s,version:%d)", m.Type, m.Version) } ================================================ FILE: migration_sql.go ================================================ package goose import ( "context" "database/sql" "fmt" "regexp" ) // Run a migration specified in raw SQL. // // Sections of the script can be annotated with a special comment, // starting with "-- +goose" to specify whether the section should // be applied during an Up or Down migration // // All statements following an Up or Down annotation are grouped together // until another direction annotation is found. func runSQLMigration( ctx context.Context, db *sql.DB, statements []string, useTx bool, v int64, direction bool, noVersioning bool, ) error { if useTx { // TRANSACTION. verboseInfo("Begin transaction") tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } for _, query := range statements { verboseInfo("Executing statement: %s\n", clearStatement(query)) if _, err := tx.ExecContext(ctx, query); err != nil { verboseInfo("Rollback transaction") _ = tx.Rollback() return fmt.Errorf("failed to execute SQL query %q: %w", clearStatement(query), err) } } if !noVersioning { if direction { if err := store.InsertVersion(ctx, tx, TableName(), v); err != nil { verboseInfo("Rollback transaction") _ = tx.Rollback() return fmt.Errorf("failed to insert new goose version: %w", err) } } else { if err := store.DeleteVersion(ctx, tx, TableName(), v); err != nil { verboseInfo("Rollback transaction") _ = tx.Rollback() return fmt.Errorf("failed to delete goose version: %w", err) } } } verboseInfo("Commit transaction") if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } return nil } // NO TRANSACTION. for _, query := range statements { verboseInfo("Executing statement: %s", clearStatement(query)) if _, err := db.ExecContext(ctx, query); err != nil { return fmt.Errorf("failed to execute SQL query %q: %w", clearStatement(query), err) } } if !noVersioning { if direction { if err := store.InsertVersionNoTx(ctx, db, TableName(), v); err != nil { return fmt.Errorf("failed to insert new goose version: %w", err) } } else { if err := store.DeleteVersionNoTx(ctx, db, TableName(), v); err != nil { return fmt.Errorf("failed to delete goose version: %w", err) } } } return nil } const ( grayColor = "\033[90m" resetColor = "\033[00m" ) func verboseInfo(s string, args ...any) { if verbose { if noColor { log.Printf(s, args...) } else { log.Printf(grayColor+s+resetColor, args...) } } } var ( matchSQLComments = regexp.MustCompile(`(?m)^--.*$[\r\n]*`) matchEmptyEOL = regexp.MustCompile(`(?m)^$[\r\n]*`) // TODO: Duplicate ) func clearStatement(s string) string { s = matchSQLComments.ReplaceAllString(s, ``) return matchEmptyEOL.ReplaceAllString(s, ``) } ================================================ FILE: osfs.go ================================================ package goose import ( "io/fs" "os" "path/filepath" ) // osFS wraps functions working with os filesystem to implement fs.FS interfaces. type osFS struct{} func (osFS) Open(name string) (fs.File, error) { return os.Open(filepath.FromSlash(name)) } func (osFS) ReadDir(name string) ([]fs.DirEntry, error) { return os.ReadDir(filepath.FromSlash(name)) } func (osFS) Stat(name string) (fs.FileInfo, error) { return os.Stat(filepath.FromSlash(name)) } func (osFS) ReadFile(name string) ([]byte, error) { return os.ReadFile(filepath.FromSlash(name)) } func (osFS) Glob(pattern string) ([]string, error) { return filepath.Glob(filepath.FromSlash(pattern)) } type noopFS struct{} var _ fs.FS = noopFS{} func (f noopFS) Open(name string) (fs.File, error) { return nil, os.ErrNotExist } ================================================ FILE: pkg/dockermanage/doc.go ================================================ // Package dockermanage provides lightweight Docker container lifecycle helpers for integration // testing. // // A [Manager] wraps the native Docker client and exposes methods to start, stop, remove, and list // containers. Containers are configured through functional [Option] values such as [WithImage], // [WithContainerPortTCP], and [WithEnv]. // // After starting a container, use [Manager.WaitReady] with a custom [ReadinessFunc] to block until // the service inside the container is accepting connections. // // Every container created through this package is tagged with the [ManagedLabelKey] label, which // allows bulk operations like [Manager.StopManaged] and [Manager.RemoveManaged] to clean up all // managed containers. // // Database-specific sub-packages (e.g., postgres) provide opinionated defaults for common // databases. package dockermanage ================================================ FILE: pkg/dockermanage/dockerpostgres/postgres.go ================================================ package dockerpostgres import ( "context" "errors" "fmt" "maps" "net" "strconv" "strings" "time" "github.com/pressly/goose/v3/pkg/dockermanage" ) const ( // DefaultImage is the default PostgreSQL image. DefaultImage = "postgres:16-alpine" // DefaultDatabase is the default PostgreSQL database name. DefaultDatabase = "testdb" // DefaultUser is the default PostgreSQL user. DefaultUser = "postgres" // DefaultPassword is the default PostgreSQL password. DefaultPassword = "password1" defaultContainerPort = "5432/tcp" ) // Option configures a PostgreSQL container instance. type Option interface { apply(*config) error } type optionFunc func(*config) error func (f optionFunc) apply(cfg *config) error { return f(cfg) } type config struct { image string database string user string password string hostPort int labels map[string]string } func defaultConfig() *config { return &config{ image: DefaultImage, database: DefaultDatabase, user: DefaultUser, password: DefaultPassword, labels: map[string]string{ dockermanage.ManagedLabelKey: "postgres", }, } } // WithImage sets the PostgreSQL image. func WithImage(image string) Option { return optionFunc(func(cfg *config) error { image = strings.TrimSpace(image) if image == "" { return errors.New("image must not be empty") } cfg.image = image return nil }) } // WithDatabase sets the database name. func WithDatabase(database string) Option { return optionFunc(func(cfg *config) error { database = strings.TrimSpace(database) if database == "" { return errors.New("database must not be empty") } cfg.database = database return nil }) } // WithUser sets the database user. func WithUser(user string) Option { return optionFunc(func(cfg *config) error { user = strings.TrimSpace(user) if user == "" { return errors.New("user must not be empty") } cfg.user = user return nil }) } // WithPassword sets the database user password. func WithPassword(password string) Option { return optionFunc(func(cfg *config) error { if password == "" { return errors.New("password must not be empty") } cfg.password = password return nil }) } // WithHostPort sets a fixed host port. If unset, Docker auto-assigns one. func WithHostPort(port int) Option { return optionFunc(func(cfg *config) error { if port <= 0 || port > 65535 { return fmt.Errorf("host port must be in range 1-65535: %d", port) } cfg.hostPort = port return nil }) } // WithLabel appends a container label. func WithLabel(key, value string) Option { return optionFunc(func(cfg *config) error { key = strings.TrimSpace(key) if key == "" { return errors.New("label key must not be empty") } cfg.labels[key] = value return nil }) } // WithLabels merges labels into container labels. func WithLabels(labels map[string]string) Option { return optionFunc(func(cfg *config) error { for key, value := range maps.Clone(labels) { key = strings.TrimSpace(key) if key == "" { return errors.New("label key must not be empty") } cfg.labels[key] = value } return nil }) } // Instance represents a running PostgreSQL container. type Instance struct { Container *dockermanage.Container Database string User string Password string } // DSN returns a connection string suitable for PostgreSQL drivers. func (i *Instance) DSN() string { if i == nil || i.Container == nil { return "" } return fmt.Sprintf( "host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", i.Container.Host, i.Container.Port, i.User, i.Password, i.Database, ) } // Start starts a PostgreSQL container and waits for its TCP port to be reachable. func Start(ctx context.Context, manager *dockermanage.Manager, options ...Option) (_ *Instance, retErr error) { if manager == nil { return nil, errors.New("manager must not be nil") } cfg := defaultConfig() for _, opt := range options { if opt == nil { continue } if err := opt.apply(cfg); err != nil { return nil, err } } startOptions := []dockermanage.Option{ dockermanage.WithImage(cfg.image), dockermanage.WithContainerPort(defaultContainerPort), dockermanage.WithEnvVars([]string{ "POSTGRES_DB=" + cfg.database, "POSTGRES_USER=" + cfg.user, "POSTGRES_PASSWORD=" + cfg.password, }), dockermanage.WithLabels(cfg.labels), } if cfg.hostPort > 0 { startOptions = append(startOptions, dockermanage.WithHostPort(cfg.hostPort)) } container, err := manager.Start(ctx, startOptions...) if err != nil { return nil, err } defer func() { if retErr != nil { retErr = errors.Join(retErr, manager.Remove(ctx, container.ID)) } }() if err := manager.WaitReady(ctx, container, TCPReady); err != nil { return nil, fmt.Errorf("wait for postgres readiness: %w", err) } return &Instance{ Container: container, Database: cfg.database, User: cfg.user, Password: cfg.password, }, nil } // TCPReady checks whether the Postgres TCP port is accepting connections. func TCPReady(ctx context.Context, c *dockermanage.Container) error { if c == nil { return errors.New("container must not be nil") } dialer := net.Dialer{Timeout: 500 * time.Millisecond} conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port))) if err != nil { return err } return conn.Close() } ================================================ FILE: pkg/dockermanage/dockerpostgres/postgres_test.go ================================================ package dockerpostgres_test import ( "bytes" "context" "errors" "log/slog" "os" "testing" "github.com/jackc/pgx/v5" "github.com/pressly/goose/v3/pkg/dockermanage" "github.com/pressly/goose/v3/pkg/dockermanage/dockerpostgres" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func newManager(t *testing.T) *dockermanage.Manager { t.Helper() m, err := dockermanage.NewManager(slog.New(slog.NewTextHandler(os.Stderr, nil))) require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, m.Close()) }) return m } func TestStartAndConnect(t *testing.T) { t.Parallel() m := newManager(t) ctx := t.Context() instance, err := dockerpostgres.Start(ctx, m) require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, m.Remove(context.WithoutCancel(ctx), instance.Container.ID)) }) require.Positive(t, instance.Container.Port) require.NotEmpty(t, instance.Container.Host) // TCP readiness doesn't guarantee Postgres is accepting queries yet. Wait for a real ping. err = m.WaitReady(ctx, instance.Container, func(ctx context.Context, c *dockermanage.Container) error { conn, err := pgx.Connect(ctx, instance.DSN()) if err != nil { return err } return errors.Join(conn.Ping(ctx), conn.Close(ctx)) }) require.NoError(t, err) } func TestStartAndStop(t *testing.T) { t.Parallel() m := newManager(t) ctx := t.Context() instance, err := dockerpostgres.Start(ctx, m) require.NoError(t, err) require.NoError(t, m.Stop(ctx, instance.Container.ID)) require.NoError(t, m.Remove(ctx, instance.Container.ID)) } func TestManagedLabel(t *testing.T) { t.Parallel() m := newManager(t) ctx := t.Context() instance, err := dockerpostgres.Start(ctx, m) require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, m.Remove(context.WithoutCancel(ctx), instance.Container.ID)) }) label, ok := instance.Container.Labels[dockermanage.ManagedLabelKey] require.True(t, ok, "expected managed label to be set") require.Equal(t, "postgres", label) ids, err := m.ListManaged(ctx) require.NoError(t, err) require.Contains(t, ids, instance.Container.ID) } func TestExecPgDump(t *testing.T) { t.Parallel() m := newManager(t) ctx := t.Context() instance, err := dockerpostgres.Start(ctx, m) require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, m.Remove(context.WithoutCancel(ctx), instance.Container.ID)) }) // Wait until Postgres is actually accepting connections. err = m.WaitReady(ctx, instance.Container, func(ctx context.Context, c *dockermanage.Container) error { conn, err := pgx.Connect(ctx, instance.DSN()) if err != nil { return err } return errors.Join(conn.Ping(ctx), conn.Close(ctx)) }) require.NoError(t, err) var stdout, stderr bytes.Buffer result, err := m.Exec(ctx, instance.Container.ID, dockermanage.ExecOptions{ Cmd: []string{ "pg_dump", "-U", instance.User, "-d", instance.Database, "--schema-only", }, Stdout: &stdout, Stderr: &stderr, }) require.NoError(t, err) require.Equal(t, 0, result.ExitCode, "stderr: %s", stderr.String()) require.Contains(t, stdout.String(), "PostgreSQL database dump") require.Contains(t, stdout.String(), "PostgreSQL database dump complete") } ================================================ FILE: pkg/dockermanage/manager.go ================================================ package dockermanage import ( "context" "encoding/binary" "errors" "fmt" "io" "log/slog" "maps" "net/netip" "os" "strconv" "time" "github.com/containerd/errdefs" "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/network" "github.com/moby/moby/client" "github.com/sethvargo/go-retry" ) const ( defaultReadinessTimeout = 30 * time.Second defaultReadinessDelay = 500 * time.Millisecond ) // Container is a running Docker container managed by this package. type Container struct { ID string Image string Host string Port int Labels map[string]string } // ReadinessFunc reports whether a container is ready. type ReadinessFunc func(ctx context.Context, container *Container) error // Manager manages Docker containers using the native Docker client. type Manager struct { client *client.Client logger *slog.Logger } // NewManager creates a new manager backed by the Docker client configured from environment. func NewManager(logger *slog.Logger) (*Manager, error) { dockerClient, err := client.New( client.FromEnv, ) if err != nil { return nil, fmt.Errorf("create Docker client: %w", err) } return newManagerWithClient(dockerClient, logger), nil } func newManagerWithClient(dockerClient *client.Client, logger *slog.Logger) *Manager { if logger == nil { logger = slog.New(slog.NewTextHandler(io.Discard, nil)) } return &Manager{ client: dockerClient, logger: logger.With(slog.String("logger", "dockermanage")), } } // Start starts a container with the provided options. func (m *Manager) Start(ctx context.Context, options ...Option) (_ *Container, retErr error) { cfg := defaultConfig() cfg.pullProgress = os.Stderr for _, opt := range options { if opt == nil { continue } if err := opt.apply(cfg); err != nil { return nil, err } } if cfg.image == "" { return nil, errors.New("image is required") } if cfg.containerPort.IsZero() { return nil, errors.New("container port is required") } if err := m.pullImageIfNotExists(ctx, cfg.image, cfg.pullProgress); err != nil { return nil, fmt.Errorf("pull image %s: %w", cfg.image, err) } portBinding := network.PortBinding{HostIP: netip.MustParseAddr(cfg.hostIP)} if cfg.hostPort > 0 { portBinding.HostPort = strconv.Itoa(cfg.hostPort) } resp, err := m.client.ContainerCreate(ctx, client.ContainerCreateOptions{ Name: cfg.name, Config: &container.Config{ Image: cfg.image, Env: cfg.envVars, ExposedPorts: network.PortSet{ cfg.containerPort: struct{}{}, }, Labels: maps.Clone(cfg.labels), }, HostConfig: &container.HostConfig{ PortBindings: network.PortMap{cfg.containerPort: []network.PortBinding{portBinding}}, AutoRemove: cfg.autoRemove, }, }) if err != nil { return nil, fmt.Errorf("create container: %w", err) } defer func() { if retErr != nil { cleanupCtx := context.WithoutCancel(ctx) _, err := m.client.ContainerRemove(cleanupCtx, resp.ID, client.ContainerRemoveOptions{Force: true}) if err != nil { m.logger.Error( "remove container after start failure", slog.String("container_id", resp.ID), slog.Any("error", err), ) } } }() if _, err := m.client.ContainerStart(ctx, resp.ID, client.ContainerStartOptions{}); err != nil { return nil, fmt.Errorf("start container: %w", err) } hostPort := cfg.hostPort if hostPort == 0 { inspectResult, err := m.client.ContainerInspect(ctx, resp.ID, client.ContainerInspectOptions{}) if err != nil { return nil, fmt.Errorf("inspect container for port: %w", err) } hostPort, err = resolveBoundPort(inspectResult.Container, cfg.containerPort) if err != nil { return nil, fmt.Errorf("resolve host port: %w", err) } } m.logger.Info( "docker container started", slog.String("container_id", resp.ID), slog.String("image", cfg.image), slog.Int("port", hostPort), ) return &Container{ ID: resp.ID, Image: cfg.image, Host: cfg.hostIP, Port: hostPort, Labels: maps.Clone(cfg.labels), }, nil } func resolveBoundPort(containerJSON container.InspectResponse, containerPort network.Port) (int, error) { if containerJSON.NetworkSettings == nil { return 0, errors.New("container network settings are missing") } portBindings, ok := containerJSON.NetworkSettings.Ports[containerPort] if !ok || len(portBindings) == 0 { return 0, fmt.Errorf("no port bindings found for %s", containerPort) } for _, binding := range portBindings { if binding.HostPort == "" { continue } port, err := strconv.Atoi(binding.HostPort) if err != nil { return 0, fmt.Errorf("parse host port %q: %w", binding.HostPort, err) } return port, nil } return 0, fmt.Errorf("no host port found for %s", containerPort) } // Stop stops a running container. func (m *Manager) Stop(ctx context.Context, containerID string) error { if _, err := m.client.ContainerStop(ctx, containerID, client.ContainerStopOptions{}); err != nil { return fmt.Errorf("stop container %s: %w", containerID, err) } m.logger.Info("docker container stopped", slog.String("container_id", containerID)) return nil } // Remove removes a container. If running, it is force removed. func (m *Manager) Remove(ctx context.Context, containerID string) error { if _, err := m.client.ContainerRemove(ctx, containerID, client.ContainerRemoveOptions{Force: true}); err != nil { return fmt.Errorf("remove container %s: %w", containerID, err) } m.logger.Info("docker container removed", slog.String("container_id", containerID)) return nil } // WaitOption configures WaitReady behavior. type WaitOption func(*waitConfig) type waitConfig struct { timeout time.Duration delay time.Duration } // WithTimeout sets the maximum time to wait for readiness. Defaults to 30s. func WithTimeout(d time.Duration) WaitOption { return func(cfg *waitConfig) { cfg.timeout = d } } // WithDelay sets the interval between readiness checks. Defaults to 500ms. func WithDelay(d time.Duration) WaitOption { return func(cfg *waitConfig) { cfg.delay = d } } // WaitReady waits until a custom readiness checker succeeds. func (m *Manager) WaitReady(ctx context.Context, container *Container, readiness ReadinessFunc, opts ...WaitOption) error { if container == nil { return errors.New("container must not be nil") } if readiness == nil { return errors.New("readiness function must not be nil") } cfg := &waitConfig{ timeout: defaultReadinessTimeout, delay: defaultReadinessDelay, } for _, opt := range opts { opt(cfg) } if cfg.timeout <= 0 { return fmt.Errorf("timeout must be positive: %v", cfg.timeout) } retryCtx, cancel := context.WithTimeout(ctx, cfg.timeout) defer cancel() backoff := retry.NewConstant(cfg.delay) err := retry.Do(retryCtx, backoff, func(ctx context.Context) error { if err := readiness(ctx, container); err != nil { return retry.RetryableError(err) } return nil }) if err != nil { return fmt.Errorf("container %s did not become ready within %s: %w", container.ID, cfg.timeout, err) } m.logger.Info("docker container ready", slog.String("container_id", container.ID)) return nil } // ListManaged returns all container IDs started by this package. func (m *Manager) ListManaged(ctx context.Context) ([]string, error) { result, err := m.client.ContainerList(ctx, client.ContainerListOptions{ All: true, Filters: client.Filters{}.Add("label", ManagedLabelKey), }) if err != nil { return nil, fmt.Errorf("list managed containers: %w", err) } ids := make([]string, 0, len(result.Items)) for _, c := range result.Items { ids = append(ids, c.ID) } return ids, nil } // StopManaged stops all containers started by this package. func (m *Manager) StopManaged(ctx context.Context) error { ids, err := m.ListManaged(ctx) if err != nil { return fmt.Errorf("list containers for stop: %w", err) } var errs []error for _, id := range ids { if err := m.Stop(ctx, id); err != nil { errs = append(errs, err) } } if len(errs) > 0 { return errors.Join(errs...) } m.logger.Info("stopped all managed containers", slog.Int("count", len(ids))) return nil } // RemoveManaged removes all containers started by this package. func (m *Manager) RemoveManaged(ctx context.Context) error { ids, err := m.ListManaged(ctx) if err != nil { return fmt.Errorf("list containers for remove: %w", err) } var errs []error for _, id := range ids { if err := m.Remove(ctx, id); err != nil { errs = append(errs, err) } } if len(errs) > 0 { return errors.Join(errs...) } m.logger.Info("removed all managed containers", slog.Int("count", len(ids))) return nil } // ExecOptions configures a command to run inside a container. type ExecOptions struct { Cmd []string // Command and arguments Env []string // Optional environment variables Stdout io.Writer // Where to write stdout (nil → discard) Stderr io.Writer // Where to write stderr (nil → discard) } // ExecResult holds the outcome of an exec invocation. type ExecResult struct { ExitCode int } // Exec runs a command inside a running container and streams its output. func (m *Manager) Exec(ctx context.Context, containerID string, opts ExecOptions) (*ExecResult, error) { if len(opts.Cmd) == 0 { return nil, errors.New("cmd must not be empty") } stdout := opts.Stdout if stdout == nil { stdout = io.Discard } stderr := opts.Stderr if stderr == nil { stderr = io.Discard } createResp, err := m.client.ExecCreate(ctx, containerID, client.ExecCreateOptions{ Cmd: opts.Cmd, Env: opts.Env, AttachStdout: true, AttachStderr: true, }) if err != nil { return nil, fmt.Errorf("exec create: %w", err) } attachResp, err := m.client.ExecAttach(ctx, createResp.ID, client.ExecAttachOptions{}) if err != nil { return nil, fmt.Errorf("exec attach: %w", err) } defer attachResp.Close() if err := demuxDockerStream(attachResp.Reader, stdout, stderr); err != nil { return nil, fmt.Errorf("exec stream: %w", err) } inspectResp, err := m.client.ExecInspect(ctx, createResp.ID, client.ExecInspectOptions{}) if err != nil { return nil, fmt.Errorf("exec inspect: %w", err) } m.logger.Info( "docker exec completed", slog.String("container_id", containerID), slog.Int("exit_code", inspectResp.ExitCode), ) return &ExecResult{ExitCode: inspectResp.ExitCode}, nil } // Close closes the underlying Docker client. func (m *Manager) Close() error { return m.client.Close() } // Docker multiplexed stream constants. When TTY is disabled, Docker prefixes each output frame with // an 8-byte header: [streamType(1), padding(3), payloadSize(4 big-endian)]. const ( streamStdout byte = 1 streamStderr byte = 2 streamHeaderSize = 8 ) // demuxDockerStream reads a Docker multiplexed stream and routes each frame to the appropriate // writer based on its stream type. func demuxDockerStream(r io.Reader, stdout, stderr io.Writer) error { var header [streamHeaderSize]byte for { if _, err := io.ReadFull(r, header[:]); err != nil { if err == io.EOF { return nil } return err } payloadSize := int64(binary.BigEndian.Uint32(header[4:])) var dst io.Writer switch header[0] { case streamStdout: dst = stdout case streamStderr: dst = stderr default: dst = io.Discard } if _, err := io.CopyN(dst, r, payloadSize); err != nil { return err } } } func (m *Manager) pullImageIfNotExists(ctx context.Context, imageName string, progressWriter io.Writer) (retErr error) { if _, err := m.client.ImageInspect(ctx, imageName); err == nil { return nil } else if !errdefs.IsNotFound(err) { return fmt.Errorf("inspect image: %w", err) } reader, err := m.client.ImagePull(ctx, imageName, client.ImagePullOptions{}) if err != nil { return fmt.Errorf("pull image: %w", err) } defer func() { retErr = errors.Join(retErr, reader.Close()) }() if progressWriter == nil { progressWriter = io.Discard } if _, err := io.Copy(progressWriter, reader); err != nil { return fmt.Errorf("stream pull output: %w", err) } return nil } ================================================ FILE: pkg/dockermanage/options.go ================================================ package dockermanage import ( "errors" "fmt" "io" "maps" "slices" "strings" "github.com/moby/moby/api/types/network" ) const ( // DefaultHostIP is the default host IP used for port bindings. DefaultHostIP = "127.0.0.1" // ManagedLabelKey marks containers created by this package. The value indicates the container // type (e.g., "postgres"). Presence of the key means the container is managed. ManagedLabelKey = "pressly.goose" ) // Option configures container start behavior. type Option interface { apply(*config) error } type optionFunc func(*config) error func (f optionFunc) apply(cfg *config) error { return f(cfg) } type config struct { name string image string containerPort network.Port hostIP string hostPort int envVars []string autoRemove bool pullProgress io.Writer labels map[string]string } func defaultConfig() *config { return &config{ hostIP: DefaultHostIP, envVars: []string{}, labels: map[string]string{ ManagedLabelKey: "", }, } } // WithName sets the container name. func WithName(name string) Option { return optionFunc(func(cfg *config) error { name = strings.TrimSpace(name) if name == "" { return errors.New("container name must not be empty") } cfg.name = name return nil }) } // WithImage sets the container image (for example: postgres:16-alpine). func WithImage(image string) Option { return optionFunc(func(cfg *config) error { image = strings.TrimSpace(image) if image == "" { return errors.New("image must not be empty") } cfg.image = image return nil }) } // WithContainerPort sets the container port to expose, for example: "5432/tcp". func WithContainerPort(port string) Option { return optionFunc(func(cfg *config) error { p, err := network.ParsePort(port) if err != nil { return fmt.Errorf("invalid container port: %w", err) } cfg.containerPort = p return nil }) } // WithContainerPortTCP is a convenience helper for TCP ports. func WithContainerPortTCP(port int) Option { return optionFunc(func(cfg *config) error { if port <= 0 || port > 65535 { return fmt.Errorf("container port must be in range 1-65535: %d", port) } p, ok := network.PortFrom(uint16(port), network.TCP) if !ok { return fmt.Errorf("invalid container port: %d", port) } cfg.containerPort = p return nil }) } // WithHostIP sets the host IP to bind the container port to. func WithHostIP(hostIP string) Option { return optionFunc(func(cfg *config) error { hostIP = strings.TrimSpace(hostIP) if hostIP == "" { return errors.New("host IP must not be empty") } cfg.hostIP = hostIP return nil }) } // WithHostPort sets a fixed host port. Leave unset to auto-assign. func WithHostPort(port int) Option { return optionFunc(func(cfg *config) error { if port <= 0 || port > 65535 { return fmt.Errorf("host port must be in range 1-65535: %d", port) } cfg.hostPort = port return nil }) } // WithEnv appends a single environment variable. func WithEnv(key, value string) Option { return optionFunc(func(cfg *config) error { key = strings.TrimSpace(key) if key == "" { return errors.New("env key must not be empty") } if strings.Contains(key, "=") { return fmt.Errorf("env key must not contain '=': %s", key) } cfg.envVars = append(cfg.envVars, key+"="+value) return nil }) } // WithEnvVars appends environment variables in KEY=VALUE format. func WithEnvVars(envVars []string) Option { return optionFunc(func(cfg *config) error { cfg.envVars = append(cfg.envVars, slices.Clone(envVars)...) return nil }) } // WithAutoRemove configures Docker AutoRemove behavior. func WithAutoRemove(autoRemove bool) Option { return optionFunc(func(cfg *config) error { cfg.autoRemove = autoRemove return nil }) } // WithPullProgress sets where image pull output is streamed. func WithPullProgress(w io.Writer) Option { return optionFunc(func(cfg *config) error { cfg.pullProgress = w return nil }) } // WithLabel sets a single container label. func WithLabel(key, value string) Option { return optionFunc(func(cfg *config) error { key = strings.TrimSpace(key) if key == "" { return errors.New("label key must not be empty") } cfg.labels[key] = value return nil }) } // WithLabels merges labels into container labels. func WithLabels(labels map[string]string) Option { return optionFunc(func(cfg *config) error { for key, value := range maps.Clone(labels) { key = strings.TrimSpace(key) if key == "" { return errors.New("label key must not be empty") } cfg.labels[key] = value } return nil }) } ================================================ FILE: provider.go ================================================ package goose import ( "cmp" "context" "database/sql" "errors" "fmt" "io/fs" "log/slog" "maps" "math" "strconv" "strings" "sync" "github.com/pressly/goose/v3/database" "github.com/pressly/goose/v3/internal/controller" "github.com/pressly/goose/v3/internal/gooseutil" "github.com/pressly/goose/v3/internal/sqlparser" "go.uber.org/multierr" ) // Provider is a goose migration provider. type Provider struct { // mu protects all accesses to the provider and must be held when calling operations on the // database. mu sync.Mutex db *sql.DB store *controller.StoreController versionTableOnce sync.Once fsys fs.FS cfg config // migrations are ordered by version in ascending order. This list will never be empty and // contains all migrations known to the provider. migrations []*Migration } // NewProvider returns a new goose provider. // // The caller is responsible for matching the database dialect with the database/sql driver. For // example, if the database dialect is "postgres", the database/sql driver could be // github.com/lib/pq or github.com/jackc/pgx. Each dialect has a corresponding [database.Dialect] // constant backed by a default [database.Store] implementation. For more advanced use cases, such // as using a custom table name or supplying a custom store implementation, see [WithStore]. // // fsys is the filesystem used to read migration files, but may be nil. Most users will want to use // [os.DirFS], os.DirFS("path/to/migrations"), to read migrations from the local filesystem. // However, it is possible to use a different "filesystem", such as [embed.FS] or filter out // migrations using [fs.Sub]. // // See [ProviderOption] for more information on configuring the provider. // // Unless otherwise specified, all methods on Provider are safe for concurrent use. func NewProvider(dialect Dialect, db *sql.DB, fsys fs.FS, opts ...ProviderOption) (*Provider, error) { if db == nil { return nil, errors.New("db must not be nil") } if fsys == nil { fsys = noopFS{} } cfg := config{ registered: make(map[int64]*Migration), excludePaths: make(map[string]bool), excludeVersions: make(map[int64]bool), } for _, opt := range opts { if err := opt.apply(&cfg); err != nil { return nil, err } } // Allow users to specify a custom store implementation, but only if they don't specify a // dialect. If they specify a dialect, we'll use the default store implementation. if dialect == DialectCustom && cfg.store == nil { return nil, errors.New("custom store must be supplied when using a custom dialect, make sure to pass WithStore option") } if dialect != DialectCustom && cfg.store != nil { return nil, errors.New("custom store must not be specified when using one of the default dialects, use DialectCustom instead") } // Allow table name to be set only if store is not set. if cfg.tableName != "" && cfg.store != nil { return nil, errors.New("WithTableName cannot be used with WithStore; set the table name directly on your custom store") } // Set default logger if neither was provided if cfg.slogger == nil && cfg.logger == nil { cfg.logger = &stdLogger{} } var store database.Store if dialect != "" { var err error store, err = database.NewStore(dialect, cmp.Or(cfg.tableName, DefaultTablename)) if err != nil { return nil, err } } else { store = cfg.store } if store.Tablename() == "" { return nil, errors.New("invalid store implementation: table name must not be empty") } return newProvider(db, store, fsys, cfg, registeredGoMigrations /* global */) } func newProvider( db *sql.DB, store database.Store, fsys fs.FS, cfg config, global map[int64]*Migration, ) (*Provider, error) { // Collect migrations from the filesystem and merge with registered migrations. // // Note, we don't parse SQL migrations here. They are parsed lazily when required. // feat(mf): we could add a flag to parse SQL migrations eagerly. This would allow us to return // an error if there are any SQL parsing errors. This adds a bit overhead to startup though, so // we should make it optional. filesystemSources, err := collectFilesystemSources(fsys, false, cfg.excludePaths, cfg.excludeVersions) if err != nil { return nil, err } versionToGoMigration := make(map[int64]*Migration) // Add user-registered Go migrations from the provider. maps.Copy(versionToGoMigration, cfg.registered) // Skip adding global Go migrations if explicitly disabled. if cfg.disableGlobalRegistry { // TODO(mf): let's add a warn-level log here to inform users if len(global) > 0. Would like // to add this once we're on go1.21 and leverage the new slog package. } else { for version, m := range global { if _, ok := versionToGoMigration[version]; ok { return nil, fmt.Errorf("global go migration conflicts with provider-registered go migration with version %d", version) } versionToGoMigration[version] = m } } // At this point we have all registered unique Go migrations (if any). We need to merge them // with SQL migrations from the filesystem. migrations, err := merge(filesystemSources, versionToGoMigration) if err != nil { return nil, err } if len(migrations) == 0 { return nil, ErrNoMigrations } return &Provider{ db: db, fsys: fsys, cfg: cfg, store: controller.NewStoreController(store), migrations: migrations, }, nil } // Status returns the status of all migrations, merging the list of migrations from the database and // filesystem. The returned items are ordered by version, in ascending order. func (p *Provider) Status(ctx context.Context) ([]*MigrationStatus, error) { return p.status(ctx) } // HasPending returns true if there are pending migrations to apply, otherwise, it returns false. If // out-of-order migrations are disabled, yet some are detected, this method returns an error. // // Note, this method will not use a SessionLocker or Locker if one is configured. This allows // callers to check for pending migrations without blocking or being blocked by other operations. func (p *Provider) HasPending(ctx context.Context) (bool, error) { return p.hasPending(ctx) } // GetVersions returns the max database version and the target version to migrate to. // // Note, this method will not use a SessionLocker or Locker if one is configured. This allows // callers to check for versions without blocking or being blocked by other operations. func (p *Provider) GetVersions(ctx context.Context) (current, target int64, err error) { return p.getVersions(ctx) } // GetDBVersion returns the highest version recorded in the database, regardless of the order in // which migrations were applied. For example, if migrations were applied out of order (1,4,2,3), // this method returns 4. If no migrations have been applied, it returns 0. func (p *Provider) GetDBVersion(ctx context.Context) (int64, error) { if p.cfg.disableVersioning { return -1, errors.New("getting database version not supported when versioning is disabled") } return p.getDBMaxVersion(ctx, nil) } // ListSources returns a list of all migration sources known to the provider, sorted in ascending // order by version. The path field may be empty for manually registered migrations, such as Go // migrations registered using the [WithGoMigrations] option. func (p *Provider) ListSources() []*Source { sources := make([]*Source, 0, len(p.migrations)) for _, m := range p.migrations { sources = append(sources, &Source{ Type: m.Type, Path: m.Source, Version: m.Version, }) } return sources } // Ping attempts to ping the database to verify a connection is available. func (p *Provider) Ping(ctx context.Context) error { return p.db.PingContext(ctx) } // Close closes the database connection initially supplied to the provider. func (p *Provider) Close() error { return p.db.Close() } // ApplyVersion applies exactly one migration for the specified version. If there is no migration // available for the specified version, this method returns [ErrVersionNotFound]. If the migration // has already been applied, this method returns [ErrAlreadyApplied]. // // The direction parameter determines the migration direction: true for up migration and false for // down migration. func (p *Provider) ApplyVersion(ctx context.Context, version int64, direction bool) (*MigrationResult, error) { res, err := p.apply(ctx, version, direction) if err != nil { return nil, err } // This should never happen, we must return exactly one result. if len(res) != 1 { versions := make([]string, 0, len(res)) for _, r := range res { versions = append(versions, strconv.FormatInt(r.Source.Version, 10)) } return nil, fmt.Errorf( "unexpected number of migrations applied running apply, expecting exactly one result: %v", strings.Join(versions, ","), ) } return res[0], nil } // Up applies all pending migrations. If there are no new migrations to apply, this method returns // empty list and nil error. func (p *Provider) Up(ctx context.Context) ([]*MigrationResult, error) { hasPending, err := p.HasPending(ctx) if err != nil { return nil, err } if !hasPending { return nil, nil } return p.up(ctx, false, math.MaxInt64) } // UpByOne applies the next pending migration. If there is no next migration to apply, this method // returns [ErrNoNextVersion]. func (p *Provider) UpByOne(ctx context.Context) (*MigrationResult, error) { hasPending, err := p.HasPending(ctx) if err != nil { return nil, err } if !hasPending { return nil, ErrNoNextVersion } res, err := p.up(ctx, true, math.MaxInt64) if err != nil { return nil, err } if len(res) == 0 { return nil, ErrNoNextVersion } // This should never happen, we must return exactly one result. if len(res) != 1 { versions := make([]string, 0, len(res)) for _, r := range res { versions = append(versions, strconv.FormatInt(r.Source.Version, 10)) } return nil, fmt.Errorf( "unexpected number of migrations applied running up-by-one, expecting exactly one result: %v", strings.Join(versions, ","), ) } return res[0], nil } // UpTo applies all pending migrations up to, and including, the specified version. If there are no // migrations to apply, this method returns empty list and nil error. // // For example, if there are three new migrations (9,10,11) and the current database version is 8 // with a requested version of 10, only versions 9,10 will be applied. func (p *Provider) UpTo(ctx context.Context, version int64) ([]*MigrationResult, error) { hasPending, err := p.HasPending(ctx) if err != nil { return nil, err } if !hasPending { return nil, nil } return p.up(ctx, false, version) } // Down rolls back the most recently applied migration. If there are no migrations to rollback, this // method returns [ErrNoNextVersion]. // // Note, migrations are rolled back in the order they were applied. And not in the reverse order of // the migration version. This only applies in scenarios where migrations are allowed to be applied // out of order. func (p *Provider) Down(ctx context.Context) (*MigrationResult, error) { res, err := p.down(ctx, true, 0) if err != nil { return nil, err } if len(res) == 0 { return nil, ErrNoNextVersion } // This should never happen, we must return exactly one result. if len(res) != 1 { versions := make([]string, 0, len(res)) for _, r := range res { versions = append(versions, strconv.FormatInt(r.Source.Version, 10)) } return nil, fmt.Errorf( "unexpected number of migrations applied running down, expecting exactly one result: %v", strings.Join(versions, ","), ) } return res[0], nil } // DownTo rolls back all migrations down to, but not including, the specified version. // // For example, if the current database version is 11,10,9... and the requested version is 9, only // migrations 11, 10 will be rolled back. // // Note, migrations are rolled back in the order they were applied. And not in the reverse order of // the migration version. This only applies in scenarios where migrations are allowed to be applied // out of order. func (p *Provider) DownTo(ctx context.Context, version int64) ([]*MigrationResult, error) { if version < 0 { return nil, fmt.Errorf("invalid version: must be a valid number or zero: %d", version) } return p.down(ctx, false, version) } // *** Internal methods *** func (p *Provider) up( ctx context.Context, byOne bool, version int64, ) (_ []*MigrationResult, retErr error) { if version < 1 { return nil, errInvalidVersion } conn, cleanup, err := p.initialize(ctx, true) if err != nil { return nil, fmt.Errorf("failed to initialize: %w", err) } defer func() { retErr = multierr.Append(retErr, cleanup()) }() if len(p.migrations) == 0 { return nil, nil } var apply []*Migration if p.cfg.disableVersioning { if byOne { return nil, errors.New("up-by-one not supported when versioning is disabled") } apply = p.migrations } else { // optimize(mf): Listing all migrations from the database isn't great. // // The ideal implementation would be to query for the current max version and then apply // migrations greater than that version. However, a nice property of the current // implementation is that we can make stronger guarantees about unapplied migrations. // // In cases where users do not use out-of-order migrations, we want to surface an error if // there are older unapplied migrations. See https://github.com/pressly/goose/issues/761 for // more details. // // And in cases where users do use out-of-order migrations, we need to build a list of older // migrations that need to be applied, so we need to query for all migrations anyways. dbMigrations, err := p.store.ListMigrations(ctx, conn) if err != nil { return nil, err } if len(dbMigrations) == 0 { return nil, errMissingZeroVersion } versions, err := gooseutil.UpVersions( getVersionsFromMigrations(p.migrations), // fsys versions getVersionsFromListMigrations(dbMigrations), // db versions version, p.cfg.allowMissing, ) if err != nil { return nil, err } for _, v := range versions { m, err := p.getMigration(v) if err != nil { return nil, err } apply = append(apply, m) } } return p.runMigrations(ctx, conn, apply, sqlparser.DirectionUp, byOne) } func (p *Provider) down( ctx context.Context, byOne bool, version int64, ) (_ []*MigrationResult, retErr error) { conn, cleanup, err := p.initialize(ctx, true) if err != nil { return nil, fmt.Errorf("failed to initialize: %w", err) } defer func() { retErr = multierr.Append(retErr, cleanup()) }() if len(p.migrations) == 0 { return nil, nil } if p.cfg.disableVersioning { var downMigrations []*Migration if byOne { last := p.migrations[len(p.migrations)-1] downMigrations = []*Migration{last} } else { downMigrations = p.migrations } return p.runMigrations(ctx, conn, downMigrations, sqlparser.DirectionDown, byOne) } dbMigrations, err := p.store.ListMigrations(ctx, conn) if err != nil { return nil, err } if len(dbMigrations) == 0 { return nil, errMissingZeroVersion } // We never migrate the zero version down. if dbMigrations[0].Version == 0 { p.logf(ctx, "no migrations to run, current version: 0", "no migrations to run", slog.Int64("version", 0), ) return nil, nil } var apply []*Migration for _, dbMigration := range dbMigrations { if dbMigration.Version <= version { break } m, err := p.getMigration(dbMigration.Version) if err != nil { return nil, err } apply = append(apply, m) } return p.runMigrations(ctx, conn, apply, sqlparser.DirectionDown, byOne) } func (p *Provider) apply( ctx context.Context, version int64, direction bool, ) (_ []*MigrationResult, retErr error) { if version < 1 { return nil, errInvalidVersion } m, err := p.getMigration(version) if err != nil { return nil, err } conn, cleanup, err := p.initialize(ctx, true) if err != nil { return nil, fmt.Errorf("failed to initialize: %w", err) } defer func() { retErr = multierr.Append(retErr, cleanup()) }() d := sqlparser.DirectionDown if direction { d = sqlparser.DirectionUp } if p.cfg.disableVersioning { // If versioning is disabled, we simply run the migration. return p.runMigrations(ctx, conn, []*Migration{m}, d, true) } result, err := p.store.GetMigration(ctx, conn, version) if err != nil && !errors.Is(err, database.ErrVersionNotFound) { return nil, err } // There are a few states here: // 1. direction is up // a. migration is applied, this is an error (ErrAlreadyApplied) // b. migration is not applied, apply it if direction && result != nil { return nil, fmt.Errorf("version %d: %w", version, ErrAlreadyApplied) } // 2. direction is down // a. migration is applied, rollback // b. migration is not applied, this is an error (ErrNotApplied) if !direction && result == nil { return nil, fmt.Errorf("version %d: %w", version, ErrNotApplied) } return p.runMigrations(ctx, conn, []*Migration{m}, d, true) } func (p *Provider) getVersions(ctx context.Context) (current, target int64, retErr error) { conn, cleanup, err := p.initialize(ctx, false) if err != nil { return -1, -1, fmt.Errorf("failed to initialize: %w", err) } defer func() { retErr = multierr.Append(retErr, cleanup()) }() target = p.migrations[len(p.migrations)-1].Version // If versioning is disabled, we always have pending migrations and the target version is the // last migration. if p.cfg.disableVersioning { return -1, target, nil } current, err = p.store.GetLatestVersion(ctx, conn) if err != nil { if errors.Is(err, database.ErrVersionNotFound) { return -1, target, errMissingZeroVersion } return -1, target, err } return current, target, nil } func (p *Provider) hasPending(ctx context.Context) (_ bool, retErr error) { conn, cleanup, err := p.initialize(ctx, false) if err != nil { return false, fmt.Errorf("failed to initialize: %w", err) } defer func() { retErr = multierr.Append(retErr, cleanup()) }() // If versioning is disabled, we always have pending migrations. if p.cfg.disableVersioning { return true, nil } // List all migrations from the database. Careful, optimizations here can lead to subtle bugs. // We have 2 important cases to consider: // // 1. Users have enabled out-of-order migrations, in which case we need to check if any // migrations are missing and report that there are pending migrations. Do not surface an // error because this is a valid state. // // 2. Users have disabled out-of-order migrations (default), in which case we need to check if all // migrations have been applied. We cannot check for the highest applied version because we lose the // ability to surface an error if an out-of-order migration was introduced. It would be silently // ignored and the user would not know that they have unapplied migrations. // // Maybe we could consider adding a flag to the provider such as IgnoreMissing, which would // allow silently ignoring missing migrations. This would be useful for users that have built // checks that prevent missing migrations from being introduced. dbMigrations, err := p.store.ListMigrations(ctx, conn) if err != nil { return false, err } apply, err := gooseutil.UpVersions( getVersionsFromMigrations(p.migrations), // fsys versions getVersionsFromListMigrations(dbMigrations), // db versions math.MaxInt64, p.cfg.allowMissing, ) if err != nil { return false, err } return len(apply) > 0, nil } func getVersionsFromMigrations(in []*Migration) []int64 { out := make([]int64, 0, len(in)) for _, m := range in { out = append(out, m.Version) } return out } func getVersionsFromListMigrations(in []*database.ListMigrationsResult) []int64 { out := make([]int64, 0, len(in)) for _, m := range in { out = append(out, m.Version) } return out } func (p *Provider) status(ctx context.Context) (_ []*MigrationStatus, retErr error) { conn, cleanup, err := p.initialize(ctx, true) if err != nil { return nil, fmt.Errorf("failed to initialize: %w", err) } defer func() { retErr = multierr.Append(retErr, cleanup()) }() status := make([]*MigrationStatus, 0, len(p.migrations)) for _, m := range p.migrations { migrationStatus := &MigrationStatus{ Source: &Source{ Type: m.Type, Path: m.Source, Version: m.Version, }, State: StatePending, } // If versioning is disabled, we can't check the database for applied migrations, so we // assume all migrations are pending. if !p.cfg.disableVersioning { dbResult, err := p.store.GetMigration(ctx, conn, m.Version) if err != nil && !errors.Is(err, database.ErrVersionNotFound) { return nil, err } if dbResult != nil { migrationStatus.State = StateApplied migrationStatus.AppliedAt = dbResult.Timestamp } } status = append(status, migrationStatus) } return status, nil } // getDBMaxVersion returns the highest version recorded in the database, regardless of the order in // which migrations were applied. conn may be nil, in which case a connection is initialized. func (p *Provider) getDBMaxVersion(ctx context.Context, conn *sql.Conn) (_ int64, retErr error) { if conn == nil { var cleanup func() error var err error conn, cleanup, err = p.initialize(ctx, true) if err != nil { return 0, err } defer func() { retErr = multierr.Append(retErr, cleanup()) }() } latest, err := p.store.GetLatestVersion(ctx, conn) if err != nil { if errors.Is(err, database.ErrVersionNotFound) { return 0, errMissingZeroVersion } return -1, err } return latest, nil } ================================================ FILE: provider_collect.go ================================================ package goose import ( "errors" "fmt" "io/fs" "path/filepath" "sort" "strings" ) // fileSources represents a collection of migration files on the filesystem. type fileSources struct { sqlSources []Source goSources []Source } // collectFilesystemSources scans the file system for migration files that have a numeric prefix // (greater than one) followed by an underscore and a file extension of either .go or .sql. fsys may // be nil, in which case an empty fileSources is returned. // // If strict is true, then any error parsing the numeric component of the filename will result in an // error. The file is skipped otherwise. // // This function DOES NOT parse SQL migrations or merge registered Go migrations. It only collects // migration sources from the filesystem. func collectFilesystemSources( fsys fs.FS, strict bool, excludePaths map[string]bool, excludeVersions map[int64]bool, ) (*fileSources, error) { if fsys == nil { return new(fileSources), nil } sources := new(fileSources) versionToBaseLookup := make(map[int64]string) // map[version]filepath.Base(fullpath) for _, pattern := range []string{ "*.sql", "*.go", } { files, err := fs.Glob(fsys, pattern) if err != nil { return nil, fmt.Errorf("failed to glob pattern %q: %w", pattern, err) } for _, fullpath := range files { base := filepath.Base(fullpath) if strings.HasSuffix(base, "_test.go") { continue } if excludePaths[base] { // TODO(mf): log this? continue } // If the filename has a valid looking version of the form: NUMBER_.{sql,go}, then use // that as the version. Otherwise, ignore it. This allows users to have arbitrary // filenames, but still have versioned migrations within the same directory. For // example, a user could have a helpers.go file which contains unexported helper // functions for migrations. version, err := NumericComponent(base) if err != nil { if strict { return nil, fmt.Errorf("failed to parse numeric component from %q: %w", base, err) } continue } if excludeVersions[version] { // TODO: log this? continue } // Ensure there are no duplicate versions. if existing, ok := versionToBaseLookup[version]; ok { return nil, fmt.Errorf("found duplicate migration version %d:\n\texisting:%v\n\tcurrent:%v", version, existing, base, ) } switch filepath.Ext(base) { case ".sql": sources.sqlSources = append(sources.sqlSources, Source{ Type: TypeSQL, Path: fullpath, Version: version, }) case ".go": sources.goSources = append(sources.goSources, Source{ Type: TypeGo, Path: fullpath, Version: version, }) default: // Should never happen since we already filtered out all other file types. return nil, fmt.Errorf("invalid file extension: %q", base) } // Add the version to the lookup map. versionToBaseLookup[version] = base } } return sources, nil } func newSQLMigration(source Source) *Migration { return &Migration{ Type: source.Type, Version: source.Version, Source: source.Path, construct: true, Next: -1, Previous: -1, sql: sqlMigration{ Parsed: false, // SQL migrations are parsed lazily. }, } } func merge(sources *fileSources, registered map[int64]*Migration) ([]*Migration, error) { var migrations []*Migration migrationLookup := make(map[int64]*Migration) // Add all SQL migrations to the list of migrations. for _, source := range sources.sqlSources { m := newSQLMigration(source) migrations = append(migrations, m) migrationLookup[source.Version] = m } // If there are no Go files in the filesystem and no registered Go migrations, return early. if len(sources.goSources) == 0 && len(registered) == 0 { return migrations, nil } // Return an error if the given sources contain a versioned Go migration that has not been // registered. This is a sanity check to ensure users didn't accidentally create a valid looking // Go migration file on disk and forget to register it. // // This is almost always a user error. var unregistered []string for _, s := range sources.goSources { m, ok := registered[s.Version] if !ok { unregistered = append(unregistered, s.Path) } else { // Populate the source path for registered Go migrations that have a corresponding file // on disk. m.Source = s.Path } } if len(unregistered) > 0 { return nil, unregisteredError(unregistered) } // Add all registered Go migrations to the list of migrations, checking for duplicate versions. // // Important, users can register Go migrations manually via goose.Add_ functions. These // migrations may not have a corresponding file on disk. Which is fine! We include them // wholesale as part of migrations. This allows users to build a custom binary that only embeds // the SQL migration files. for version, r := range registered { // Ensure there are no duplicate versions. if existing, ok := migrationLookup[version]; ok { fullpath := r.Source if fullpath == "" { fullpath = "no source path" } return nil, fmt.Errorf("found duplicate migration version %d:\n\texisting:%v\n\tcurrent:%v", version, existing.Source, fullpath, ) } migrations = append(migrations, r) migrationLookup[version] = r } // Sort migrations by version in ascending order. sort.Slice(migrations, func(i, j int) bool { return migrations[i].Version < migrations[j].Version }) return migrations, nil } func unregisteredError(unregistered []string) error { const ( hintURL = "https://github.com/pressly/goose/tree/master/examples/go-migrations" ) f := "file" if len(unregistered) > 1 { f += "s" } var b strings.Builder fmt.Fprintf(&b, "error: detected %d unregistered Go %s:\n", len(unregistered), f) for _, name := range unregistered { b.WriteString("\t" + name + "\n") } hint := fmt.Sprintf("hint: go functions must be registered and built into a custom binary see:\n%s", hintURL) b.WriteString(hint) b.WriteString("\n") return errors.New(b.String()) } ================================================ FILE: provider_collect_test.go ================================================ package goose import ( "io/fs" "testing" "testing/fstest" "github.com/stretchr/testify/require" ) func TestCollectFileSources(t *testing.T) { t.Parallel() t.Run("nil_fsys", func(t *testing.T) { sources, err := collectFilesystemSources(nil, false, nil, nil) require.NoError(t, err) require.NotNil(t, sources) require.Empty(t, sources.goSources) require.Empty(t, sources.sqlSources) }) t.Run("noop_fsys", func(t *testing.T) { sources, err := collectFilesystemSources(noopFS{}, false, nil, nil) require.NoError(t, err) require.NotNil(t, sources) require.Empty(t, sources.goSources) require.Empty(t, sources.sqlSources) }) t.Run("empty_fsys", func(t *testing.T) { sources, err := collectFilesystemSources(fstest.MapFS{}, false, nil, nil) require.NoError(t, err) require.Empty(t, sources.goSources) require.Empty(t, sources.sqlSources) require.NotNil(t, sources) }) t.Run("incorrect_fsys", func(t *testing.T) { mapFS := fstest.MapFS{ "00000_foo.sql": sqlMapFile, } // strict disable - should not error sources, err := collectFilesystemSources(mapFS, false, nil, nil) require.NoError(t, err) require.Empty(t, sources.goSources) require.Empty(t, sources.sqlSources) // strict enabled - should error _, err = collectFilesystemSources(mapFS, true, nil, nil) require.Error(t, err) require.Contains(t, err.Error(), "migration version must be greater than zero") }) t.Run("collect", func(t *testing.T) { fsys, err := fs.Sub(newSQLOnlyFS(), "migrations") require.NoError(t, err) sources, err := collectFilesystemSources(fsys, false, nil, nil) require.NoError(t, err) require.Len(t, sources.sqlSources, 4) require.Empty(t, sources.goSources) expected := fileSources{ sqlSources: []Source{ newSource(TypeSQL, "00001_foo.sql", 1), newSource(TypeSQL, "00002_bar.sql", 2), newSource(TypeSQL, "00003_baz.sql", 3), newSource(TypeSQL, "00110_qux.sql", 110), }, } for i := 0; i < len(sources.sqlSources); i++ { require.Equal(t, sources.sqlSources[i], expected.sqlSources[i]) } }) t.Run("excludes", func(t *testing.T) { fsys, err := fs.Sub(newSQLOnlyFS(), "migrations") require.NoError(t, err) sources, err := collectFilesystemSources( fsys, false, // exclude 2 files explicitly map[string]bool{ "00002_bar.sql": true, "00110_qux.sql": true, }, nil, ) require.NoError(t, err) require.Len(t, sources.sqlSources, 2) require.Empty(t, sources.goSources) expected := fileSources{ sqlSources: []Source{ newSource(TypeSQL, "00001_foo.sql", 1), newSource(TypeSQL, "00003_baz.sql", 3), }, } for i := 0; i < len(sources.sqlSources); i++ { require.Equal(t, sources.sqlSources[i], expected.sqlSources[i]) } }) t.Run("strict", func(t *testing.T) { mapFS := newSQLOnlyFS() // Add a file with no version number mapFS["migrations/not_valid.sql"] = &fstest.MapFile{Data: []byte("invalid")} fsys, err := fs.Sub(mapFS, "migrations") require.NoError(t, err) _, err = collectFilesystemSources(fsys, true, nil, nil) require.Error(t, err) require.Contains(t, err.Error(), `failed to parse numeric component from "not_valid.sql"`) }) t.Run("skip_go_test_files", func(t *testing.T) { mapFS := fstest.MapFS{ "1_foo.sql": sqlMapFile, "2_bar.sql": sqlMapFile, "3_baz.sql": sqlMapFile, "4_qux.sql": sqlMapFile, "5_foo_test.go": {Data: []byte(`package goose_test`)}, } sources, err := collectFilesystemSources(mapFS, false, nil, nil) require.NoError(t, err) require.Len(t, sources.sqlSources, 4) require.Empty(t, sources.goSources) }) t.Run("skip_random_files", func(t *testing.T) { mapFS := fstest.MapFS{ "1_foo.sql": sqlMapFile, "4_something.go": {Data: []byte(`package goose`)}, "5_qux.sql": sqlMapFile, "README.md": {Data: []byte(`# README`)}, "LICENSE": {Data: []byte(`MIT`)}, "no_a_real_migration.sql": {Data: []byte(`SELECT 1;`)}, "some/other/dir/2_foo.sql": {Data: []byte(`SELECT 1;`)}, } sources, err := collectFilesystemSources(mapFS, false, nil, nil) require.NoError(t, err) require.Len(t, sources.sqlSources, 2) require.Len(t, sources.goSources, 1) // 1 require.Equal(t, "1_foo.sql", sources.sqlSources[0].Path) require.EqualValues(t, 1, sources.sqlSources[0].Version) // 2 require.Equal(t, "5_qux.sql", sources.sqlSources[1].Path) require.EqualValues(t, 5, sources.sqlSources[1].Version) // 3 require.Equal(t, "4_something.go", sources.goSources[0].Path) require.EqualValues(t, 4, sources.goSources[0].Version) }) t.Run("duplicate_versions", func(t *testing.T) { mapFS := fstest.MapFS{ "001_foo.sql": sqlMapFile, "01_bar.sql": sqlMapFile, } _, err := collectFilesystemSources(mapFS, false, nil, nil) require.Error(t, err) require.Contains(t, err.Error(), "found duplicate migration version 1") }) t.Run("dirpath", func(t *testing.T) { mapFS := fstest.MapFS{ "dir1/101_a.sql": sqlMapFile, "dir1/102_b.sql": sqlMapFile, "dir1/103_c.sql": sqlMapFile, "dir2/201_a.sql": sqlMapFile, "876_a.sql": sqlMapFile, } assertDirpath := func(dirpath string, sqlSources []Source) { t.Helper() f, err := fs.Sub(mapFS, dirpath) require.NoError(t, err) got, err := collectFilesystemSources(f, false, nil, nil) require.NoError(t, err) require.Len(t, sqlSources, len(got.sqlSources)) require.Empty(t, got.goSources) for i := 0; i < len(got.sqlSources); i++ { require.Equal(t, got.sqlSources[i], sqlSources[i]) } } assertDirpath(".", []Source{ newSource(TypeSQL, "876_a.sql", 876), }) assertDirpath("dir1", []Source{ newSource(TypeSQL, "101_a.sql", 101), newSource(TypeSQL, "102_b.sql", 102), newSource(TypeSQL, "103_c.sql", 103), }) assertDirpath("dir2", []Source{ newSource(TypeSQL, "201_a.sql", 201), }) assertDirpath("dir3", nil) }) } func TestMerge(t *testing.T) { t.Parallel() t.Run("with_go_files_on_disk", func(t *testing.T) { mapFS := fstest.MapFS{ // SQL "migrations/00001_foo.sql": sqlMapFile, // Go "migrations/00002_bar.go": {Data: []byte(`package migrations`)}, "migrations/00003_baz.go": {Data: []byte(`package migrations`)}, } fsys, err := fs.Sub(mapFS, "migrations") require.NoError(t, err) sources, err := collectFilesystemSources(fsys, false, nil, nil) require.NoError(t, err) require.Len(t, sources.sqlSources, 1) require.Len(t, sources.goSources, 2) t.Run("valid", func(t *testing.T) { registered := map[int64]*Migration{ 2: NewGoMigration(2, nil, nil), 3: NewGoMigration(3, nil, nil), } migrations, err := merge(sources, registered) require.NoError(t, err) require.Len(t, migrations, 3) assertMigration(t, migrations[0], newSource(TypeSQL, "00001_foo.sql", 1)) assertMigration(t, migrations[1], newSource(TypeGo, "00002_bar.go", 2)) assertMigration(t, migrations[2], newSource(TypeGo, "00003_baz.go", 3)) }) t.Run("unregistered_all", func(t *testing.T) { _, err := merge(sources, nil) require.Error(t, err) require.Contains(t, err.Error(), "error: detected 2 unregistered Go files:") require.Contains(t, err.Error(), "00002_bar.go") require.Contains(t, err.Error(), "00003_baz.go") }) t.Run("unregistered_some", func(t *testing.T) { _, err := merge(sources, map[int64]*Migration{2: NewGoMigration(2, nil, nil)}) require.Error(t, err) require.Contains(t, err.Error(), "error: detected 1 unregistered Go file") require.Contains(t, err.Error(), "00003_baz.go") }) t.Run("duplicate_sql", func(t *testing.T) { _, err := merge(sources, map[int64]*Migration{ 1: NewGoMigration(1, nil, nil), // duplicate. SQL already exists. 2: NewGoMigration(2, nil, nil), 3: NewGoMigration(3, nil, nil), }) require.Error(t, err) require.Contains(t, err.Error(), "found duplicate migration version 1") }) }) t.Run("no_go_files_on_disk", func(t *testing.T) { mapFS := fstest.MapFS{ // SQL "migrations/00001_foo.sql": sqlMapFile, "migrations/00002_bar.sql": sqlMapFile, "migrations/00005_baz.sql": sqlMapFile, } fsys, err := fs.Sub(mapFS, "migrations") require.NoError(t, err) sources, err := collectFilesystemSources(fsys, false, nil, nil) require.NoError(t, err) t.Run("unregistered_all", func(t *testing.T) { migrations, err := merge(sources, map[int64]*Migration{ 3: NewGoMigration(3, nil, nil), // 4 is missing 6: NewGoMigration(6, nil, nil), }) require.NoError(t, err) require.Len(t, migrations, 5) assertMigration(t, migrations[0], newSource(TypeSQL, "00001_foo.sql", 1)) assertMigration(t, migrations[1], newSource(TypeSQL, "00002_bar.sql", 2)) assertMigration(t, migrations[2], newSource(TypeGo, "", 3)) assertMigration(t, migrations[3], newSource(TypeSQL, "00005_baz.sql", 5)) assertMigration(t, migrations[4], newSource(TypeGo, "", 6)) }) }) t.Run("partial_go_files_on_disk", func(t *testing.T) { mapFS := fstest.MapFS{ "migrations/00001_foo.sql": sqlMapFile, "migrations/00002_bar.go": &fstest.MapFile{Data: []byte(`package migrations`)}, } fsys, err := fs.Sub(mapFS, "migrations") require.NoError(t, err) sources, err := collectFilesystemSources(fsys, false, nil, nil) require.NoError(t, err) t.Run("unregistered_all", func(t *testing.T) { migrations, err := merge(sources, map[int64]*Migration{ // This is the only Go file on disk. 2: NewGoMigration(2, nil, nil), // These are not on disk. Explicitly registered. 3: NewGoMigration(3, nil, nil), 6: NewGoMigration(6, nil, nil), }) require.NoError(t, err) require.Len(t, migrations, 4) assertMigration(t, migrations[0], newSource(TypeSQL, "00001_foo.sql", 1)) assertMigration(t, migrations[1], newSource(TypeGo, "00002_bar.go", 2)) assertMigration(t, migrations[2], newSource(TypeGo, "", 3)) assertMigration(t, migrations[3], newSource(TypeGo, "", 6)) }) }) } func assertMigration(t *testing.T, got *Migration, want Source) { t.Helper() require.Equal(t, want.Type, got.Type) require.Equal(t, want.Version, got.Version) require.Equal(t, want.Path, got.Source) switch got.Type { case TypeGo: require.NotNil(t, got.goUp) require.NotNil(t, got.goDown) case TypeSQL: require.False(t, got.sql.Parsed) default: t.Fatalf("unknown migration type: %s", got.Type) } } func newSQLOnlyFS() fstest.MapFS { return fstest.MapFS{ "migrations/00001_foo.sql": sqlMapFile, "migrations/00002_bar.sql": sqlMapFile, "migrations/00003_baz.sql": sqlMapFile, "migrations/00110_qux.sql": sqlMapFile, } } func newSource(t MigrationType, fullpath string, version int64) Source { return Source{ Type: t, Path: fullpath, Version: version, } } var ( sqlMapFile = &fstest.MapFile{Data: []byte(`-- +goose Up`)} ) ================================================ FILE: provider_errors.go ================================================ package goose import ( "errors" "fmt" ) var ( // ErrVersionNotFound is returned when a specific migration version is not located. This can // occur if a .sql file or a Go migration function for the specified version is missing. ErrVersionNotFound = errors.New("version not found") // ErrNoMigrations is returned by [NewProvider] when no migrations are found. ErrNoMigrations = errors.New("no migrations found") // ErrAlreadyApplied indicates that the migration cannot be applied because it has already been // executed. This error is returned by [Provider.Apply]. ErrAlreadyApplied = errors.New("migration already applied") // ErrNotApplied indicates that the rollback cannot be performed because the migration has not // yet been applied. This error is returned by [Provider.Apply]. ErrNotApplied = errors.New("migration not applied") // errInvalidVersion is returned when a migration version is invalid. errInvalidVersion = errors.New("version must be greater than 0") ) // PartialError is returned when a migration fails, but some migrations already got applied. type PartialError struct { // Applied are migrations that were applied successfully before the error occurred. May be // empty. Applied []*MigrationResult // Failed contains the result of the migration that failed. Cannot be nil. Failed *MigrationResult // Err is the error that occurred while running the migration and caused the failure. Err error } func (e *PartialError) Error() string { return fmt.Sprintf( "partial migration error (type:%s,version:%d): %v", e.Failed.Source.Type, e.Failed.Source.Version, e.Err, ) } func (e *PartialError) Unwrap() error { return e.Err } ================================================ FILE: provider_options.go ================================================ package goose import ( "errors" "fmt" "log/slog" "github.com/pressly/goose/v3/database" "github.com/pressly/goose/v3/lock" ) const ( // DefaultTablename is the default name of the database table used to track history of applied // migrations. DefaultTablename = "goose_db_version" ) // ProviderOption is a configuration option for a goose goose. type ProviderOption interface { apply(*config) error } // WithStore configures the provider with a custom [database.Store], allowing users to bring their // own implementation of the store interface. When this option is used, the dialect parameter of // [NewProvider] must be set to [DialectCustom]. // // This option cannot be used together with [WithTableName], since the table name is set on the // store. // // By default, the provider uses the [database.NewStore] function to create a store backed by one of // the officially supported dialects. func WithStore(store database.Store) ProviderOption { return configFunc(func(c *config) error { if c.store != nil { return fmt.Errorf("store already set: %T", c.store) } if store == nil { return errors.New("store must not be nil") } if store.Tablename() == "" { return errors.New("store implementation must set the table name") } c.store = store return nil }) } // WithTableName sets the name of the database table used to track history of applied migrations. // This option cannot be used together with [WithStore], since the table name is set on the store. // // Default is "goose_db_version". func WithTableName(name string) ProviderOption { return configFunc(func(c *config) error { if name == "" { return errors.New("table name must not be empty") } c.tableName = name return nil }) } // WithVerbose enables verbose logging. func WithVerbose(b bool) ProviderOption { return configFunc(func(c *config) error { c.verbose = b return nil }) } // WithSessionLocker enables locking using the provided SessionLocker. // // If WithSessionLocker is not called, locking is disabled. Must not be used together with // [WithLocker]. func WithSessionLocker(locker lock.SessionLocker) ProviderOption { return configFunc(func(c *config) error { if c.lockEnabled { return errors.New("lock already enabled") } if c.sessionLocker != nil { return errors.New("session locker already set") } if c.locker != nil { return errors.New("locker already set; cannot use both SessionLocker and Locker") } if locker == nil { return errors.New("session locker must not be nil") } c.lockEnabled = true c.sessionLocker = locker return nil }) } // WithLocker enables locking using the provided Locker. // // If WithLocker is not called, locking is disabled. Must not be used together with // [WithSessionLocker]. func WithLocker(locker lock.Locker) ProviderOption { return configFunc(func(c *config) error { if c.lockEnabled { return errors.New("lock already enabled") } if c.locker != nil { return errors.New("locker already set") } if c.sessionLocker != nil { return errors.New("session locker already set; cannot use both SessionLocker and Locker") } if locker == nil { return errors.New("locker must not be nil") } c.lockEnabled = true c.locker = locker return nil }) } // WithExcludeNames excludes the given file name from the list of migrations. If called multiple // times, the list of excludes is merged. func WithExcludeNames(excludes []string) ProviderOption { return configFunc(func(c *config) error { for _, name := range excludes { if _, ok := c.excludePaths[name]; ok { return fmt.Errorf("duplicate exclude file name: %s", name) } c.excludePaths[name] = true } return nil }) } // WithExcludeVersions excludes the given versions from the list of migrations. If called multiple // times, the list of excludes is merged. func WithExcludeVersions(versions []int64) ProviderOption { return configFunc(func(c *config) error { for _, version := range versions { if version < 1 { return errInvalidVersion } if _, ok := c.excludeVersions[version]; ok { return fmt.Errorf("duplicate excludes version: %d", version) } c.excludeVersions[version] = true } return nil }) } // WithGoMigrations registers Go migrations with the provider. If a Go migration with the same // version has already been registered, an error will be returned. // // Go migrations must be constructed using the [NewGoMigration] function. func WithGoMigrations(migrations ...*Migration) ProviderOption { return configFunc(func(c *config) error { for _, m := range migrations { if _, ok := c.registered[m.Version]; ok { return fmt.Errorf("go migration with version %d already registered", m.Version) } if err := checkGoMigration(m); err != nil { return fmt.Errorf("invalid go migration: %w", err) } c.registered[m.Version] = m } return nil }) } // WithDisableGlobalRegistry prevents the provider from registering Go migrations from the global // registry. By default, goose will register all Go migrations including those registered globally. func WithDisableGlobalRegistry(b bool) ProviderOption { return configFunc(func(c *config) error { c.disableGlobalRegistry = b return nil }) } // WithAllowOutofOrder allows the provider to apply missing (out-of-order) migrations. By default, // goose will raise an error if it encounters a missing migration. // // For example: migrations 1,3 are applied and then version 2,6 are introduced. If this option is // true, then goose will apply 2 (missing) and 6 (new) instead of raising an error. The final order // of applied migrations will be: 1,3,2,6. Out-of-order migrations are always applied first, // followed by new migrations. func WithAllowOutofOrder(b bool) ProviderOption { return configFunc(func(c *config) error { c.allowMissing = b return nil }) } // WithDisableVersioning disables versioning. Disabling versioning allows applying migrations // without tracking the versions in the database schema table. Useful for tests, seeding a database // or running ad-hoc queries. By default, goose will track all versions in the database schema // table. func WithDisableVersioning(b bool) ProviderOption { return configFunc(func(c *config) error { c.disableVersioning = b return nil }) } // WithLogger will set a custom Logger, which will override the default logger. Cannot be used // together with [WithSlog]. func WithLogger(l Logger) ProviderOption { return configFunc(func(c *config) error { if l == nil { return errors.New("logger must not be nil") } if c.slogger != nil { return errors.New("cannot use both WithLogger and WithSlog") } c.logger = l return nil }) } // WithSlog will set a custom [*slog.Logger] for structured logging. This enables rich structured // logging with attributes like source, direction, duration, etc. Cannot be used together with // [WithLogger]. // // Example: // // logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) // p, err := goose.NewProvider("postgres", db, fs, goose.WithSlog(logger)) func WithSlog(logger *slog.Logger) ProviderOption { return configFunc(func(c *config) error { if logger == nil { return errors.New("slog logger must not be nil") } if c.logger != nil { return errors.New("cannot use both WithLogger and WithSlog") } c.slogger = logger return nil }) } // WithIsolateDDL executes DDL operations separately from DML operations. This is useful for // databases like AWS Aurora DSQL that don't support mixing DDL and DML within the same transaction. func WithIsolateDDL(b bool) ProviderOption { return configFunc(func(c *config) error { c.isolateDDL = b return nil }) } type config struct { tableName string store database.Store verbose bool excludePaths map[string]bool excludeVersions map[int64]bool // Go migrations registered by the user. These will be merged/resolved against the globally // registered migrations. registered map[int64]*Migration // Locking options lockEnabled bool sessionLocker lock.SessionLocker locker lock.Locker // Feature disableVersioning bool allowMissing bool disableGlobalRegistry bool isolateDDL bool // Only a single logger can be set, they are mutually exclusive. If neither is set, a default // [Logger] will be set to maintain backward compatibility in /v3. logger Logger slogger *slog.Logger } type configFunc func(*config) error func (f configFunc) apply(cfg *config) error { return f(cfg) } ================================================ FILE: provider_options_test.go ================================================ package goose_test import ( "database/sql" "path/filepath" "testing" "testing/fstest" "github.com/pressly/goose/v3" "github.com/pressly/goose/v3/database" "github.com/stretchr/testify/require" _ "modernc.org/sqlite" ) func TestNewProvider(t *testing.T) { dir := t.TempDir() db, err := sql.Open("sqlite", filepath.Join(dir, "sql_embed.db")) require.NoError(t, err) fsys := fstest.MapFS{ "1_foo.sql": {Data: []byte(migration1)}, "2_bar.sql": {Data: []byte(migration2)}, "3_baz.sql": {Data: []byte(migration3)}, "4_qux.sql": {Data: []byte(migration4)}, } t.Run("invalid", func(t *testing.T) { // Empty dialect not allowed _, err = goose.NewProvider(goose.DialectCustom, db, fsys) require.Error(t, err) // Invalid dialect not allowed _, err = goose.NewProvider("unknown-dialect", db, fsys) require.Error(t, err) // Nil db not allowed _, err = goose.NewProvider(goose.DialectSQLite3, nil, fsys) require.Error(t, err) // Nil store not allowed _, err = goose.NewProvider(goose.DialectSQLite3, db, nil, goose.WithStore(nil)) require.Error(t, err) // Cannot set both dialect and store store, err := database.NewStore(goose.DialectSQLite3, "custom_table") require.NoError(t, err) _, err = goose.NewProvider(goose.DialectSQLite3, db, nil, goose.WithStore(store)) require.Error(t, err) // Multiple stores not allowed _, err = goose.NewProvider(goose.DialectSQLite3, db, nil, goose.WithStore(store), goose.WithStore(store), ) require.Error(t, err) // Cannot set empty table name _, err = goose.NewProvider(goose.DialectSQLite3, db, nil, goose.WithTableName("")) require.Error(t, err) // Cannot set table name when custom store is set _, err = goose.NewProvider(goose.DialectCustom, db, nil, goose.WithStore(store), goose.WithTableName("custom_table"), ) require.Error(t, err) }) t.Run("valid", func(t *testing.T) { // Valid dialect, db, and fsys allowed _, err = goose.NewProvider(goose.DialectSQLite3, db, fsys) require.NoError(t, err) // Valid dialect, db, fsys, and verbose allowed _, err = goose.NewProvider(goose.DialectSQLite3, db, fsys, goose.WithVerbose(testing.Verbose()), ) require.NoError(t, err) // Custom store allowed const tableName = "custom_table" store, err := database.NewStore(goose.DialectSQLite3, tableName) require.NoError(t, err) require.Equal(t, tableName, store.Tablename()) _, err = goose.NewProvider(goose.DialectCustom, db, fsys, goose.WithStore(store)) require.NoError(t, err) // Custom table name allowed on dialect-based store _, err = goose.NewProvider(goose.DialectSQLite3, db, fsys, goose.WithTableName("some_table")) require.NoError(t, err) }) } ================================================ FILE: provider_run.go ================================================ package goose import ( "cmp" "context" "database/sql" "errors" "fmt" "io/fs" "log/slog" "path/filepath" "runtime/debug" "slices" "time" "github.com/pressly/goose/v3/database" "github.com/pressly/goose/v3/internal/sqlparser" "github.com/sethvargo/go-retry" "go.uber.org/multierr" ) var ( errMissingZeroVersion = errors.New("missing zero version migration") ) func (p *Provider) prepareMigration(fsys fs.FS, m *Migration, direction bool) error { switch m.Type { case TypeGo: if m.goUp.Mode == 0 { return errors.New("go up migration mode is not set") } if m.goDown.Mode == 0 { return errors.New("go down migration mode is not set") } var useTx bool if direction { useTx = m.goUp.Mode == TransactionEnabled } else { useTx = m.goDown.Mode == TransactionEnabled } // bug(mf): this is a potential deadlock scenario. We're running Go migrations with *sql.DB, // but are locking the database with *sql.Conn. If the caller sets max open connections to // 1, then this will deadlock because the Go migration will try to acquire a connection from // the pool, but the pool is exhausted because the lock is held. // // A potential solution is to expose a third Go register function *sql.Conn. Or continue to // use *sql.DB and document that the user SHOULD NOT SET max open connections to 1. This is // a bit of an edge case. For now, we guard against this scenario by checking the max open // connections and returning an error. if p.cfg.lockEnabled && p.cfg.sessionLocker != nil && p.db.Stats().MaxOpenConnections == 1 { if !useTx { return errors.New("potential deadlock detected: cannot run Go migration without a transaction when max open connections set to 1") } } return nil case TypeSQL: if m.sql.Parsed { return nil } parsed, err := sqlparser.ParseAllFromFS(fsys, m.Source, false) if err != nil { return err } m.sql.Parsed = true m.sql.UseTx = parsed.UseTx m.sql.Up, m.sql.Down = parsed.Up, parsed.Down return nil } return fmt.Errorf("invalid migration type: %+v", m) } func (p *Provider) logf(ctx context.Context, legacyMsg string, slogMsg string, attrs ...slog.Attr) { if !p.cfg.verbose { return } if p.cfg.slogger != nil { // Sort attributes by key for consistent ordering slices.SortFunc(attrs, func(a, b slog.Attr) int { return cmp.Compare(a.Key, b.Key) }) // Use slog with structured attributes args := make([]any, 0, len(attrs)+1) // Add the logger=goose identifier args = append(args, slog.String("logger", "goose")) for _, attr := range attrs { args = append(args, attr) } p.cfg.slogger.InfoContext(ctx, slogMsg, args...) } else if p.cfg.logger != nil { p.cfg.logger.Printf("goose: %s", legacyMsg) } } // runMigrations runs migrations sequentially in the given direction. If the migrations list is // empty, return nil without error. func (p *Provider) runMigrations( ctx context.Context, conn *sql.Conn, migrations []*Migration, direction sqlparser.Direction, byOne bool, ) ([]*MigrationResult, error) { if len(migrations) == 0 { if !p.cfg.disableVersioning { // No need to print this message if versioning is disabled because there are no // migrations being tracked in the goose version table. maxVersion, err := p.getDBMaxVersion(ctx, conn) if err != nil { return nil, err } p.logf(ctx, fmt.Sprintf("no migrations to run, current version: %d", maxVersion), "no migrations to run", slog.Int64("current_version", maxVersion), ) } return nil, nil } apply := migrations if byOne { apply = migrations[:1] } // SQL migrations are lazily parsed in both directions. This is done before attempting to run // any migrations to catch errors early and prevent leaving the database in an incomplete state. for _, m := range apply { if err := p.prepareMigration(p.fsys, m, direction.ToBool()); err != nil { return nil, fmt.Errorf("failed to prepare migration %s: %w", m.ref(), err) } } // feat(mf): If we decide to add support for advisory locks at the transaction level, this may // be a good place to acquire the lock. However, we need to be sure that ALL migrations are safe // to run in a transaction. // feat(mf): this is where we can (optionally) group multiple migrations to be run in a single // transaction. The default is to apply each migration sequentially on its own. See the // following issues for more details: // - https://github.com/pressly/goose/issues/485 // - https://github.com/pressly/goose/issues/222 // // Be careful, we can't use a single transaction for all migrations because some may be marked // as not using a transaction. var results []*MigrationResult for _, m := range apply { result := &MigrationResult{ Source: &Source{ Type: m.Type, Path: m.Source, Version: m.Version, }, Direction: direction.String(), Empty: isEmpty(m, direction.ToBool()), } start := time.Now() if err := p.runIndividually(ctx, conn, m, direction.ToBool()); err != nil { // TODO(mf): we should also return the pending migrations here, the remaining items in // the apply slice. result.Error = err result.Duration = time.Since(start) return nil, &PartialError{ Applied: results, Failed: result, Err: err, } } result.Duration = time.Since(start) results = append(results, result) // Log the result of the migration. var state string if result.Empty { state = "empty" } else { state = "applied" } p.logf(ctx, result.String(), "migration completed", slog.String("source", filepath.Base(result.Source.Path)), slog.String("direction", result.Direction), slog.Float64("duration_seconds", result.Duration.Seconds()), slog.String("state", state), slog.Int64("version", result.Source.Version), slog.String("type", string(result.Source.Type)), ) } if !p.cfg.disableVersioning && !byOne { maxVersion, err := p.getDBMaxVersion(ctx, conn) if err != nil { return nil, err } p.logf(ctx, fmt.Sprintf("successfully migrated database, current version: %d", maxVersion), "successfully migrated database", slog.Int64("current_version", maxVersion), ) } return results, nil } func (p *Provider) runIndividually( ctx context.Context, conn *sql.Conn, m *Migration, direction bool, ) error { useTx, err := useTx(m, direction) if err != nil { return err } if useTx && !p.cfg.isolateDDL { return beginTx(ctx, conn, func(tx *sql.Tx) error { if err := p.runMigration(ctx, tx, m, direction); err != nil { return err } return p.maybeInsertOrDelete(ctx, tx, m.Version, direction) }) } switch m.Type { case TypeGo: // Note, we are using *sql.DB instead of *sql.Conn because it's the Go migration contract. // This may be a deadlock scenario if max open connections is set to 1 AND a lock is // acquired on the database. In this case, the migration will block forever unable to // acquire a connection from the pool. // // For now, we guard against this scenario by checking the max open connections and // returning an error in the prepareMigration function. if err := p.runMigration(ctx, p.db, m, direction); err != nil { return err } return p.maybeInsertOrDelete(ctx, p.db, m.Version, direction) case TypeSQL: if err := p.runMigration(ctx, conn, m, direction); err != nil { return err } return p.maybeInsertOrDelete(ctx, conn, m.Version, direction) } return fmt.Errorf("failed to run individual migration: neither sql or go: %v", m) } func (p *Provider) maybeInsertOrDelete( ctx context.Context, db database.DBTxConn, version int64, direction bool, ) error { // If versioning is disabled, we don't need to insert or delete the migration version. if p.cfg.disableVersioning { return nil } if direction { return p.store.Insert(ctx, db, database.InsertRequest{Version: version}) } return p.store.Delete(ctx, db, version) } // beginTx begins a transaction and runs the given function. If the function returns an error, the // transaction is rolled back. Otherwise, the transaction is committed. func beginTx(ctx context.Context, conn *sql.Conn, fn func(tx *sql.Tx) error) (retErr error) { tx, err := conn.BeginTx(ctx, nil) if err != nil { return err } defer func() { if retErr != nil { retErr = multierr.Append(retErr, tx.Rollback()) } }() if err := fn(tx); err != nil { return err } return tx.Commit() } func (p *Provider) initialize(ctx context.Context, useLocker bool) (*sql.Conn, func() error, error) { p.mu.Lock() conn, err := p.db.Conn(ctx) if err != nil { p.mu.Unlock() return nil, nil, err } // cleanup is a function that cleans up the connection, and optionally, the lock. cleanup := func() error { p.mu.Unlock() return conn.Close() } // Handle locking if enabled and requested if useLocker && p.cfg.lockEnabled { // Session locker (connection-based locking) if p.cfg.sessionLocker != nil { l := p.cfg.sessionLocker if err := l.SessionLock(ctx, conn); err != nil { return nil, nil, multierr.Append(err, cleanup()) } // A lock was acquired, so we need to unlock the session when we're done. This is done // by returning a cleanup function that unlocks the session and closes the connection. cleanup = func() error { p.mu.Unlock() // Use a detached context to unlock the session. This is because the context passed // to SessionLock may have been canceled, and we don't want to cancel the unlock. return multierr.Append( l.SessionUnlock(context.WithoutCancel(ctx), conn), conn.Close(), ) } } // General locker (db-based locking) if p.cfg.locker != nil { l := p.cfg.locker if err := l.Lock(ctx, p.db); err != nil { return nil, nil, multierr.Append(err, cleanup()) } // A lock was acquired, so we need to unlock when we're done. cleanup = func() error { p.mu.Unlock() // Use a detached context to unlock. This is because the context passed to Lock may // have been canceled, and we don't want to cancel the unlock. return multierr.Append( l.Unlock(context.WithoutCancel(ctx), p.db), conn.Close(), ) } } } // If versioning is enabled, ensure the version table exists. For ad-hoc migrations, we don't // need the version table because no versions are being tracked. if !p.cfg.disableVersioning { if err := p.ensureVersionTable(ctx, conn); err != nil { return nil, nil, multierr.Append(err, cleanup()) } } return conn, cleanup, nil } func (p *Provider) ensureVersionTable( ctx context.Context, conn *sql.Conn, ) (retErr error) { // There are 2 optimizations here: // - 1. We create the version table once per Provider instance. // - 2. We retry the operation a few times in case the table is being created concurrently. // // Regarding item 2, certain goose operations, like HasPending, don't respect a SessionLocker. // So, when goose is run for the first time in a multi-instance environment, it's possible that // multiple instances will try to create the version table at the same time. This is why we // retry this operation a few times. Best case, the table is created by one instance and all the // other instances see that change immediately. Worst case, all instances try to create the // table at the same time, but only one will succeed and the others will retry. p.versionTableOnce.Do(func() { retErr = p.tryEnsureVersionTable(ctx, conn) }) return retErr } func (p *Provider) tryEnsureVersionTable(ctx context.Context, conn *sql.Conn) error { b := retry.NewConstant(1 * time.Second) b = retry.WithMaxRetries(3, b) return retry.Do(ctx, b, func(ctx context.Context) error { exists, err := p.store.TableExists(ctx, conn) if err == nil && exists { return nil } else if err != nil && errors.Is(err, errors.ErrUnsupported) { // Fallback strategy for checking table existence: // // When direct table existence checks aren't supported, we attempt to query the initial // migration (version 0). This approach has two implications: // // 1. If the table exists, the query succeeds and confirms existence // 2. If the table doesn't exist, the query fails and generates an error log // // Note: This check must occur outside any transaction, as a failed query would // otherwise cause the entire transaction to roll back. The error logs generated by this // approach are expected and can be safely ignored. if res, err := p.store.GetMigration(ctx, conn, 0); err == nil && res != nil { return nil } // Fallthrough to create the table. } else if err != nil { return fmt.Errorf("check if version table exists: %w", err) } if p.cfg.isolateDDL { // If isolation is enabled, we create the version table separately to ensure subsequent // DML operations are not mixed with DDL. if err := p.store.CreateVersionTable(ctx, conn); err != nil { return retry.RetryableError(fmt.Errorf("create version table: %w", err)) } if err := p.store.Insert(ctx, conn, database.InsertRequest{Version: 0}); err != nil { return retry.RetryableError(fmt.Errorf("insert zero version: %w", err)) } } else { // If DDL isolation is not enabled, we can create the version table and insert the zero // version in a single transaction. if err := beginTx(ctx, conn, func(tx *sql.Tx) error { if err := p.store.CreateVersionTable(ctx, tx); err != nil { return err } return p.store.Insert(ctx, tx, database.InsertRequest{Version: 0}) }); err != nil { // Mark the error as retryable so we can try again. It's possible that another // instance is creating the table at the same time and the checks above will succeed // on the next iteration. return retry.RetryableError(fmt.Errorf("create version table: %w", err)) } } return nil }) } // getMigration returns the migration for the given version. If no migration is found, then // ErrVersionNotFound is returned. func (p *Provider) getMigration(version int64) (*Migration, error) { for _, m := range p.migrations { if m.Version == version { return m, nil } } return nil, ErrVersionNotFound } // useTx is a helper function that returns true if the migration should be run in a transaction. It // must only be called after the migration has been parsed and initialized. func useTx(m *Migration, direction bool) (bool, error) { switch m.Type { case TypeGo: if m.goUp.Mode == 0 || m.goDown.Mode == 0 { return false, fmt.Errorf("go migrations must have a mode set") } if direction { return m.goUp.Mode == TransactionEnabled, nil } return m.goDown.Mode == TransactionEnabled, nil case TypeSQL: if !m.sql.Parsed { return false, fmt.Errorf("sql migrations must be parsed") } return m.sql.UseTx, nil } return false, fmt.Errorf("use tx: invalid migration type: %q", m.Type) } // isEmpty is a helper function that returns true if the migration has no functions or no statements // to execute. It must only be called after the migration has been parsed and initialized. func isEmpty(m *Migration, direction bool) bool { switch m.Type { case TypeGo: if direction { return m.goUp.RunTx == nil && m.goUp.RunDB == nil } return m.goDown.RunTx == nil && m.goDown.RunDB == nil case TypeSQL: if direction { return len(m.sql.Up) == 0 } return len(m.sql.Down) == 0 } return true } // runMigration is a helper function that runs the migration in the given direction. It must only be // called after the migration has been parsed and initialized. func (p *Provider) runMigration(ctx context.Context, db database.DBTxConn, m *Migration, direction bool) error { switch m.Type { case TypeGo: return p.runGo(ctx, db, m, direction) case TypeSQL: return p.runSQL(ctx, db, m, direction) } return fmt.Errorf("invalid migration type: %q", m.Type) } // runGo is a helper function that runs the given Go functions in the given direction. It must only // be called after the migration has been initialized. func (p *Provider) runGo(ctx context.Context, db database.DBTxConn, m *Migration, direction bool) (retErr error) { defer func() { if r := recover(); r != nil { retErr = fmt.Errorf("panic: %v\n%s", r, debug.Stack()) } }() switch db := db.(type) { case *sql.Conn: return fmt.Errorf("go migrations are not supported with *sql.Conn") case *sql.DB: if direction && m.goUp.RunDB != nil { return m.goUp.RunDB(ctx, db) } if !direction && m.goDown.RunDB != nil { return m.goDown.RunDB(ctx, db) } return nil case *sql.Tx: if direction && m.goUp.RunTx != nil { return m.goUp.RunTx(ctx, db) } if !direction && m.goDown.RunTx != nil { return m.goDown.RunTx(ctx, db) } return nil } return fmt.Errorf("invalid database connection type: %T", db) } // runSQL is a helper function that runs the given SQL statements in the given direction. It must // only be called after the migration has been parsed. func (p *Provider) runSQL(ctx context.Context, db database.DBTxConn, m *Migration, direction bool) error { if !m.sql.Parsed { return fmt.Errorf("sql migrations must be parsed") } var statements []string if direction { statements = m.sql.Up } else { statements = m.sql.Down } for _, stmt := range statements { p.logf(ctx, fmt.Sprintf("Executing statement: %s", stmt), "executing statement", slog.String("statement", stmt), slog.String("source", filepath.Base(m.Source)), slog.Int64("version", m.Version), slog.String("type", string(m.Type)), slog.String("direction", string(sqlparser.FromBool(direction))), ) if _, err := db.ExecContext(ctx, stmt); err != nil { return err } } return nil } ================================================ FILE: provider_run_test.go ================================================ package goose_test import ( "context" "database/sql" "errors" "fmt" "math" "math/rand" "os" "path/filepath" "reflect" "sync" "testing" "testing/fstest" "github.com/pressly/goose/v3" "github.com/pressly/goose/v3/database" "github.com/stretchr/testify/require" ) func TestProviderRun(t *testing.T) { t.Parallel() t.Run("closed_db", func(t *testing.T) { p, db := newProviderWithDB(t) require.NoError(t, db.Close()) _, err := p.Up(context.Background()) require.Error(t, err) require.Equal(t, "failed to initialize: sql: database is closed", err.Error()) }) t.Run("ping_and_close", func(t *testing.T) { p, _ := newProviderWithDB(t) t.Cleanup(func() { require.NoError(t, p.Close()) }) require.NoError(t, p.Ping(context.Background())) }) t.Run("apply_unknown_version", func(t *testing.T) { p, _ := newProviderWithDB(t) _, err := p.ApplyVersion(context.Background(), 999, true) require.Error(t, err) require.ErrorIs(t, err, goose.ErrVersionNotFound) _, err = p.ApplyVersion(context.Background(), 999, false) require.Error(t, err) require.ErrorIs(t, err, goose.ErrVersionNotFound) }) t.Run("run_zero", func(t *testing.T) { p, _ := newProviderWithDB(t) _, err := p.UpTo(context.Background(), 0) require.Error(t, err) require.Equal(t, "version must be greater than 0", err.Error()) _, err = p.DownTo(context.Background(), -1) require.Error(t, err) require.Equal(t, "invalid version: must be a valid number or zero: -1", err.Error()) _, err = p.ApplyVersion(context.Background(), 0, true) require.Error(t, err) require.Equal(t, "version must be greater than 0", err.Error()) }) t.Run("up_and_down_all", func(t *testing.T) { ctx := context.Background() p, _ := newProviderWithDB(t) const ( numCount = 7 ) sources := p.ListSources() require.Len(t, sources, numCount) // Ensure only SQL migrations are returned for _, s := range sources { require.Equal(t, goose.TypeSQL, s.Type) } // Test Up res, err := p.Up(ctx) require.NoError(t, err) require.Len(t, res, numCount) assertResult(t, res[0], newSource(goose.TypeSQL, "00001_users_table.sql", 1), "up", false) assertResult(t, res[1], newSource(goose.TypeSQL, "00002_posts_table.sql", 2), "up", false) assertResult(t, res[2], newSource(goose.TypeSQL, "00003_comments_table.sql", 3), "up", false) assertResult(t, res[3], newSource(goose.TypeSQL, "00004_insert_data.sql", 4), "up", false) assertResult(t, res[4], newSource(goose.TypeSQL, "00005_posts_view.sql", 5), "up", false) assertResult(t, res[5], newSource(goose.TypeSQL, "00006_empty_up.sql", 6), "up", true) assertResult(t, res[6], newSource(goose.TypeSQL, "00007_empty_up_down.sql", 7), "up", true) // Test Down res, err = p.DownTo(ctx, 0) require.NoError(t, err) require.Len(t, res, numCount) assertResult(t, res[0], newSource(goose.TypeSQL, "00007_empty_up_down.sql", 7), "down", true) assertResult(t, res[1], newSource(goose.TypeSQL, "00006_empty_up.sql", 6), "down", true) assertResult(t, res[2], newSource(goose.TypeSQL, "00005_posts_view.sql", 5), "down", false) assertResult(t, res[3], newSource(goose.TypeSQL, "00004_insert_data.sql", 4), "down", false) assertResult(t, res[4], newSource(goose.TypeSQL, "00003_comments_table.sql", 3), "down", false) assertResult(t, res[5], newSource(goose.TypeSQL, "00002_posts_table.sql", 2), "down", false) assertResult(t, res[6], newSource(goose.TypeSQL, "00001_users_table.sql", 1), "down", false) }) t.Run("up_and_down_by_one", func(t *testing.T) { ctx := context.Background() p, _ := newProviderWithDB(t) maxVersion := len(p.ListSources()) // Apply all migrations one-by-one. var counter int for { res, err := p.UpByOne(ctx) counter++ if counter > maxVersion { if !errors.Is(err, goose.ErrNoNextVersion) { t.Fatalf("incorrect error: got:%v want:%v", err, goose.ErrNoNextVersion) } break } require.NoError(t, err) require.NotNil(t, res) require.Equal(t, res.Source.Version, int64(counter)) } currentVersion, err := p.GetDBVersion(ctx) require.NoError(t, err) require.Equal(t, currentVersion, int64(maxVersion)) // Reset counter counter = 0 // Rollback all migrations one-by-one. for { res, err := p.Down(ctx) counter++ if counter > maxVersion { if !errors.Is(err, goose.ErrNoNextVersion) { t.Fatalf("incorrect error: got:%v want:%v", err, goose.ErrNoNextVersion) } break } require.NoError(t, err) require.NotNil(t, res) require.Equal(t, res.Source.Version, int64(maxVersion-counter+1)) } // Once everything is tested the version should match the highest testdata version currentVersion, err = p.GetDBVersion(ctx) require.NoError(t, err) require.EqualValues(t, 0, currentVersion) }) t.Run("up_to", func(t *testing.T) { ctx := context.Background() p, db := newProviderWithDB(t) const ( upToVersion int64 = 2 ) results, err := p.UpTo(ctx, upToVersion) require.NoError(t, err) require.Len(t, results, int(upToVersion)) assertResult(t, results[0], newSource(goose.TypeSQL, "00001_users_table.sql", 1), "up", false) assertResult(t, results[1], newSource(goose.TypeSQL, "00002_posts_table.sql", 2), "up", false) // Fetch the goose version from DB currentVersion, err := p.GetDBVersion(ctx) require.NoError(t, err) require.Equal(t, upToVersion, currentVersion) // Validate the version actually matches what goose claims it is gotVersion, err := getMaxVersionID(db, goose.DefaultTablename) require.NoError(t, err) require.Equal(t, upToVersion, gotVersion) }) t.Run("sql_connections", func(t *testing.T) { tt := []struct { name string maxOpenConns int maxIdleConns int useDefaults bool }{ // Single connection ensures goose is able to function correctly when multiple // connections are not available. {name: "single_conn", maxOpenConns: 1, maxIdleConns: 1}, {name: "defaults", useDefaults: true}, } for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { ctx := context.Background() // Start a new database for each test case. p, db := newProviderWithDB(t) if !tc.useDefaults { db.SetMaxOpenConns(tc.maxOpenConns) db.SetMaxIdleConns(tc.maxIdleConns) } sources := p.ListSources() require.NotEmpty(t, sources) currentVersion, err := p.GetDBVersion(ctx) require.NoError(t, err) require.EqualValues(t, 0, currentVersion) { // Apply all up migrations upResult, err := p.Up(ctx) require.NoError(t, err) require.Len(t, sources, len(upResult)) currentVersion, err := p.GetDBVersion(ctx) require.NoError(t, err) require.Equal(t, currentVersion, p.ListSources()[len(sources)-1].Version) // Validate the db migration version actually matches what goose claims it is gotVersion, err := getMaxVersionID(db, goose.DefaultTablename) require.NoError(t, err) require.Equal(t, gotVersion, currentVersion) tables, err := getTableNames(db) require.NoError(t, err) if !reflect.DeepEqual(tables, knownTables) { t.Logf("got tables: %v", tables) t.Logf("known tables: %v", knownTables) t.Fatal("failed to match tables") } } { // Apply all down migrations downResult, err := p.DownTo(ctx, 0) require.NoError(t, err) require.Len(t, sources, len(downResult)) gotVersion, err := getMaxVersionID(db, goose.DefaultTablename) require.NoError(t, err) require.EqualValues(t, 0, gotVersion) // Should only be left with a single table, the default goose table tables, err := getTableNames(db) require.NoError(t, err) knownTables := []string{goose.DefaultTablename, "sqlite_sequence"} if !reflect.DeepEqual(tables, knownTables) { t.Logf("got tables: %v", tables) t.Logf("known tables: %v", knownTables) t.Fatal("failed to match tables") } } }) } }) t.Run("apply", func(t *testing.T) { ctx := context.Background() p, _ := newProviderWithDB(t) sources := p.ListSources() // Apply all migrations in the up direction. for _, s := range sources { res, err := p.ApplyVersion(ctx, s.Version, true) require.NoError(t, err) // Round-trip the migration result through the database to ensure it's valid. var empty bool if s.Version == 6 || s.Version == 7 { empty = true } assertResult(t, res, s, "up", empty) } // Apply all migrations in the down direction. for i := len(sources) - 1; i >= 0; i-- { s := sources[i] res, err := p.ApplyVersion(ctx, s.Version, false) require.NoError(t, err) // Round-trip the migration result through the database to ensure it's valid. var empty bool if s.Version == 6 || s.Version == 7 { empty = true } assertResult(t, res, s, "down", empty) } // Try apply version 1 multiple times _, err := p.ApplyVersion(ctx, 1, true) require.NoError(t, err) _, err = p.ApplyVersion(ctx, 1, true) require.Error(t, err) require.ErrorIs(t, err, goose.ErrAlreadyApplied) require.Contains(t, err.Error(), "version 1: migration already applied") t.Run("no_versioning", func(t *testing.T) { p, db := newProviderWithDB(t, goose.WithDisableVersioning(true)) _, err := p.ApplyVersion(ctx, 1, true) require.NoError(t, err) tables, err := getTableNames(db) require.NoError(t, err) // When versioning is disabled and a single migration is applied, the only table // expected is whatever the migration creates. No goose table is created. knownTables := []string{"users"} require.Equal(t, knownTables, tables) }) }) t.Run("status", func(t *testing.T) { ctx := context.Background() p, _ := newProviderWithDB(t) numCount := len(p.ListSources()) // Before any migrations are applied, the status should be empty. status, err := p.Status(ctx) require.NoError(t, err) require.Equal(t, len(status), numCount) assertStatus(t, status[0], goose.StatePending, newSource(goose.TypeSQL, "00001_users_table.sql", 1), true) assertStatus(t, status[1], goose.StatePending, newSource(goose.TypeSQL, "00002_posts_table.sql", 2), true) assertStatus(t, status[2], goose.StatePending, newSource(goose.TypeSQL, "00003_comments_table.sql", 3), true) assertStatus(t, status[3], goose.StatePending, newSource(goose.TypeSQL, "00004_insert_data.sql", 4), true) assertStatus(t, status[4], goose.StatePending, newSource(goose.TypeSQL, "00005_posts_view.sql", 5), true) assertStatus(t, status[5], goose.StatePending, newSource(goose.TypeSQL, "00006_empty_up.sql", 6), true) assertStatus(t, status[6], goose.StatePending, newSource(goose.TypeSQL, "00007_empty_up_down.sql", 7), true) // Apply all migrations _, err = p.Up(ctx) require.NoError(t, err) status, err = p.Status(ctx) require.NoError(t, err) require.Equal(t, len(status), numCount) assertStatus(t, status[0], goose.StateApplied, newSource(goose.TypeSQL, "00001_users_table.sql", 1), false) assertStatus(t, status[1], goose.StateApplied, newSource(goose.TypeSQL, "00002_posts_table.sql", 2), false) assertStatus(t, status[2], goose.StateApplied, newSource(goose.TypeSQL, "00003_comments_table.sql", 3), false) assertStatus(t, status[3], goose.StateApplied, newSource(goose.TypeSQL, "00004_insert_data.sql", 4), false) assertStatus(t, status[4], goose.StateApplied, newSource(goose.TypeSQL, "00005_posts_view.sql", 5), false) assertStatus(t, status[5], goose.StateApplied, newSource(goose.TypeSQL, "00006_empty_up.sql", 6), false) assertStatus(t, status[6], goose.StateApplied, newSource(goose.TypeSQL, "00007_empty_up_down.sql", 7), false) }) t.Run("tx_partial_errors", func(t *testing.T) { countOwners := func(db *sql.DB) (int, error) { q := `SELECT count(*)FROM owners` var count int if err := db.QueryRow(q).Scan(&count); err != nil { return 0, err } return count, nil } ctx := context.Background() db := newDB(t) mapFS := fstest.MapFS{ "00001_users_table.sql": newMapFile(` -- +goose Up CREATE TABLE owners ( owner_name TEXT NOT NULL ); `), "00002_partial_error.sql": newMapFile(` -- +goose Up INSERT INTO invalid_table (invalid_table) VALUES ('invalid_value'); `), "00003_insert_data.sql": newMapFile(` -- +goose Up INSERT INTO owners (owner_name) VALUES ('seed-user-1'); INSERT INTO owners (owner_name) VALUES ('seed-user-2'); INSERT INTO owners (owner_name) VALUES ('seed-user-3'); `), } p, err := goose.NewProvider(goose.DialectSQLite3, db, mapFS) require.NoError(t, err) _, err = p.Up(ctx) require.Error(t, err) require.Contains(t, err.Error(), "partial migration error (type:sql,version:2)") expected := new(goose.PartialError) require.ErrorAs(t, err, &expected) // Check Err field require.Contains(t, expected.Err.Error(), "SQL logic error: no such table: invalid_table (1)") // Check Results field require.Len(t, expected.Applied, 1) assertResult(t, expected.Applied[0], newSource(goose.TypeSQL, "00001_users_table.sql", 1), "up", false) // Check Failed field require.NotNil(t, expected.Failed) assertSource(t, expected.Failed.Source, goose.TypeSQL, "00002_partial_error.sql", 2) require.False(t, expected.Failed.Empty) require.Error(t, expected.Failed.Error) require.Contains(t, expected.Failed.Error.Error(), "SQL logic error: no such table: invalid_table (1)") require.Equal(t, "up", expected.Failed.Direction) require.Positive(t, expected.Failed.Duration) // Ensure the partial error did not affect the database. count, err := countOwners(db) require.NoError(t, err) require.Equal(t, 0, count) status, err := p.Status(ctx) require.NoError(t, err) require.Len(t, status, 3) assertStatus(t, status[0], goose.StateApplied, newSource(goose.TypeSQL, "00001_users_table.sql", 1), false) assertStatus(t, status[1], goose.StatePending, newSource(goose.TypeSQL, "00002_partial_error.sql", 2), true) assertStatus(t, status[2], goose.StatePending, newSource(goose.TypeSQL, "00003_insert_data.sql", 3), true) }) t.Run("isolate_ddl", func(t *testing.T) { ctx := context.Background() p, _ := newProviderWithDB(t, goose.WithIsolateDDL(true)) // Apply all migrations res, err := p.Up(ctx) require.NoError(t, err) require.Len(t, res, 7) currentVersion, err := p.GetDBVersion(ctx) require.NoError(t, err) require.EqualValues(t, 7, currentVersion) }) } func TestConcurrentProvider(t *testing.T) { t.Parallel() t.Run("up", func(t *testing.T) { ctx := context.Background() p, _ := newProviderWithDB(t) maxVersion := len(p.ListSources()) ch := make(chan int64) var wg sync.WaitGroup for range maxVersion { wg.Go(func() { res, err := p.UpByOne(ctx) if err != nil { t.Error(err) return } if res == nil { t.Errorf("expected non-nil result, got nil") return } ch <- res.Source.Version }) } go func() { wg.Wait() close(ch) }() var versions []int64 for version := range ch { versions = append(versions, version) } // Fail early if any of the goroutines failed. if t.Failed() { return } require.Equal(t, len(versions), maxVersion) for i := range maxVersion { require.Equal(t, versions[i], int64(i+1)) } currentVersion, err := p.GetDBVersion(ctx) require.NoError(t, err) require.EqualValues(t, currentVersion, maxVersion) }) t.Run("down", func(t *testing.T) { ctx := context.Background() p, _ := newProviderWithDB(t) maxVersion := len(p.ListSources()) // Apply all migrations _, err := p.Up(ctx) require.NoError(t, err) currentVersion, err := p.GetDBVersion(ctx) require.NoError(t, err) require.EqualValues(t, currentVersion, maxVersion) ch := make(chan []*goose.MigrationResult) var wg sync.WaitGroup for range maxVersion { wg.Go(func() { res, err := p.DownTo(ctx, 0) if err != nil { t.Error(err) return } ch <- res }) } go func() { wg.Wait() close(ch) }() var ( valid [][]*goose.MigrationResult empty [][]*goose.MigrationResult ) for results := range ch { if len(results) == 0 { empty = append(empty, results) continue } valid = append(valid, results) } // Fail early if any of the goroutines failed. if t.Failed() { return } require.Len(t, valid, 1) require.Equal(t, len(empty), maxVersion-1) // Ensure the valid result is correct. require.Equal(t, len(valid[0]), maxVersion) }) } func TestNoVersioning(t *testing.T) { t.Parallel() countSeedOwners := func(db *sql.DB) (int, error) { q := `SELECT count(*)FROM owners WHERE owner_name LIKE'seed-user-%'` var count int if err := db.QueryRow(q).Scan(&count); err != nil { return 0, err } return count, nil } countOwners := func(db *sql.DB) (int, error) { q := `SELECT count(*)FROM owners` var count int if err := db.QueryRow(q).Scan(&count); err != nil { return 0, err } return count, nil } ctx := context.Background() dbName := fmt.Sprintf("test_%s.db", randomAlphaNumeric(8)) db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), dbName)) require.NoError(t, err) fsys := os.DirFS(filepath.Join("testdata", "no-versioning", "migrations")) const ( // Total owners created by the seed files. wantSeedOwnerCount = 250 // These are owners created by migration files. wantOwnerCount = 4 ) p, err := goose.NewProvider(goose.DialectSQLite3, db, fsys, goose.WithVerbose(testing.Verbose()), goose.WithDisableVersioning(false), // This is the default. ) require.Len(t, p.ListSources(), 3) require.NoError(t, err) _, err = p.Up(ctx) require.NoError(t, err) baseVersion, err := p.GetDBVersion(ctx) require.NoError(t, err) require.EqualValues(t, 3, baseVersion) t.Run("seed-up-down-to-zero", func(t *testing.T) { fsys := os.DirFS(filepath.Join("testdata", "no-versioning", "seed")) p, err := goose.NewProvider(goose.DialectSQLite3, db, fsys, goose.WithVerbose(testing.Verbose()), goose.WithDisableVersioning(true), // Provider with no versioning. ) require.NoError(t, err) require.Len(t, p.ListSources(), 2) // Run (all) up migrations from the seed dir { upResult, err := p.Up(ctx) require.NoError(t, err) require.Len(t, upResult, 2) // When versioning is disabled, we cannot track the version of the seed files. _, err = p.GetDBVersion(ctx) require.Error(t, err) seedOwnerCount, err := countSeedOwners(db) require.NoError(t, err) require.Equal(t, wantSeedOwnerCount, seedOwnerCount) } // Run (all) down migrations from the seed dir { downResult, err := p.DownTo(ctx, 0) require.NoError(t, err) require.Len(t, downResult, 2) // When versioning is disabled, we cannot track the version of the seed files. _, err = p.GetDBVersion(ctx) require.Error(t, err) seedOwnerCount, err := countSeedOwners(db) require.NoError(t, err) require.Equal(t, 0, seedOwnerCount) } // The migrations added 4 non-seed owners, they must remain in the database afterwards ownerCount, err := countOwners(db) require.NoError(t, err) require.Equal(t, wantOwnerCount, ownerCount) }) } func TestAllowMissing(t *testing.T) { t.Parallel() ctx := context.Background() // Developer A and B check out the "main" branch which is currently on version 3. Developer A // mistakenly creates migration 5 and commits. Developer B did not pull the latest changes and // commits migration 4. Oops -- now the migrations are out of order. // // When goose is set to allow missing migrations, then 5 is applied after 4 with no error. // Otherwise it's expected to be an error. t.Run("missing_now_allowed", func(t *testing.T) { db := newDB(t) p, err := goose.NewProvider(goose.DialectSQLite3, db, newFsys(), goose.WithAllowOutofOrder(false), ) require.NoError(t, err) // Create and apply first 3 migrations. _, err = p.UpTo(ctx, 3) require.NoError(t, err) currentVersion, err := p.GetDBVersion(ctx) require.NoError(t, err) require.EqualValues(t, 3, currentVersion) // Developer A - migration 5 (mistakenly applied) result, err := p.ApplyVersion(ctx, 5, true) require.NoError(t, err) require.EqualValues(t, 5, result.Source.Version) current, err := p.GetDBVersion(ctx) require.NoError(t, err) require.EqualValues(t, 5, current) // The database has migrations 1,2,3,5 applied. // Developer B is on version 3 (e.g., never pulled the latest changes). Adds migration 4. By // default goose does not allow missing (out-of-order) migrations, which means halt if a // missing migration is detected. _, err = p.Up(ctx) require.Error(t, err) // found 1 missing (out-of-order) migration: [00004_insert_data.sql] require.Contains(t, err.Error(), "missing (out-of-order) migration") // Confirm db version is unchanged. current, err = p.GetDBVersion(ctx) require.NoError(t, err) require.EqualValues(t, 5, current) _, err = p.UpByOne(ctx) require.Error(t, err) // found 1 missing (out-of-order) migration: [00004_insert_data.sql] require.Contains(t, err.Error(), "missing (out-of-order) migration") // Confirm db version is unchanged. current, err = p.GetDBVersion(ctx) require.NoError(t, err) require.EqualValues(t, 5, current) _, err = p.UpTo(ctx, math.MaxInt64) require.Error(t, err) // found 1 missing (out-of-order) migration: [00004_insert_data.sql] require.Contains(t, err.Error(), "missing (out-of-order) migration") // Confirm db version is unchanged. current, err = p.GetDBVersion(ctx) require.NoError(t, err) require.EqualValues(t, 5, current) }) t.Run("missing_allowed", func(t *testing.T) { db := newDB(t) p, err := goose.NewProvider(goose.DialectSQLite3, db, newFsys(), goose.WithAllowOutofOrder(true), ) require.NoError(t, err) // Create and apply first 3 migrations. _, err = p.UpTo(ctx, 3) require.NoError(t, err) currentVersion, err := p.GetDBVersion(ctx) require.NoError(t, err) require.EqualValues(t, 3, currentVersion) // Developer A - migration 5 (mistakenly applied) { _, err = p.ApplyVersion(ctx, 5, true) require.NoError(t, err) current, err := p.GetDBVersion(ctx) require.NoError(t, err) require.EqualValues(t, 5, current) } // Developer B - migration 4 (missing) and 6 (new) { // 4 upResult, err := p.UpByOne(ctx) require.NoError(t, err) require.NotNil(t, upResult) require.EqualValues(t, 4, upResult.Source.Version) // 6 upResult, err = p.UpByOne(ctx) require.NoError(t, err) require.NotNil(t, upResult) require.EqualValues(t, 6, upResult.Source.Version) count, err := getGooseVersionCount(db, goose.DefaultTablename) require.NoError(t, err) require.EqualValues(t, 6, count) current, err := p.GetDBVersion(ctx) require.NoError(t, err) // Expecting max(version_id) to be 8 require.EqualValues(t, 6, current) } // The applied order in the database is expected to be: // 1,2,3,5,4,6 // So migrating down should be the reverse of the applied order: // 6,4,5,3,2,1 testDownAndVersion := func(wantDBVersion, wantResultVersion int64) { currentVersion, err := p.GetDBVersion(ctx) require.NoError(t, err) require.Equal(t, wantDBVersion, currentVersion) downRes, err := p.Down(ctx) require.NoError(t, err) require.NotNil(t, downRes) require.Equal(t, wantResultVersion, downRes.Source.Version) } // This behaviour may need to change, see the following issues for more details: // - https://github.com/pressly/goose/issues/523 // - https://github.com/pressly/goose/issues/402 testDownAndVersion(6, 6) testDownAndVersion(5, 4) // Ensure the max db version is 5 before down. testDownAndVersion(5, 5) testDownAndVersion(3, 3) testDownAndVersion(2, 2) testDownAndVersion(1, 1) _, err = p.Down(ctx) require.Error(t, err) require.ErrorIs(t, err, goose.ErrNoNextVersion) }) } func TestSQLiteSharedCache(t *testing.T) { t.Parallel() // goose uses *sql.Conn for most operations (incl. creating the initial table), but for Go // migrations when running outside a transaction we use *sql.DB. This is a problem for SQLite // because it does not support shared cache mode by default and it does not see the table that // was created through the initial connection. This test ensures goose works with SQLite shared // cache mode. // // Ref: https://www.sqlite.org/inmemorydb.html // // "In-memory databases are allowed to use shared cache if they are opened using a URI filename. // If the unadorned ":memory:" name is used to specify the in-memory database, then that // database always has a private cache and is only visible to the database connection that // originally opened it. However, the same in-memory database can be opened by two or more // database connections as follows: file::memory:?cache=shared" t.Run("shared_cache", func(t *testing.T) { db, err := sql.Open("sqlite", "file::memory:?cache=shared") require.NoError(t, err) fsys := fstest.MapFS{"00001_a.sql": newMapFile(`-- +goose Up`)} p, err := goose.NewProvider(goose.DialectSQLite3, db, fsys, goose.WithGoMigrations( goose.NewGoMigration(2, &goose.GoFunc{Mode: goose.TransactionDisabled}, nil), ), ) require.NoError(t, err) _, err = p.Up(context.Background()) require.NoError(t, err) }) t.Run("no_shared_cache", func(t *testing.T) { db, err := sql.Open("sqlite", "file::memory:") require.NoError(t, err) fsys := fstest.MapFS{"00001_a.sql": newMapFile(`-- +goose Up`)} p, err := goose.NewProvider(goose.DialectSQLite3, db, fsys, goose.WithGoMigrations( goose.NewGoMigration(2, &goose.GoFunc{Mode: goose.TransactionDisabled}, nil), ), ) require.NoError(t, err) _, err = p.Up(context.Background()) require.Error(t, err) require.Contains(t, err.Error(), "SQL logic error: no such table: goose_db_version") }) } func TestGoMigrationPanic(t *testing.T) { t.Parallel() ctx := context.Background() const ( wantErrString = "panic: runtime error: index out of range [7] with length 0" ) migration := goose.NewGoMigration( 1, &goose.GoFunc{RunTx: func(ctx context.Context, tx *sql.Tx) error { var ss []int _ = ss[7] return nil }}, nil, ) p, err := goose.NewProvider(goose.DialectSQLite3, newDB(t), nil, goose.WithGoMigrations(migration), // Add a Go migration that panics. ) require.NoError(t, err) _, err = p.Up(ctx) require.Error(t, err) expected := new(goose.PartialError) require.ErrorAs(t, err, &expected) require.Contains(t, expected.Err.Error(), wantErrString) } func TestCustomStoreTableExists(t *testing.T) { t.Parallel() db := newDB(t) store, err := database.NewStore(database.DialectSQLite3, goose.DefaultTablename) require.NoError(t, err) for range 2 { p, err := goose.NewProvider(goose.DialectCustom, db, newFsys(), goose.WithStore(&customStoreSQLite3{store}), ) require.NoError(t, err) _, err = p.Up(context.Background()) require.NoError(t, err) } } func TestProviderApply(t *testing.T) { t.Parallel() ctx := context.Background() p, err := goose.NewProvider(goose.DialectSQLite3, newDB(t), newFsys()) require.NoError(t, err) _, err = p.ApplyVersion(ctx, 1, true) require.NoError(t, err) // This version has a corresponding down migration, but has never been applied. _, err = p.ApplyVersion(ctx, 2, false) require.Error(t, err) require.ErrorIs(t, err, goose.ErrNotApplied) } func TestPending(t *testing.T) { t.Parallel() t.Run("allow_out_of_order", func(t *testing.T) { ctx := context.Background() fsys := newFsys() p, err := goose.NewProvider(goose.DialectSQLite3, newDB(t), fsys, goose.WithAllowOutofOrder(true), ) require.NoError(t, err) // Some migrations have been applied out of order. _, err = p.ApplyVersion(ctx, 1, true) require.NoError(t, err) _, err = p.ApplyVersion(ctx, 3, true) require.NoError(t, err) // Even though the latest migration HAS been applied, there are still pending out-of-order // migrations. current, target, err := p.GetVersions(ctx) require.NoError(t, err) require.EqualValues(t, 3, current) require.Len(t, fsys, int(target)) hasPending, err := p.HasPending(ctx) require.NoError(t, err) require.True(t, hasPending) // Apply the missing migrations. _, err = p.Up(ctx) require.NoError(t, err) // All migrations have been applied. hasPending, err = p.HasPending(ctx) require.NoError(t, err) require.False(t, hasPending) current, target, err = p.GetVersions(ctx) require.NoError(t, err) require.Equal(t, current, target) }) t.Run("disallow_out_of_order", func(t *testing.T) { ctx := context.Background() fsys := newFsys() run := func(t *testing.T, versionToApply int64) { t.Helper() p, err := goose.NewProvider(goose.DialectSQLite3, newDB(t), fsys, goose.WithAllowOutofOrder(false), ) require.NoError(t, err) // Some migrations have been applied. _, err = p.ApplyVersion(ctx, 1, true) require.NoError(t, err) _, err = p.ApplyVersion(ctx, versionToApply, true) require.NoError(t, err) // TODO(mf): revisit the pending check behavior in addition to the HasPending // method. current, target, err := p.GetVersions(ctx) require.NoError(t, err) require.Equal(t, current, versionToApply) require.Len(t, fsys, int(target)) _, err = p.HasPending(ctx) require.Error(t, err) require.Contains(t, err.Error(), "missing (out-of-order) migration") _, err = p.Up(ctx) require.Error(t, err) require.Contains(t, err.Error(), "missing (out-of-order) migration") } t.Run("latest_version", func(t *testing.T) { run(t, int64(len(fsys))) }) t.Run("latest_version_minus_one", func(t *testing.T) { run(t, int64(len(fsys)-1)) }) }) } var _ database.StoreExtender = (*customStoreSQLite3)(nil) type customStoreSQLite3 struct{ database.Store } func (s *customStoreSQLite3) TableExists(ctx context.Context, db database.DBTxConn) (bool, error) { q := `SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type='table' AND name=?) AS table_exists` var exists bool if err := db.QueryRowContext(ctx, q, s.Tablename()).Scan(&exists); err != nil { return false, err } return exists, nil } func getGooseVersionCount(db *sql.DB, gooseTable string) (int64, error) { var gotVersion int64 if err := db.QueryRow( fmt.Sprintf("SELECT count(*) FROM %s WHERE version_id > 0", gooseTable), ).Scan(&gotVersion); err != nil { return 0, err } return gotVersion, nil } func TestGoOnly(t *testing.T) { t.Cleanup(goose.ResetGlobalMigrations) // Not parallel because each subtest modifies global state. countUser := func(db *sql.DB) int { q := `SELECT count(*)FROM users` var count int err := db.QueryRow(q).Scan(&count) require.NoError(t, err) return count } t.Run("with_tx", func(t *testing.T) { ctx := context.Background() register := []*goose.Migration{ goose.NewGoMigration( 1, &goose.GoFunc{RunTx: newTxFn("CREATE TABLE users (id INTEGER PRIMARY KEY)")}, &goose.GoFunc{RunTx: newTxFn("DROP TABLE users")}, ), } err := goose.SetGlobalMigrations(register...) require.NoError(t, err) t.Cleanup(goose.ResetGlobalMigrations) db := newDB(t) register = []*goose.Migration{ goose.NewGoMigration( 2, &goose.GoFunc{RunTx: newTxFn("INSERT INTO users (id) VALUES (1), (2), (3)")}, &goose.GoFunc{RunTx: newTxFn("DELETE FROM users")}, ), } p, err := goose.NewProvider(goose.DialectSQLite3, db, nil, goose.WithGoMigrations(register...), ) require.NoError(t, err) sources := p.ListSources() require.Len(t, p.ListSources(), 2) assertSource(t, sources[0], goose.TypeGo, "", 1) assertSource(t, sources[1], goose.TypeGo, "", 2) // Apply migration 1 res, err := p.UpByOne(ctx) require.NoError(t, err) assertResult(t, res, newSource(goose.TypeGo, "", 1), "up", false) require.Equal(t, 0, countUser(db)) require.True(t, tableExists(t, db, "users")) // Apply migration 2 res, err = p.UpByOne(ctx) require.NoError(t, err) assertResult(t, res, newSource(goose.TypeGo, "", 2), "up", false) require.Equal(t, 3, countUser(db)) // Rollback migration 2 res, err = p.Down(ctx) require.NoError(t, err) assertResult(t, res, newSource(goose.TypeGo, "", 2), "down", false) require.Equal(t, 0, countUser(db)) // Rollback migration 1 res, err = p.Down(ctx) require.NoError(t, err) assertResult(t, res, newSource(goose.TypeGo, "", 1), "down", false) // Check table does not exist require.False(t, tableExists(t, db, "users")) }) t.Run("with_db", func(t *testing.T) { ctx := context.Background() register := []*goose.Migration{ goose.NewGoMigration( 1, &goose.GoFunc{ RunDB: newDBFn("CREATE TABLE users (id INTEGER PRIMARY KEY)"), }, &goose.GoFunc{ RunDB: newDBFn("DROP TABLE users"), }, ), } err := goose.SetGlobalMigrations(register...) require.NoError(t, err) t.Cleanup(goose.ResetGlobalMigrations) db := newDB(t) register = []*goose.Migration{ goose.NewGoMigration( 2, &goose.GoFunc{RunDB: newDBFn("INSERT INTO users (id) VALUES (1), (2), (3)")}, &goose.GoFunc{RunDB: newDBFn("DELETE FROM users")}, ), } p, err := goose.NewProvider(goose.DialectSQLite3, db, nil, goose.WithGoMigrations(register...), ) require.NoError(t, err) sources := p.ListSources() require.Len(t, p.ListSources(), 2) assertSource(t, sources[0], goose.TypeGo, "", 1) assertSource(t, sources[1], goose.TypeGo, "", 2) // Apply migration 1 res, err := p.UpByOne(ctx) require.NoError(t, err) assertResult(t, res, newSource(goose.TypeGo, "", 1), "up", false) require.Equal(t, 0, countUser(db)) require.True(t, tableExists(t, db, "users")) // Apply migration 2 res, err = p.UpByOne(ctx) require.NoError(t, err) assertResult(t, res, newSource(goose.TypeGo, "", 2), "up", false) require.Equal(t, 3, countUser(db)) // Rollback migration 2 res, err = p.Down(ctx) require.NoError(t, err) assertResult(t, res, newSource(goose.TypeGo, "", 2), "down", false) require.Equal(t, 0, countUser(db)) // Rollback migration 1 res, err = p.Down(ctx) require.NoError(t, err) assertResult(t, res, newSource(goose.TypeGo, "", 1), "down", false) // Check table does not exist require.False(t, tableExists(t, db, "users")) }) } func newDBFn(query string) func(context.Context, *sql.DB) error { return func(ctx context.Context, db *sql.DB) error { _, err := db.ExecContext(ctx, query) return err } } func newTxFn(query string) func(context.Context, *sql.Tx) error { return func(ctx context.Context, tx *sql.Tx) error { _, err := tx.ExecContext(ctx, query) return err } } func tableExists(t *testing.T, db *sql.DB, table string) bool { q := fmt.Sprintf(`SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END AS table_exists FROM sqlite_master WHERE type = 'table' AND name = '%s'`, table) var b string err := db.QueryRow(q).Scan(&b) require.NoError(t, err) return b == "1" } const ( charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" ) func randomAlphaNumeric(length int) string { b := make([]byte, length) for i := range b { b[i] = charset[rand.Intn(len(charset))] } return string(b) } func newProviderWithDB(t *testing.T, opts ...goose.ProviderOption) (*goose.Provider, *sql.DB) { t.Helper() db := newDB(t) opts = append( opts, goose.WithVerbose(testing.Verbose()), ) p, err := goose.NewProvider(goose.DialectSQLite3, db, newFsys(), opts...) require.NoError(t, err) return p, db } func newDB(t *testing.T) *sql.DB { t.Helper() dbName := fmt.Sprintf("test_%s.db", randomAlphaNumeric(8)) db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), dbName)) require.NoError(t, err) return db } func getMaxVersionID(db *sql.DB, gooseTable string) (int64, error) { var gotVersion int64 if err := db.QueryRow( fmt.Sprintf("select max(version_id) from %s", gooseTable), ).Scan(&gotVersion); err != nil { return 0, err } return gotVersion, nil } func getTableNames(db *sql.DB) ([]string, error) { rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`) if err != nil { return nil, err } defer rows.Close() var tables []string for rows.Next() { var name string if err := rows.Scan(&name); err != nil { return nil, err } tables = append(tables, name) } if err := rows.Err(); err != nil { return nil, err } return tables, nil } func assertStatus( t *testing.T, got *goose.MigrationStatus, state goose.State, source *goose.Source, appliedIsZero bool, ) { t.Helper() require.Equal(t, got.State, state) require.Equal(t, got.Source, source) require.Equal(t, got.AppliedAt.IsZero(), appliedIsZero) } func assertResult( t *testing.T, got *goose.MigrationResult, source *goose.Source, direction string, isEmpty bool, ) { t.Helper() require.NotNil(t, got) require.Equal(t, got.Source, source) require.Equal(t, got.Direction, direction) require.Equal(t, got.Empty, isEmpty) require.NoError(t, got.Error) require.Positive(t, got.Duration) } func assertSource(t *testing.T, got *goose.Source, typ goose.MigrationType, name string, version int64) { t.Helper() require.Equal(t, got.Type, typ) require.Equal(t, got.Path, name) require.Equal(t, got.Version, version) } func newSource(t goose.MigrationType, fullpath string, version int64) *goose.Source { return &goose.Source{ Type: t, Path: fullpath, Version: version, } } func newMapFile(data string) *fstest.MapFile { return &fstest.MapFile{ Data: []byte(data), } } func newFsys() fstest.MapFS { return fstest.MapFS{ "00001_users_table.sql": newMapFile(runMigration1), "00002_posts_table.sql": newMapFile(runMigration2), "00003_comments_table.sql": newMapFile(runMigration3), "00004_insert_data.sql": newMapFile(runMigration4), "00005_posts_view.sql": newMapFile(runMigration5), "00006_empty_up.sql": newMapFile(runMigration6), "00007_empty_up_down.sql": newMapFile(runMigration7), } } var ( // known tables are the tables (including goose table) created by running all migration files. // If you add a table, make sure to add to this list and keep it in order. knownTables = []string{ "comments", "goose_db_version", "posts", "sqlite_sequence", "users", } runMigration1 = ` -- +goose Up CREATE TABLE users ( id INTEGER PRIMARY KEY, username TEXT NOT NULL, email TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- +goose Down DROP TABLE users; ` runMigration2 = ` -- +goose Up -- +goose StatementBegin CREATE TABLE posts ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL, author_id INTEGER NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (author_id) REFERENCES users(id) ); -- +goose StatementEnd SELECT 1; SELECT 2; -- +goose Down DROP TABLE posts; ` runMigration3 = ` -- +goose Up CREATE TABLE comments ( id INTEGER PRIMARY KEY, post_id INTEGER NOT NULL, user_id INTEGER NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (post_id) REFERENCES posts(id), FOREIGN KEY (user_id) REFERENCES users(id) ); -- +goose Down DROP TABLE comments; SELECT 1; SELECT 2; SELECT 3; ` runMigration4 = ` -- +goose Up INSERT INTO users (id, username, email) VALUES (1, 'john_doe', 'john@example.com'), (2, 'jane_smith', 'jane@example.com'), (3, 'alice_wonderland', 'alice@example.com'); INSERT INTO posts (id, title, content, author_id) VALUES (1, 'Introduction to SQL', 'SQL is a powerful language for managing databases...', 1), (2, 'Data Modeling Techniques', 'Choosing the right data model is crucial...', 2), (3, 'Advanced Query Optimization', 'Optimizing queries can greatly improve...', 1); INSERT INTO comments (id, post_id, user_id, content) VALUES (1, 1, 3, 'Great introduction! Looking forward to more.'), (2, 1, 2, 'SQL can be a bit tricky at first, but practice helps.'), (3, 2, 1, 'You covered normalization really well in this post.'); -- +goose Down DELETE FROM comments; DELETE FROM posts; DELETE FROM users; ` runMigration5 = ` -- +goose NO TRANSACTION -- +goose Up CREATE VIEW posts_view AS SELECT p.id, p.title, p.content, p.created_at, u.username AS author FROM posts p JOIN users u ON p.author_id = u.id; -- +goose Down DROP VIEW posts_view; ` runMigration6 = ` -- +goose Up ` runMigration7 = ` -- +goose Up -- +goose Down ` ) ================================================ FILE: provider_test.go ================================================ package goose_test import ( "database/sql" "io/fs" "path/filepath" "testing" "testing/fstest" "github.com/pressly/goose/v3" "github.com/stretchr/testify/require" _ "modernc.org/sqlite" ) func TestProvider(t *testing.T) { dir := t.TempDir() db, err := sql.Open("sqlite", filepath.Join(dir, "sql_embed.db")) require.NoError(t, err) t.Run("empty", func(t *testing.T) { _, err := goose.NewProvider(goose.DialectSQLite3, db, fstest.MapFS{}) require.Error(t, err) require.ErrorIs(t, err, goose.ErrNoMigrations) }) mapFS := fstest.MapFS{ "migrations/001_foo.sql": {Data: []byte(`-- +goose Up`)}, "migrations/002_bar.sql": {Data: []byte(`-- +goose Up`)}, } fsys, err := fs.Sub(mapFS, "migrations") require.NoError(t, err) p, err := goose.NewProvider(goose.DialectSQLite3, db, fsys) require.NoError(t, err) sources := p.ListSources() require.Len(t, sources, 2) require.Equal(t, sources[0], newSource(goose.TypeSQL, "001_foo.sql", 1)) require.Equal(t, sources[1], newSource(goose.TypeSQL, "002_bar.sql", 2)) } var ( migration1 = ` -- +goose Up CREATE TABLE foo (id INTEGER PRIMARY KEY); -- +goose Down DROP TABLE foo; ` migration2 = ` -- +goose Up ALTER TABLE foo ADD COLUMN name TEXT; -- +goose Down ALTER TABLE foo DROP COLUMN name; ` migration3 = ` -- +goose Up CREATE TABLE bar ( id INTEGER PRIMARY KEY, description TEXT ); -- +goose Down DROP TABLE bar; ` migration4 = ` -- +goose Up -- Rename the 'foo' table to 'my_foo' ALTER TABLE foo RENAME TO my_foo; -- Add a new column 'timestamp' to 'my_foo' ALTER TABLE my_foo ADD COLUMN timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP; -- +goose Down -- Remove the 'timestamp' column from 'my_foo' ALTER TABLE my_foo DROP COLUMN timestamp; -- Rename the 'my_foo' table back to 'foo' ALTER TABLE my_foo RENAME TO foo; ` ) func TestPartialErrorUnwrap(t *testing.T) { err := &goose.PartialError{Err: goose.ErrNoCurrentVersion} require.ErrorIs(t, err, goose.ErrNoCurrentVersion) } ================================================ FILE: provider_types.go ================================================ package goose import ( "fmt" "path/filepath" "time" ) // MigrationType is the type of migration. type MigrationType string const ( TypeGo MigrationType = "go" TypeSQL MigrationType = "sql" ) // Source represents a single migration source. // // The Path field may be empty if the migration was registered manually. This is typically the case // for Go migrations registered using the [WithGoMigration] option. type Source struct { Type MigrationType Path string Version int64 } // MigrationResult is the result of a single migration operation. type MigrationResult struct { Source *Source Duration time.Duration Direction string // Empty indicates no action was taken during the migration, but it was still versioned. For // SQL, it means no statements; for Go, it's a nil function. Empty bool // Error is only set if the migration failed. Error error } // String returns a string representation of the migration result. // // Example down: // // EMPTY down 00006_posts_view-copy.sql (607.83µs) // OK down 00005_posts_view.sql (646.25µs) // // Example up: // // OK up 00005_posts_view.sql (727.5µs) // EMPTY up 00006_posts_view-copy.sql (378.33µs) func (m *MigrationResult) String() string { var format string if m.Direction == "up" { format = "%-5s %-2s %s (%s)" } else { format = "%-5s %-4s %s (%s)" } var state string if m.Empty { state = "EMPTY" } else { state = "OK" } return fmt.Sprintf(format, state, m.Direction, filepath.Base(m.Source.Path), truncateDuration(m.Duration), ) } // State represents the state of a migration. type State string const ( // StatePending is a migration that exists on the filesystem, but not in the database. StatePending State = "pending" // StateApplied is a migration that has been applied to the database and exists on the // filesystem. StateApplied State = "applied" // TODO(mf): we could also add a third state for untracked migrations. This would be useful for // migrations that were manually applied to the database, but not versioned. Or the Source was // deleted, but the migration still exists in the database. StateUntracked State = "untracked" ) // MigrationStatus represents the status of a single migration. type MigrationStatus struct { Source *Source State State AppliedAt time.Time } ================================================ FILE: redo.go ================================================ package goose import ( "context" "database/sql" ) // Redo rolls back the most recently applied migration, then runs it again. func Redo(db *sql.DB, dir string, opts ...OptionsFunc) error { ctx := context.Background() return RedoContext(ctx, db, dir, opts...) } // RedoContext rolls back the most recently applied migration, then runs it again. func RedoContext(ctx context.Context, db *sql.DB, dir string, opts ...OptionsFunc) error { option := &options{} for _, f := range opts { f(option) } migrations, err := CollectMigrations(dir, minVersion, maxVersion) if err != nil { return err } var ( currentVersion int64 ) if option.noVersioning { if len(migrations) == 0 { return nil } currentVersion = migrations[len(migrations)-1].Version } else { if currentVersion, err = GetDBVersionContext(ctx, db); err != nil { return err } } current, err := migrations.Current(currentVersion) if err != nil { return err } current.noVersioning = option.noVersioning if err := current.DownContext(ctx, db); err != nil { return err } if err := current.UpContext(ctx, db); err != nil { return err } return nil } ================================================ FILE: register.go ================================================ package goose import ( "context" "database/sql" "fmt" "runtime" ) // GoMigrationContext is a Go migration func that is run within a transaction and receives a // context. type GoMigrationContext func(ctx context.Context, tx *sql.Tx) error // AddMigrationContext adds Go migrations. func AddMigrationContext(up, down GoMigrationContext) { _, filename, _, _ := runtime.Caller(1) AddNamedMigrationContext(filename, up, down) } // AddNamedMigrationContext adds named Go migrations. func AddNamedMigrationContext(filename string, up, down GoMigrationContext) { if err := register( filename, true, &GoFunc{RunTx: up, Mode: TransactionEnabled}, &GoFunc{RunTx: down, Mode: TransactionEnabled}, ); err != nil { panic(err) } } // GoMigrationNoTxContext is a Go migration func that is run outside a transaction and receives a // context. type GoMigrationNoTxContext func(ctx context.Context, db *sql.DB) error // AddMigrationNoTxContext adds Go migrations that will be run outside transaction. func AddMigrationNoTxContext(up, down GoMigrationNoTxContext) { _, filename, _, _ := runtime.Caller(1) AddNamedMigrationNoTxContext(filename, up, down) } // AddNamedMigrationNoTxContext adds named Go migrations that will be run outside transaction. func AddNamedMigrationNoTxContext(filename string, up, down GoMigrationNoTxContext) { if err := register( filename, false, &GoFunc{RunDB: up, Mode: TransactionDisabled}, &GoFunc{RunDB: down, Mode: TransactionDisabled}, ); err != nil { panic(err) } } func register(filename string, useTx bool, up, down *GoFunc) error { v, _ := NumericComponent(filename) if existing, ok := registeredGoMigrations[v]; ok { return fmt.Errorf("failed to add migration %q: version %d conflicts with %q", filename, v, existing.Source, ) } // Add to global as a registered migration. m := NewGoMigration(v, up, down) m.Source = filename // We explicitly set transaction to maintain existing behavior. Both up and down may be nil, but // we know based on the register function what the user is requesting. m.UseTx = useTx registeredGoMigrations[v] = m return nil } // withContext changes the signature of a function that receives one argument to receive a context // and the argument. func withContext[T any](fn func(T) error) func(context.Context, T) error { if fn == nil { return nil } return func(ctx context.Context, t T) error { return fn(t) } } // withoutContext changes the signature of a function that receives a context and one argument to // receive only the argument. When called the passed context is always context.Background(). func withoutContext[T any](fn func(context.Context, T) error) func(T) error { if fn == nil { return nil } return func(t T) error { return fn(context.Background(), t) } } // GoMigration is a Go migration func that is run within a transaction. // // Deprecated: Use GoMigrationContext. type GoMigration func(tx *sql.Tx) error // GoMigrationNoTx is a Go migration func that is run outside a transaction. // // Deprecated: Use GoMigrationNoTxContext. type GoMigrationNoTx func(db *sql.DB) error // AddMigration adds Go migrations. // // Deprecated: Use AddMigrationContext. func AddMigration(up, down GoMigration) { _, filename, _, _ := runtime.Caller(1) AddNamedMigrationContext(filename, withContext(up), withContext(down)) } // AddNamedMigration adds named Go migrations. // // Deprecated: Use AddNamedMigrationContext. func AddNamedMigration(filename string, up, down GoMigration) { AddNamedMigrationContext(filename, withContext(up), withContext(down)) } // AddMigrationNoTx adds Go migrations that will be run outside transaction. // // Deprecated: Use AddMigrationNoTxContext. func AddMigrationNoTx(up, down GoMigrationNoTx) { _, filename, _, _ := runtime.Caller(1) AddNamedMigrationNoTxContext(filename, withContext(up), withContext(down)) } // AddNamedMigrationNoTx adds named Go migrations that will be run outside transaction. // // Deprecated: Use AddNamedMigrationNoTxContext. func AddNamedMigrationNoTx(filename string, up, down GoMigrationNoTx) { AddNamedMigrationNoTxContext(filename, withContext(up), withContext(down)) } ================================================ FILE: reset.go ================================================ package goose import ( "context" "database/sql" "fmt" "sort" ) // Reset rolls back all migrations func Reset(db *sql.DB, dir string, opts ...OptionsFunc) error { ctx := context.Background() return ResetContext(ctx, db, dir, opts...) } // ResetContext rolls back all migrations func ResetContext(ctx context.Context, db *sql.DB, dir string, opts ...OptionsFunc) error { option := &options{} for _, f := range opts { f(option) } migrations, err := CollectMigrations(dir, minVersion, maxVersion) if err != nil { return fmt.Errorf("failed to collect migrations: %w", err) } if option.noVersioning { return DownToContext(ctx, db, dir, minVersion, opts...) } statuses, err := dbMigrationsStatus(ctx, db) if err != nil { return fmt.Errorf("failed to get status of migrations: %w", err) } sort.Sort(sort.Reverse(migrations)) for _, migration := range migrations { if !statuses[migration.Version] { continue } if err = migration.DownContext(ctx, db); err != nil { return fmt.Errorf("failed to db-down: %w", err) } } return nil } func dbMigrationsStatus(ctx context.Context, db *sql.DB) (map[int64]bool, error) { dbMigrations, err := store.ListMigrations(ctx, db, TableName()) if err != nil { return nil, err } // The most recent record for each migration specifies // whether it has been applied or rolled back. results := make(map[int64]bool) for _, m := range dbMigrations { if _, ok := results[m.VersionID]; ok { continue } results[m.VersionID] = m.IsApplied } return results, nil } ================================================ FILE: scripts/release-notes.sh ================================================ #!/bin/bash set -euo pipefail # Check if the required argument is provided if [ $# -lt 1 ]; then echo "Usage: $0 []" exit 1 fi version="$1" changelog_file="${2:-CHANGELOG.md}" # Check if the changelog file exists if [ ! -f "$changelog_file" ]; then echo "Error: $changelog_file does not exist" exit 1 fi CAPTURE=0 items="" # Read the changelog file line by line while IFS= read -r LINE; do # Stop capturing when we reach the next version sections if [[ "${LINE}" == "##"* ]] && [[ "${CAPTURE}" -eq 1 ]]; then break fi # Stop capturing when we reach the Unreleased section if [[ "${LINE}" == "[Unreleased]"* ]]; then break fi # Start capturing when we reach the specified version section if [[ "${LINE}" == "## [${version}]"* ]] && [[ "${CAPTURE}" -eq 0 ]]; then CAPTURE=1 continue fi # Capture the lines between the specified version and the next version if [[ "${CAPTURE}" -eq 1 ]]; then # Ignore empty lines if [[ -z "${LINE}" ]]; then continue fi items+="$(echo "${LINE}" | xargs -0)" # Add a newline between each item if [[ -n "$items" ]]; then items+=$'\n' fi fi done <"${changelog_file}" if [[ -n "$items" ]]; then echo "${items%$'\n'}" else echo "No changelog items found for version $version" fi ================================================ FILE: status.go ================================================ package goose import ( "context" "database/sql" "errors" "fmt" "path/filepath" "time" ) // Status prints the status of all migrations. func Status(db *sql.DB, dir string, opts ...OptionsFunc) error { ctx := context.Background() return StatusContext(ctx, db, dir, opts...) } // StatusContext prints the status of all migrations. func StatusContext(ctx context.Context, db *sql.DB, dir string, opts ...OptionsFunc) error { option := &options{} for _, f := range opts { f(option) } migrations, err := CollectMigrations(dir, minVersion, maxVersion) if err != nil { return fmt.Errorf("failed to collect migrations: %w", err) } if option.noVersioning { log.Printf(" Applied At Migration") log.Printf(" =======================================") for _, current := range migrations { log.Printf(" %-24s -- %v", "no versioning", filepath.Base(current.Source)) } return nil } // must ensure that the version table exists if we're running on a pristine DB if _, err := EnsureDBVersionContext(ctx, db); err != nil { return fmt.Errorf("failed to ensure DB version: %w", err) } log.Printf(" Applied At Migration") log.Printf(" =======================================") for _, migration := range migrations { if err := printMigrationStatus(ctx, db, migration.Version, filepath.Base(migration.Source)); err != nil { return fmt.Errorf("failed to print status: %w", err) } } return nil } func printMigrationStatus(ctx context.Context, db *sql.DB, version int64, script string) error { m, err := store.GetMigration(ctx, db, TableName(), version) if err != nil && !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("failed to query the latest migration: %w", err) } appliedAt := "Pending" if m != nil && m.IsApplied { appliedAt = m.Timestamp.Format(time.ANSIC) } log.Printf(" %-24s -- %v", appliedAt, script) return nil } ================================================ FILE: testdata/migrations/00001_users_table.sql ================================================ -- +goose Up CREATE TABLE users ( id INTEGER PRIMARY KEY, username TEXT NOT NULL, email TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- +goose Down DROP TABLE users; ================================================ FILE: testdata/migrations/00002_posts_table.sql ================================================ -- +goose Up -- +goose StatementBegin CREATE TABLE posts ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL, author_id INTEGER NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (author_id) REFERENCES users(id) ); -- +goose StatementEnd -- +goose Down DROP TABLE posts; ================================================ FILE: testdata/migrations/00003_comments_table.sql ================================================ -- +goose Up CREATE TABLE comments ( id INTEGER PRIMARY KEY, post_id INTEGER NOT NULL, user_id INTEGER NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (post_id) REFERENCES posts(id), FOREIGN KEY (user_id) REFERENCES users(id) ); -- +goose Down DROP TABLE comments; ================================================ FILE: testdata/migrations/00004_insert_data.sql ================================================ -- +goose Up INSERT INTO users (id, username, email) VALUES (1, 'john_doe', 'john@example.com'), (2, 'jane_smith', 'jane@example.com'), (3, 'alice_wonderland', 'alice@example.com'); INSERT INTO posts (id, title, content, author_id) VALUES (1, 'Introduction to SQL', 'SQL is a powerful language for managing databases...', 1), (2, 'Data Modeling Techniques', 'Choosing the right data model is crucial...', 2), (3, 'Advanced Query Optimization', 'Optimizing queries can greatly improve...', 1); INSERT INTO comments (id, post_id, user_id, content) VALUES (1, 1, 3, 'Great introduction! Looking forward to more.'), (2, 1, 2, 'SQL can be a bit tricky at first, but practice helps.'), (3, 2, 1, 'You covered normalization really well in this post.'); -- +goose Down DELETE FROM comments; DELETE FROM posts; DELETE FROM users; ================================================ FILE: testdata/migrations/00005_posts_view.sql ================================================ -- +goose NO TRANSACTION -- +goose Up CREATE VIEW posts_view AS SELECT p.id, p.title, p.content, p.created_at, u.username AS author FROM posts p JOIN users u ON p.author_id = u.id; -- +goose Down DROP VIEW posts_view; ================================================ FILE: testdata/no-versioning/migrations/00001_a.sql ================================================ -- +goose Up CREATE TABLE owners ( owner_id INTEGER PRIMARY KEY AUTOINCREMENT, owner_name TEXT NOT NULL ); -- +goose Down DROP TABLE IF EXISTS owners; ================================================ FILE: testdata/no-versioning/migrations/00002_b.sql ================================================ -- +goose Up -- +goose StatementBegin INSERT INTO owners(owner_name) VALUES ('lucas'), ('ocean'); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DELETE FROM owners; -- +goose StatementEnd ================================================ FILE: testdata/no-versioning/migrations/00003_c.sql ================================================ -- +goose Up -- +goose StatementBegin INSERT INTO owners(owner_name) VALUES ('james'), ('space'); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin DELETE FROM owners WHERE owner_name IN ('james', 'space'); -- +goose StatementEnd ================================================ FILE: testdata/no-versioning/seed/00001_a.sql ================================================ -- +goose Up -- +goose StatementBegin -- Insert 100 owners. INSERT INTO owners (owner_name) WITH numbers AS ( SELECT 1 AS n UNION ALL SELECT n + 1 FROM numbers WHERE n < 100 ) SELECT 'seed-user-' || n FROM numbers; -- +goose StatementEnd -- +goose Down -- +goose StatementBegin -- Delete the previously inserted data. DELETE FROM owners WHERE owner_name LIKE 'seed-user-%'; -- +goose StatementEnd ================================================ FILE: testdata/no-versioning/seed/00002_b.sql ================================================ -- +goose Up -- Insert 150 more owners. INSERT INTO owners (owner_name) WITH numbers AS ( SELECT 101 AS n UNION ALL SELECT n + 1 FROM numbers WHERE n < 250 ) SELECT 'seed-user-' || n FROM numbers; -- +goose Down -- NOTE: there are 4 migration owners and 100 seed owners, that's why owner_id starts at 105 DELETE FROM owners WHERE owner_name LIKE 'seed-user-%' AND owner_id BETWEEN 105 AND 254; ================================================ FILE: testdata/testdata.go ================================================ package testdata import ( "embed" "io/fs" ) //go:embed migrations/*.sql var migrationsFS embed.FS // MustMigrationsFS returns the embedded migrations filesystem. func MustMigrationsFS() fs.FS { fsys, err := fs.Sub(migrationsFS, "migrations") if err != nil { // This should never happen, since the subdirectory is hardcoded. If the layout of the // embedded files changes, this will panic to alert the developer to update the code // accordingly. panic(err) } return fsys } ================================================ FILE: tests/gomigrations/error/gomigrations_error_test.go ================================================ package gomigrations import ( "database/sql" "path/filepath" "testing" "github.com/pressly/goose/v3" _ "github.com/pressly/goose/v3/tests/gomigrations/error/testdata" "github.com/stretchr/testify/require" _ "modernc.org/sqlite" ) func TestGoMigrationByOne(t *testing.T) { tempDir := t.TempDir() db, err := sql.Open("sqlite", filepath.Join(tempDir, "test.db")) require.NoError(t, err) err = goose.SetDialect(string(goose.DialectSQLite3)) require.NoError(t, err) // Create goose table. current, err := goose.EnsureDBVersion(db) require.NoError(t, err) require.EqualValues(t, 0, current) // Collect migrations. dir := "testdata" migrations, err := goose.CollectMigrations(dir, 0, goose.MaxVersion) require.NoError(t, err) require.Len(t, migrations, 4) // Setup table. err = migrations[0].Up(db) require.NoError(t, err) version, err := goose.GetDBVersion(db) require.NoError(t, err) require.EqualValues(t, 1, version) // Registered Go migration run outside a goose tx using *sql.DB. err = migrations[1].Up(db) require.Error(t, err) require.Contains(t, err.Error(), "failed to run go migration") version, err = goose.GetDBVersion(db) require.NoError(t, err) require.EqualValues(t, 1, version) // This migration was inserting 100 rows, but fails at 50, and // because it's run outside a goose tx then we expect 50 rows. var count int err = db.QueryRow("SELECT COUNT(*) FROM foo").Scan(&count) require.NoError(t, err) require.Equal(t, 50, count) // Truncate table so we have 0 rows. err = migrations[2].Up(db) require.NoError(t, err) version, err = goose.GetDBVersion(db) require.NoError(t, err) // We're at version 3, but keep in mind 2 was never applied because it failed. require.EqualValues(t, 3, version) // Registered Go migration run within a tx. err = migrations[3].Up(db) require.Error(t, err) require.Contains(t, err.Error(), "failed to run go migration") version, err = goose.GetDBVersion(db) require.NoError(t, err) require.EqualValues(t, 3, version) // This migration failed, so we're still at 3. // This migration was inserting 100 rows, but fails at 50. However, since it's // running within a tx we expect none of the inserts to persist. err = db.QueryRow("SELECT COUNT(*) FROM foo").Scan(&count) require.NoError(t, err) require.Equal(t, 0, count) } ================================================ FILE: tests/gomigrations/error/testdata/001_up_no_tx.go ================================================ package gomigrations import ( "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationNoTx(up001, nil) } func up001(db *sql.DB) error { q := "CREATE TABLE foo (id INTEGER)" _, err := db.Exec(q) return err } ================================================ FILE: tests/gomigrations/error/testdata/002_ERROR_insert_no_tx.go ================================================ package gomigrations import ( "database/sql" "fmt" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationNoTx(up002, nil) } func up002(db *sql.DB) error { for i := 1; i <= 100; i++ { q := "INSERT INTO foo VALUES ($1)" if _, err := db.Exec(q, i); err != nil { return err } // Simulate an error when no tx. We should have 50 rows // inserted in the DB. if i == 50 { return fmt.Errorf("simulate error: too many inserts") } } return nil } ================================================ FILE: tests/gomigrations/error/testdata/003_truncate.go ================================================ package gomigrations import ( "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigration(up003, nil) } func up003(tx *sql.Tx) error { q := "DELETE FROM foo" _, err := tx.Exec(q) return err } ================================================ FILE: tests/gomigrations/error/testdata/004_ERROR_insert.go ================================================ package gomigrations import ( "database/sql" "fmt" "github.com/pressly/goose/v3" ) func init() { goose.AddMigration(up004, nil) } func up004(tx *sql.Tx) error { for i := 1; i <= 100; i++ { // Simulate an error when no tx. We should have 50 rows // inserted in the DB. if i == 50 { return fmt.Errorf("simulate error: too many inserts") } q := "INSERT INTO foo VALUES ($1)" if _, err := tx.Exec(q); err != nil { return err } } return nil } ================================================ FILE: tests/gomigrations/register/register_test.go ================================================ package register_test import ( "math" "path/filepath" "testing" "github.com/pressly/goose/v3" _ "github.com/pressly/goose/v3/tests/gomigrations/register/testdata" "github.com/stretchr/testify/require" ) func TestAddFunctions(t *testing.T) { goMigrations, err := goose.CollectMigrations("testdata", 0, math.MaxInt64) require.NoError(t, err) require.Len(t, goMigrations, 4) checkMigration(t, goMigrations[0], &goose.Migration{ Version: 1, Next: 2, Previous: -1, Source: "001_addmigration.go", Registered: true, UseTx: true, }) checkMigration(t, goMigrations[1], &goose.Migration{ Version: 2, Next: 3, Previous: 1, Source: "002_addmigrationnotx.go", Registered: true, UseTx: false, }) checkMigration(t, goMigrations[2], &goose.Migration{ Version: 3, Next: 4, Previous: 2, Source: "003_addmigrationcontext.go", Registered: true, UseTx: true, }) checkMigration(t, goMigrations[3], &goose.Migration{ Version: 4, Next: -1, Previous: 3, Source: "004_addmigrationnotxcontext.go", Registered: true, UseTx: false, }) } func checkMigration(t *testing.T, got *goose.Migration, want *goose.Migration) { t.Helper() require.Equal(t, want.Version, got.Version) require.Equal(t, want.Next, got.Next) require.Equal(t, want.Previous, got.Previous) require.Equal(t, want.Source, filepath.Base(got.Source)) require.Equal(t, want.Registered, got.Registered) require.Equal(t, want.UseTx, got.UseTx) checkFunctions(t, got) } func checkFunctions(t *testing.T, m *goose.Migration) { t.Helper() switch filepath.Base(m.Source) { case "001_addmigration.go": // With transaction require.NotNil(t, m.UpFn) require.NotNil(t, m.DownFn) require.NotNil(t, m.UpFnContext) require.NotNil(t, m.DownFnContext) // No transaction require.Nil(t, m.UpFnNoTx) require.Nil(t, m.DownFnNoTx) require.Nil(t, m.UpFnNoTxContext) require.Nil(t, m.DownFnNoTxContext) case "002_addmigrationnotx.go": // With transaction require.Nil(t, m.UpFn) require.Nil(t, m.DownFn) require.Nil(t, m.UpFnContext) require.Nil(t, m.DownFnContext) // No transaction require.NotNil(t, m.UpFnNoTx) require.NotNil(t, m.DownFnNoTx) require.NotNil(t, m.UpFnNoTxContext) require.NotNil(t, m.DownFnNoTxContext) case "003_addmigrationcontext.go": // With transaction require.NotNil(t, m.UpFn) require.NotNil(t, m.DownFn) require.NotNil(t, m.UpFnContext) require.NotNil(t, m.DownFnContext) // No transaction require.Nil(t, m.UpFnNoTx) require.Nil(t, m.DownFnNoTx) require.Nil(t, m.UpFnNoTxContext) require.Nil(t, m.DownFnNoTxContext) case "004_addmigrationnotxcontext.go": // With transaction require.Nil(t, m.UpFn) require.Nil(t, m.DownFn) require.Nil(t, m.UpFnContext) require.Nil(t, m.DownFnContext) // No transaction require.NotNil(t, m.UpFnNoTx) require.NotNil(t, m.DownFnNoTx) require.NotNil(t, m.UpFnNoTxContext) require.NotNil(t, m.DownFnNoTxContext) default: t.Fatalf("unexpected migration: %s", filepath.Base(m.Source)) } } ================================================ FILE: tests/gomigrations/register/testdata/001_addmigration.go ================================================ package register import ( "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigration( func(_ *sql.Tx) error { return nil }, func(_ *sql.Tx) error { return nil }, ) } ================================================ FILE: tests/gomigrations/register/testdata/002_addmigrationnotx.go ================================================ package register import ( "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationNoTx( func(_ *sql.DB) error { return nil }, func(_ *sql.DB) error { return nil }, ) } ================================================ FILE: tests/gomigrations/register/testdata/003_addmigrationcontext.go ================================================ package register import ( "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationContext( func(_ context.Context, _ *sql.Tx) error { return nil }, func(_ context.Context, _ *sql.Tx) error { return nil }, ) } ================================================ FILE: tests/gomigrations/register/testdata/004_addmigrationnotxcontext.go ================================================ package register import ( "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationNoTxContext( func(_ context.Context, _ *sql.DB) error { return nil }, func(_ context.Context, _ *sql.DB) error { return nil }, ) } ================================================ FILE: tests/gomigrations/success/gomigrations_success_test.go ================================================ package gomigrations_test import ( "database/sql" "path/filepath" "testing" "github.com/pressly/goose/v3" "github.com/stretchr/testify/require" _ "github.com/pressly/goose/v3/tests/gomigrations/success/testdata" _ "modernc.org/sqlite" ) func TestGoMigrationByOne(t *testing.T) { t.Parallel() require.NoError(t, goose.SetDialect("sqlite3")) db, err := sql.Open("sqlite", ":memory:") require.NoError(t, err) dir := "testdata" files, err := filepath.Glob(dir + "/*.go") require.NoError(t, err) upByOne := func(t *testing.T) int64 { t.Helper() err = goose.UpByOne(db, dir) t.Logf("err: %v %s", err, dir) require.NoError(t, err) version, err := goose.GetDBVersion(db) require.NoError(t, err) return version } downByOne := func(t *testing.T) int64 { t.Helper() err = goose.Down(db, dir) require.NoError(t, err) version, err := goose.GetDBVersion(db) require.NoError(t, err) return version } // Migrate all files up-by-one. for i := 1; i <= len(files); i++ { require.EqualValues(t, upByOne(t), i) } version, err := goose.GetDBVersion(db) require.NoError(t, err) require.Len(t, files, int(version)) tables, err := ListTables(db) require.NoError(t, err) require.Equal(t, []string{ "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "goose_db_version", "hotel", "sqlite_sequence", }, tables, ) // Migrate all files down-by-one. for i := len(files) - 1; i >= 0; i-- { require.EqualValues(t, downByOne(t), i) } version, err = goose.GetDBVersion(db) require.NoError(t, err) require.EqualValues(t, 0, version) tables, err = ListTables(db) require.NoError(t, err) require.Equal(t, []string{ "goose_db_version", "sqlite_sequence", }, tables, ) } func ListTables(db *sql.DB) ([]string, error) { rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`) if err != nil { return nil, err } defer rows.Close() var tables []string for rows.Next() { var name string if err := rows.Scan(&name); err != nil { return nil, err } tables = append(tables, name) } if err := rows.Err(); err != nil { return nil, err } return tables, nil } ================================================ FILE: tests/gomigrations/success/testdata/001_up_down.go ================================================ package gomigrations import ( "context" "database/sql" "fmt" "github.com/pressly/goose/v3" "github.com/pressly/goose/v3/database" ) func init() { goose.AddMigration(up001, down001) } func up001(tx *sql.Tx) error { return createTable(tx, "alpha") } func down001(tx *sql.Tx) error { return dropTable(tx, "alpha") } func createTable(db database.DBTxConn, name string) error { _, err := db.ExecContext(context.Background(), fmt.Sprintf("CREATE TABLE %s (id INTEGER)", name)) return err } func dropTable(db database.DBTxConn, name string) error { _, err := db.ExecContext(context.Background(), fmt.Sprintf("DROP TABLE %s", name)) return err } ================================================ FILE: tests/gomigrations/success/testdata/002_up_only.go ================================================ package gomigrations import ( "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigration(up002, nil) } func up002(tx *sql.Tx) error { return createTable(tx, "bravo") } ================================================ FILE: tests/gomigrations/success/testdata/003_down_only.go ================================================ package gomigrations import ( "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigration(nil, down003) } func down003(tx *sql.Tx) error { return dropTable(tx, "bravo") } ================================================ FILE: tests/gomigrations/success/testdata/004_empty.go ================================================ package gomigrations import ( "github.com/pressly/goose/v3" ) func init() { goose.AddMigration(nil, nil) } ================================================ FILE: tests/gomigrations/success/testdata/005_up_down_no_tx.go ================================================ package gomigrations import ( "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationNoTx(up005, down005) } func up005(db *sql.DB) error { return createTable(db, "charlie") } func down005(db *sql.DB) error { return dropTable(db, "charlie") } ================================================ FILE: tests/gomigrations/success/testdata/006_up_only_no_tx.go ================================================ package gomigrations import ( "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationNoTx(up006, nil) } func up006(db *sql.DB) error { return createTable(db, "delta") } ================================================ FILE: tests/gomigrations/success/testdata/007_down_only_no_tx.go ================================================ package gomigrations import ( "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationNoTx(nil, down007) } func down007(db *sql.DB) error { return dropTable(db, "delta") } ================================================ FILE: tests/gomigrations/success/testdata/008_empty_no_tx.go ================================================ package gomigrations import ( "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationNoTx(nil, nil) } ================================================ FILE: tests/gomigrations/success/testdata/009_up_down_ctx.go ================================================ package gomigrations import ( "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationContext(up009, down009) } func up009(ctx context.Context, tx *sql.Tx) error { return createTable(tx, "echo") } func down009(ctx context.Context, tx *sql.Tx) error { return dropTable(tx, "echo") } ================================================ FILE: tests/gomigrations/success/testdata/010_up_only_ctx.go ================================================ package gomigrations import ( "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationContext(up010, nil) } func up010(ctx context.Context, tx *sql.Tx) error { return createTable(tx, "foxtrot") } ================================================ FILE: tests/gomigrations/success/testdata/011_down_only_ctx.go ================================================ package gomigrations import ( "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationContext(nil, down011) } func down011(ctx context.Context, tx *sql.Tx) error { return dropTable(tx, "foxtrot") } ================================================ FILE: tests/gomigrations/success/testdata/012_empty_ctx.go ================================================ package gomigrations import ( "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationContext(nil, nil) } ================================================ FILE: tests/gomigrations/success/testdata/013_up_down_no_tx_ctx.go ================================================ package gomigrations import ( "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationNoTxContext(up013, down013) } func up013(ctx context.Context, db *sql.DB) error { return createTable(db, "golf") } func down013(ctx context.Context, db *sql.DB) error { return dropTable(db, "golf") } ================================================ FILE: tests/gomigrations/success/testdata/014_up_only_no_tx_ctx.go ================================================ package gomigrations import ( "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationNoTxContext(up014, nil) } func up014(ctx context.Context, db *sql.DB) error { return createTable(db, "hotel") } ================================================ FILE: tests/gomigrations/success/testdata/015_down_only_no_tx_ctx.go ================================================ package gomigrations import ( "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationNoTxContext(nil, down015) } func down015(ctx context.Context, db *sql.DB) error { return dropTable(db, "hotel") } ================================================ FILE: tests/gomigrations/success/testdata/016_empty_no_tx_ctx.go ================================================ package gomigrations import ( "github.com/pressly/goose/v3" ) func init() { goose.AddMigrationNoTxContext(nil, nil) } ================================================ FILE: up.go ================================================ package goose import ( "context" "database/sql" "fmt" "sort" "strings" ) type options struct { allowMissing bool applyUpByOne bool noVersioning bool } type OptionsFunc func(o *options) func WithAllowMissing() OptionsFunc { return func(o *options) { o.allowMissing = true } } func WithNoVersioning() OptionsFunc { return func(o *options) { o.noVersioning = true } } func WithNoColor(b bool) OptionsFunc { return func(o *options) { noColor = b } } func withApplyUpByOne() OptionsFunc { return func(o *options) { o.applyUpByOne = true } } // UpTo migrates up to a specific version. func UpTo(db *sql.DB, dir string, version int64, opts ...OptionsFunc) error { ctx := context.Background() return UpToContext(ctx, db, dir, version, opts...) } func UpToContext(ctx context.Context, db *sql.DB, dir string, version int64, opts ...OptionsFunc) error { option := &options{} for _, f := range opts { f(option) } foundMigrations, err := CollectMigrations(dir, minVersion, version) if err != nil { return err } if option.noVersioning { if len(foundMigrations) == 0 { return nil } if option.applyUpByOne { // For up-by-one this means keep re-applying the first // migration over and over. version = foundMigrations[0].Version } return upToNoVersioning(ctx, db, foundMigrations, version) } if _, err := EnsureDBVersionContext(ctx, db); err != nil { return err } dbMigrations, err := listAllDBVersions(ctx, db) if err != nil { return err } dbMaxVersion := dbMigrations[len(dbMigrations)-1].Version // lookupAppliedInDB is a map of all applied migrations in the database. lookupAppliedInDB := make(map[int64]bool) for _, m := range dbMigrations { lookupAppliedInDB[m.Version] = true } missingMigrations := findMissingMigrations(dbMigrations, foundMigrations, dbMaxVersion) // feature(mf): It is very possible someone may want to apply ONLY new migrations // and skip missing migrations altogether. At the moment this is not supported, // but leaving this comment because that's where that logic will be handled. if len(missingMigrations) > 0 && !option.allowMissing { var collected []string for _, m := range missingMigrations { output := fmt.Sprintf("version %d: %s", m.Version, m.Source) collected = append(collected, output) } return fmt.Errorf("error: found %d missing migrations before current version %d:\n\t%s", len(missingMigrations), dbMaxVersion, strings.Join(collected, "\n\t")) } var migrationsToApply Migrations if option.allowMissing { migrationsToApply = missingMigrations } // filter all migrations with a version greater than the supplied version (min) and less than or // equal to the requested version (max). Note, we do not need to filter out missing migrations // because we are only appending "new" migrations that have a higher version than the current // database max version, which inevitably means they are not "missing". for _, m := range foundMigrations { if lookupAppliedInDB[m.Version] { continue } if m.Version > dbMaxVersion && m.Version <= version { migrationsToApply = append(migrationsToApply, m) } } var current int64 for _, m := range migrationsToApply { if err := m.UpContext(ctx, db); err != nil { return err } if option.applyUpByOne { return nil } current = m.Version } if len(migrationsToApply) == 0 { current, err = GetDBVersionContext(ctx, db) if err != nil { return err } log.Printf("goose: no migrations to run. current version: %d", current) } else { log.Printf("goose: successfully migrated database to version: %d", current) } // At this point there are no more migrations to apply. But we need to maintain // the following behaviour: // UpByOne returns an error to signifying there are no more migrations. // Up and UpTo return nil if option.applyUpByOne { return ErrNoNextVersion } return nil } // upToNoVersioning applies up migrations up to, and including, the // target version. func upToNoVersioning(ctx context.Context, db *sql.DB, migrations Migrations, version int64) error { var finalVersion int64 for _, current := range migrations { if current.Version > version { break } current.noVersioning = true if err := current.UpContext(ctx, db); err != nil { return err } finalVersion = current.Version } log.Printf("goose: up to current file version: %d", finalVersion) return nil } // Up applies all available migrations. func Up(db *sql.DB, dir string, opts ...OptionsFunc) error { ctx := context.Background() return UpContext(ctx, db, dir, opts...) } // UpContext applies all available migrations. func UpContext(ctx context.Context, db *sql.DB, dir string, opts ...OptionsFunc) error { return UpToContext(ctx, db, dir, maxVersion, opts...) } // UpByOne migrates up by a single version. func UpByOne(db *sql.DB, dir string, opts ...OptionsFunc) error { ctx := context.Background() return UpByOneContext(ctx, db, dir, opts...) } // UpByOneContext migrates up by a single version. func UpByOneContext(ctx context.Context, db *sql.DB, dir string, opts ...OptionsFunc) error { opts = append(opts, withApplyUpByOne()) return UpToContext(ctx, db, dir, maxVersion, opts...) } // listAllDBVersions returns a list of all migrations, ordered ascending. func listAllDBVersions(ctx context.Context, db *sql.DB) (Migrations, error) { dbMigrations, err := store.ListMigrations(ctx, db, TableName()) if err != nil { return nil, err } all := make(Migrations, 0, len(dbMigrations)) for _, m := range dbMigrations { all = append(all, &Migration{ Version: m.VersionID, }) } // ListMigrations returns migrations in descending order by id. // But we want to return them in ascending order by version_id, so we re-sort. sort.SliceStable(all, func(i, j int) bool { return all[i].Version < all[j].Version }) return all, nil } // findMissingMigrations migrations returns all missing migrations. // A migrations is considered missing if it has a version less than the // current known max version. func findMissingMigrations(knownMigrations, newMigrations Migrations, dbMaxVersion int64) Migrations { existing := make(map[int64]bool) for _, known := range knownMigrations { existing[known.Version] = true } var missing Migrations for _, new := range newMigrations { if !existing[new.Version] && new.Version < dbMaxVersion { missing = append(missing, new) } } sort.SliceStable(missing, func(i, j int) bool { return missing[i].Version < missing[j].Version }) return missing } ================================================ FILE: up_test.go ================================================ package goose import ( "testing" ) func TestFindMissingMigrations(t *testing.T) { known := Migrations{ {Version: 1}, {Version: 3}, {Version: 4}, {Version: 5}, {Version: 7}, // <-- database max version_id } new := Migrations{ {Version: 1}, {Version: 2}, // missing migration {Version: 3}, {Version: 4}, {Version: 5}, {Version: 6}, // missing migration {Version: 7}, // <-- database max version_id {Version: 8}, // new migration } got := findMissingMigrations(known, new, 7) if len(got) != 2 { t.Fatalf("invalid migration count: got:%d want:%d", len(got), 2) } if got[0].Version != 2 { t.Errorf("expecting first migration: got:%d want:%d", got[0].Version, 2) } if got[1].Version != 6 { t.Errorf("expecting second migration: got:%d want:%d", got[0].Version, 6) } } ================================================ FILE: version.go ================================================ package goose import ( "context" "database/sql" "fmt" ) // Version prints the current version of the database. func Version(db *sql.DB, dir string, opts ...OptionsFunc) error { ctx := context.Background() return VersionContext(ctx, db, dir, opts...) } // VersionContext prints the current version of the database. func VersionContext(ctx context.Context, db *sql.DB, dir string, opts ...OptionsFunc) error { option := &options{} for _, f := range opts { f(option) } if option.noVersioning { var current int64 migrations, err := CollectMigrations(dir, minVersion, maxVersion) if err != nil { return fmt.Errorf("failed to collect migrations: %w", err) } if len(migrations) > 0 { current = migrations[len(migrations)-1].Version } log.Printf("goose: file version %v", current) return nil } current, err := GetDBVersionContext(ctx, db) if err != nil { return err } log.Printf("goose: version %v", current) return nil } var tableName = "goose_db_version" // TableName returns goose db version table name func TableName() string { return tableName } // SetTableName set goose db version table name func SetTableName(n string) { tableName = n }