├── .tool-versions ├── res ├── cover.png ├── crab-icon.png ├── crab-final-pure.png ├── crab-high-res.png ├── favicon │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── site.webmanifest │ └── head.html ├── crab-final-pure-small-white-bg.png └── crab-icon.svg ├── krab ├── type_hooks.go ├── kcl_statement.go ├── templates.go ├── version.go ├── version_generator.go ├── type_test_suite.go ├── hook_runner.go ├── type_set_runtime_parameters.go ├── inputs.go ├── cmd.go ├── action_test_run.go ├── action_version.go ├── cmd_version.go ├── validator.go ├── validator_test.go ├── subtype_do.go ├── action_custom.go ├── type_test_query.go ├── type_test_query_row.go ├── cmd_registry.go ├── type_test_query_col.go ├── type_ddl_identity.go ├── type_test_example_it.go ├── type_ddl_drop_table.go ├── action_gen_migration.go ├── action_migrate_status.go ├── type_ddl_generated_column.go ├── type_ddl_check.go ├── cmd_action.go ├── action_migrate_up.go ├── type_test_example.go ├── type_ddl_foreign_key.go ├── action_migrate_down.go ├── type_ddl_drop_index.go ├── type_ddl_unique.go ├── file.go ├── sql_statement.go ├── type_ddl_primary_key.go ├── cmd_migrate_status.go ├── type_ddl_references.go ├── cmd_test_run.go ├── action.go ├── type_migration_set.go ├── config.go ├── parser.go ├── cmd_gen_migration.go ├── type_ddl_column.go ├── arguments.go ├── cmd_migrate_down.go ├── schema_migration_table.go ├── type_ddl_create_index.go └── cmd_migrate_up.go ├── .dockerignore ├── main_test.go ├── web ├── embeddable_resources.go ├── dto │ ├── schema_list.go │ ├── tablespace_list.go │ ├── table_list.go │ ├── actions.go │ └── database_list.go └── renderer.go ├── test └── fixtures │ ├── migrations │ ├── locals.hcl │ └── simple.hcl │ ├── actions │ └── actions.krab.hcl │ ├── args │ └── migrations.krab.hcl │ ├── tests │ ├── versions_test.krab.hcl │ └── versions.krab.hcl │ └── simple │ └── migrations.krab.hcl ├── cli ├── color.go └── ui.go ├── views ├── layout_info.go ├── err_404.templ ├── tablespace_list.templ ├── schema_list.templ ├── action_list.templ ├── action_new.templ ├── table_list.templ └── database_list.templ ├── cliargs ├── values.go ├── highlights.go └── parser.go ├── krabhcl ├── source.go ├── body.go ├── addr.go └── expression.go ├── krabtpl ├── functions.go └── fake.go ├── .gitignore ├── package.json ├── emojis └── emoji.go ├── docker-compose.yml ├── krabfn ├── eval_context.go └── file.go ├── README.md ├── .chglog ├── config.yml └── CHANGELOG.tpl.md ├── .goreleaser.yml ├── spec ├── action_migrate_up_hooks_test.go ├── action_migrate_up_arguments_test.go ├── test_test.go ├── action_migrate_status_arguments_test.go ├── action_migrate_down_transaction_test.go ├── action_migrate_down_hooks_test.go ├── action_migrate_down_arguments_test.go ├── action_migrate_dsl_statement_ordering_test.go ├── action_migrate_up_transaction_test.go ├── action_migrate_up_test.go ├── action_insert_fixtures_test.go ├── action_custom_test.go ├── action_gen_migration_test.go ├── action_migrate_dsl_index_test.go ├── testdb_test.go ├── parser_test.go ├── action_migrate_dsl_table_test.go └── action_migrate_down_test.go ├── krabdb ├── quote_test.go ├── transaction.go ├── connection.go ├── quote.go ├── db.go └── advisory_lock.go ├── Dockerfile ├── krabenv └── config.go ├── .air.toml ├── LICENSE ├── tpls └── templates.go ├── .github └── workflows │ ├── nightly.yml │ └── ci.yml ├── main.go ├── Makefile ├── go.mod ├── CHANGELOG.md └── krabcli └── app.go /.tool-versions: -------------------------------------------------------------------------------- 1 | golang 1.24.1 2 | git-chglog 0.15.4 3 | -------------------------------------------------------------------------------- /res/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ohkrab/krab/HEAD/res/cover.png -------------------------------------------------------------------------------- /res/crab-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ohkrab/krab/HEAD/res/crab-icon.png -------------------------------------------------------------------------------- /res/crab-final-pure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ohkrab/krab/HEAD/res/crab-final-pure.png -------------------------------------------------------------------------------- /res/crab-high-res.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ohkrab/krab/HEAD/res/crab-high-res.png -------------------------------------------------------------------------------- /res/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ohkrab/krab/HEAD/res/favicon/favicon.ico -------------------------------------------------------------------------------- /krab/type_hooks.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | type Hooks struct { 4 | Before string `hcl:"before"` 5 | } 6 | -------------------------------------------------------------------------------- /res/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ohkrab/krab/HEAD/res/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /res/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ohkrab/krab/HEAD/res/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /res/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ohkrab/krab/HEAD/res/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /res/crab-final-pure-small-white-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ohkrab/krab/HEAD/res/crab-final-pure-small-white-bg.png -------------------------------------------------------------------------------- /res/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ohkrab/krab/HEAD/res/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /res/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ohkrab/krab/HEAD/res/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.md 2 | *.yml 3 | .dockerignore 4 | .gitignore 5 | Makefile 6 | bin/ 7 | docs/ 8 | test/ 9 | tmp/ 10 | vendor/ 11 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestMain(m *testing.M) { 9 | os.Exit(m.Run()) 10 | } 11 | -------------------------------------------------------------------------------- /web/embeddable_resources.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | type EmbeddableResources struct { 4 | Favicon []byte 5 | WhiteLogo []byte 6 | Logo []byte 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/migrations/locals.hcl: -------------------------------------------------------------------------------- 1 | locals { 2 | one = 1 3 | two = "two" 4 | three = [1, 2, 3] 5 | four = { 6 | a = 1 7 | b = 2 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /krab/kcl_statement.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | // ToKCL converts DSL struct to Krab HCL. 8 | type ToKCL interface { 9 | ToKCL(w io.StringWriter) 10 | } 11 | -------------------------------------------------------------------------------- /cli/color.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/wzshiming/ctc" 7 | ) 8 | 9 | // Red colorizes output. 10 | func Red(s string) string { 11 | return fmt.Sprint(ctc.ForegroundRed, s, ctc.Reset) 12 | } 13 | -------------------------------------------------------------------------------- /views/layout_info.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | const ( 4 | NavNone = iota 5 | NavDatabase = iota + 1 6 | ) 7 | 8 | type LayoutInfo struct { 9 | Blank bool 10 | Nav int 11 | Database string 12 | Footer string 13 | } 14 | -------------------------------------------------------------------------------- /cliargs/values.go: -------------------------------------------------------------------------------- 1 | package cliargs 2 | 3 | type Values map[string]any 4 | 5 | func (v Values) Get(key string) string { 6 | val, exists := v[key] 7 | if exists { 8 | if s, ok := val.(string); ok { 9 | return s 10 | } 11 | } 12 | 13 | return "" 14 | } 15 | -------------------------------------------------------------------------------- /krab/templates.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | type Templates struct { 4 | } 5 | 6 | func (t Templates) ProcessArguments(tpl string, args map[string]any) (string, error) { 7 | return tpl, nil 8 | } 9 | 10 | func EmptyArgs() map[string]any { 11 | return map[string]any{} 12 | } 13 | -------------------------------------------------------------------------------- /res/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /krab/version.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | const ( 4 | InfoName = "krab" 5 | InfoFullName = "Oh Krab!" 6 | InfoWWW = "https://ohkrab.dev" 7 | ) 8 | 9 | var ( 10 | InfoVersion = "" 11 | InfoCommit = "" 12 | InfoBuildDate = "" 13 | ) 14 | -------------------------------------------------------------------------------- /res/favicon/head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /cliargs/highlights.go: -------------------------------------------------------------------------------- 1 | package cliargs 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/wzshiming/ctc" 7 | ) 8 | 9 | func Highlight(color ctc.Color, s string, colorize bool) string { 10 | if colorize { 11 | return fmt.Sprint(color, s, ctc.Reset) 12 | } 13 | return fmt.Sprint(ctc.Reset, s, ctc.Reset) 14 | } 15 | -------------------------------------------------------------------------------- /web/dto/schema_list.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type SchemaListItem struct { 4 | ID uint64 `json:"ID" db:"id"` 5 | DatabaseName string 6 | Name string `json:"name" db:"name"` 7 | OwnerID uint64 `json:"ownerID" db:"owner_id"` 8 | OwnerName string `json:"ownerName" db:"owner_name"` 9 | } 10 | -------------------------------------------------------------------------------- /krab/version_generator.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import "time" 4 | 5 | type VersionGenerator interface { 6 | Next() string 7 | } 8 | 9 | type TimestampVersionGenerator struct{} 10 | 11 | func (g *TimestampVersionGenerator) Next() string { 12 | version := time.Now().UTC().Format("20060102_150405") // YYYYMMDD_HHMMSS 13 | return version 14 | } 15 | -------------------------------------------------------------------------------- /krabhcl/source.go: -------------------------------------------------------------------------------- 1 | package krabhcl 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2" 5 | ) 6 | 7 | // Source helps identifing code definition in krah hcl files. 8 | type Source struct { 9 | DefRange hcl.Range 10 | } 11 | 12 | // Extract saves the source information. 13 | func (s *Source) Extract(block *hcl.Block) { 14 | s.DefRange = block.DefRange 15 | } 16 | -------------------------------------------------------------------------------- /web/renderer.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/a-h/templ" 7 | "github.com/ohkrab/krab/views" 8 | ) 9 | 10 | type Renderer struct { 11 | } 12 | 13 | func (renderer *Renderer) HTML(w http.ResponseWriter, r *http.Request, info views.LayoutInfo, view templ.Component) { 14 | views.Layout(info, view).Render(r.Context(), w) 15 | } 16 | -------------------------------------------------------------------------------- /krab/type_test_suite.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "github.com/ohkrab/krab/krabhcl" 5 | ) 6 | 7 | // TestSuite represents test runner configuration. 8 | type TestSuite struct { 9 | Tests []*TestExample 10 | } 11 | 12 | func (t *TestSuite) Addr() krabhcl.Addr { 13 | return krabhcl.NullAddr 14 | } 15 | 16 | func (t *TestSuite) Validate() error { 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /krabtpl/functions.go: -------------------------------------------------------------------------------- 1 | package krabtpl 2 | 3 | import ( 4 | "text/template" 5 | 6 | "github.com/jaswdr/faker" 7 | "github.com/ohkrab/krab/krabdb" 8 | ) 9 | 10 | func Functions() template.FuncMap { 11 | fake := faker.New() 12 | 13 | return template.FuncMap{ 14 | "quote_ident": krabdb.QuoteIdent, 15 | "quote": krabdb.Quote, 16 | "fake": Fake(fake), 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /web/dto/tablespace_list.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type TablespaceListItem struct { 4 | ID uint64 `json:"ID" db:"id"` 5 | Name string `json:"name" db:"name"` 6 | Size string `json:"size" db:"size"` 7 | OwnerID uint64 `json:"ownerID" db:"owner_id"` 8 | OwnerName string `json:"ownerName" db:"owner_name"` 9 | Location string `json:"location" db:"location"` 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | /bin/ 17 | /tmp/ 18 | node_modules/ 19 | views/*_templ.go 20 | /dist/ 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@emotion/react": "^11.11.0", 4 | "@mantine/core": "^6.0.11", 5 | "@mantine/dates": "^6.0.11", 6 | "@mantine/form": "^6.0.11", 7 | "@mantine/hooks": "^6.0.11", 8 | "@mantine/modals": "^6.0.11", 9 | "@mantine/notifications": "^6.0.11", 10 | "@mantine/prism": "^6.0.11", 11 | "@mantine/spotlight": "^6.0.11", 12 | "dayjs": "^1.11.7" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /emojis/emoji.go: -------------------------------------------------------------------------------- 1 | package emojis 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/wzshiming/ctc" 7 | ) 8 | 9 | // CheckMarkColor ✔ emoji 10 | func CheckMarkColor(color ctc.Color) string { 11 | return fmt.Sprint(color, ByCode('\u2714'), ctc.Reset) 12 | } 13 | 14 | // CheckMark green check mark 15 | func CheckMark() string { 16 | return CheckMarkColor(ctc.ForegroundGreen) 17 | } 18 | 19 | func ByCode(code rune) string { 20 | return fmt.Sprintf("%c", code) 21 | } 22 | -------------------------------------------------------------------------------- /krab/hook_runner.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/jmoiron/sqlx" 8 | "github.com/ohkrab/krab/krabdb" 9 | ) 10 | 11 | type HookRunner struct { 12 | Hooks *Hooks 13 | } 14 | 15 | // SetSearchPath sets Postgres search_path. 16 | func (h HookRunner) SetSearchPath(ctx context.Context, db sqlx.ExecerContext, schema string) error { 17 | _, err := db.ExecContext(ctx, fmt.Sprint("SET search_path TO ", krabdb.QuoteIdent(schema))) 18 | return err 19 | } 20 | -------------------------------------------------------------------------------- /krab/type_set_runtime_parameters.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | // SetRuntimeParameters 8 | // https://www.postgresql.org/docs/current/sql-set.html 9 | type SetRuntimeParameters struct { 10 | SearchPath *string `hcl:"search_path"` 11 | } 12 | 13 | // ToSQL converts set parameters to SQL. 14 | func (d *SetRuntimeParameters) ToSQL(w io.StringWriter) { 15 | if d.SearchPath != nil { 16 | w.WriteString("SET search_path TO ") 17 | w.WriteString(*d.SearchPath) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /web/dto/table_list.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type TableListItem struct { 4 | DatabaseName string 5 | Schema string `db:"schema_name"` 6 | Name string `db:"name"` 7 | OwnerName string `db:"owner_name"` 8 | TablespaceName string `db:"tablespace_name"` 9 | RLS bool `db:"rls"` 10 | Internal bool `db:"internal"` 11 | Size string `db:"size"` 12 | SizePercent float64 `db:"size_percent"` 13 | EstimatedRows int64 `db:"estimated_rows"` 14 | } 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | pg: 5 | image: postgres:12.3-alpine 6 | ports: 7 | - 5432:5432 8 | environment: 9 | POSTGRES_PASSWORD: secret 10 | POSTGRES_USER: krab 11 | POSTGRES_DB: krab 12 | 13 | pgweb: 14 | container_name: pgweb 15 | image: sosedoff/pgweb 16 | ports: 17 | - "8081:8081" 18 | environment: 19 | - PGWEB_DATABASE_URL=postgres://krab:secret@pg:5432/krab?sslmode=disable 20 | depends_on: 21 | - pg 22 | -------------------------------------------------------------------------------- /krabfn/eval_context.go: -------------------------------------------------------------------------------- 1 | package krabfn 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2" 5 | "github.com/spf13/afero" 6 | "github.com/zclconf/go-cty/cty" 7 | "github.com/zclconf/go-cty/cty/function" 8 | ) 9 | 10 | // EvalContext builds default EvalContext for hcl parser. 11 | func EvalContext(fs afero.Afero) *hcl.EvalContext { 12 | ctx := &hcl.EvalContext{ 13 | Variables: map[string]cty.Value{}, 14 | Functions: map[string]function.Function{ 15 | "file_read": FnFileRead(fs), 16 | }, 17 | } 18 | 19 | return ctx 20 | } 21 | -------------------------------------------------------------------------------- /krab/inputs.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import "github.com/zclconf/go-cty/cty" 4 | 5 | // NamedInputs are params passed to command. 6 | type NamedInputs map[string]any 7 | 8 | // Inputs are params passed to command. 9 | type PositionalInputs []string 10 | 11 | func InputsFromCtyInputs(vals map[string]cty.Value) NamedInputs { 12 | inputs := NamedInputs{} 13 | for k, v := range vals { 14 | str := v.AsString() 15 | inputs[k] = str 16 | } 17 | 18 | return inputs 19 | } 20 | 21 | func (i NamedInputs) Merge(other NamedInputs) { 22 | for k, v := range other { 23 | i[k] = v 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Oh, Krab! 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/ohkrab/krab)](https://goreportcard.com/report/github.com/ohkrab/krab) 4 | [![Last commit](https://img.shields.io/github/last-commit/ohkrab/krab)](https://github.com/ohkrab/krab/commits/master) 5 | ![CI](https://github.com/ohkrab/krab/actions/workflows/ci.yml/badge.svg) 6 | 7 | ## Useful links 8 | 9 | - [Examples repository](https://github.com/ohkrab/examples) 10 | - [GitHub packages](https://github.com/orgs/ohkrab/packages) 11 | - [Docker hub](https://hub.docker.com/orgs/ohkrab/repositories) 12 | - [Documentation](https://ohkrab.dev) -------------------------------------------------------------------------------- /krab/cmd.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ohkrab/krab/krabhcl" 7 | ) 8 | 9 | // Cmd is a command that app can execute. 10 | type Cmd interface { 11 | // Addr associated with name 12 | Addr() krabhcl.Addr 13 | 14 | // Name that is mounted at API path or CLI. 15 | Name() []string 16 | 17 | // HttpMethod that is used for API call. 18 | HttpMethod() string 19 | 20 | // Do executes the action. 21 | Do(ctx context.Context, opts CmdOpts) (any, error) 22 | } 23 | 24 | // CmdOpts are options passed to command. 25 | type CmdOpts struct { 26 | NamedInputs 27 | PositionalInputs 28 | } 29 | -------------------------------------------------------------------------------- /web/dto/actions.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type ActionListItem struct { 4 | Namespace string 5 | Name string 6 | Description string 7 | Transaction bool 8 | Arguments []*ActionListItemArgument 9 | } 10 | 11 | type ActionListItemArgument struct { 12 | Name string 13 | Type string 14 | Description string 15 | } 16 | 17 | type ActionForm struct { 18 | Description string 19 | ExecutionID string 20 | Namespace string 21 | Name string 22 | Arguments []*ActionFormArgument 23 | } 24 | 25 | type ActionFormArgument struct { 26 | Name string 27 | Description string 28 | Value string 29 | } 30 | -------------------------------------------------------------------------------- /.chglog/config.yml: -------------------------------------------------------------------------------- 1 | style: github 2 | template: CHANGELOG.tpl.md 3 | info: 4 | title: CHANGELOG 5 | repository_url: https://github.com/ohkrab/krab 6 | options: 7 | commits: 8 | filters: 9 | Type: 10 | - feat 11 | - fix 12 | - perf 13 | - refactor 14 | commit_groups: 15 | # title_maps: 16 | # feat: Features 17 | # fix: Bug Fixes 18 | # perf: Performance Improvements 19 | # refactor: Code Refactoring 20 | header: 21 | pattern: "^(\\w*)\\:\\s(.*)$" 22 | pattern_maps: 23 | - Type 24 | - Subject 25 | notes: 26 | keywords: 27 | - BREAKING CHANGE 28 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - make install 4 | - make gen 5 | builds: 6 | - 7 | ldflags: 8 | - -s -w -X github.com/ohkrab/krab/krab.InfoVersion={{.Version}} -X github.com/ohkrab/krab/krab.InfoCommit={{.Commit}} -X github.com/ohkrab/krab/krab.InfoBuildDate={{.Date}} 9 | goos: 10 | - linux 11 | - darwin 12 | - windows 13 | goarch: 14 | - amd64 15 | - arm64 16 | goarm: 17 | - 7 18 | ignore: 19 | - goos: linux 20 | goarch: arm64 21 | - goos: windows 22 | goarch: arm64 23 | env: 24 | - CGO_ENABLED=0 25 | 26 | binary: krab 27 | 28 | -------------------------------------------------------------------------------- /spec/action_migrate_up_hooks_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestActionMigrateUpHooks(t *testing.T) { 8 | c := mockCli(mockConfig(` 9 | migration "create_animals" { 10 | version = "v1" 11 | 12 | up { sql = "CREATE TABLE animals(name VARCHAR)" } 13 | down { sql = "DROP TABLE animals" } 14 | } 15 | 16 | migration_set "public" { 17 | schema = "tenants" 18 | 19 | migrations = [migration.create_animals] 20 | } 21 | `)) 22 | defer c.Teardown() 23 | 24 | c.AssertSuccessfulRun(t, []string{"migrate", "up", "public"}) 25 | c.AssertSchemaMigrationTableMissing(t, "public") 26 | c.AssertSchemaMigrationTable(t, "tenants", "v1") 27 | } 28 | -------------------------------------------------------------------------------- /web/dto/database_list.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type DatabaseListItem struct { 4 | ID uint64 `db:"id"` 5 | Name string `db:"name"` 6 | OwnerID uint64 `db:"owner_id"` 7 | OwnerName string `db:"owner_name"` 8 | IsTemplate bool `db:"is_template"` 9 | ConnectionLimit int64 `db:"connection_limit"` 10 | TablespaceID uint64 `db:"tablespace_id"` 11 | TablespaceName string `db:"tablespace_name"` 12 | Size string `db:"size"` 13 | SizePercent float64 `db:"size_percent"` 14 | Encoding string `db:"encoding"` 15 | Collation string `db:"collation"` 16 | CharacterType string `db:"character_type"` 17 | CanConnect bool 18 | } 19 | -------------------------------------------------------------------------------- /cli/ui.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "io" 5 | "os" 6 | 7 | mcli "github.com/mitchellh/cli" 8 | ) 9 | 10 | // UI implements the mitchellh/cli.Ui interface. 11 | type UI interface { 12 | mcli.Ui 13 | } 14 | 15 | func New(errorWriter io.Writer, writer io.Writer) UI { 16 | ui := &mcli.ColoredUi{ 17 | Ui: &mcli.BasicUi{ErrorWriter: errorWriter, Writer: writer}, 18 | WarnColor: mcli.UiColorYellow, 19 | ErrorColor: mcli.UiColorRed, 20 | InfoColor: mcli.UiColorGreen, 21 | } 22 | 23 | return ui 24 | } 25 | 26 | func DefaultUI() UI { 27 | return New(os.Stderr, os.Stdout) 28 | } 29 | 30 | func NullUI() UI { 31 | ui := &mcli.BasicUi{ErrorWriter: io.Discard, Writer: io.Discard} 32 | 33 | return ui 34 | } 35 | -------------------------------------------------------------------------------- /krab/action_test_run.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/ohkrab/krab/cli" 8 | ) 9 | 10 | // ActionTestRun outputs test runner. 11 | type ActionTestRun struct { 12 | Ui cli.UI 13 | Cmd *CmdTestRun 14 | } 15 | 16 | func (a *ActionTestRun) Help() string { 17 | return fmt.Sprint( 18 | `Usage: krab test suite`, 19 | "\n\n", 20 | ` 21 | Starts a test suite. 22 | `, 23 | ) 24 | } 25 | 26 | func (a *ActionTestRun) Synopsis() string { 27 | return fmt.Sprintf("Test suite") 28 | } 29 | 30 | // Run in CLI. 31 | func (a *ActionTestRun) Run(args []string) int { 32 | ui := a.Ui 33 | 34 | _, err := a.Cmd.Do(context.Background(), CmdOpts{}) 35 | 36 | if err != nil { 37 | ui.Error(err.Error()) 38 | return 1 39 | } 40 | 41 | ui.Info("Done") 42 | 43 | return 0 44 | } 45 | -------------------------------------------------------------------------------- /spec/action_migrate_up_arguments_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestActionMigrateUpArguments(t *testing.T) { 8 | c := mockCli(mockConfig(` 9 | migration "create_animals" { 10 | version = "v1" 11 | 12 | up { sql = "CREATE TABLE animals(name VARCHAR)" } 13 | down { sql = "DROP TABLE animals" } 14 | } 15 | 16 | migration_set "public" { 17 | arguments { 18 | arg "schema" {} 19 | } 20 | 21 | schema = "{{.Args.schema}}" 22 | 23 | migrations = [migration.create_animals] 24 | } 25 | `)) 26 | defer c.Teardown() 27 | 28 | c.AssertSuccessfulRun(t, []string{"migrate", "up", "public", "-schema", "custom"}) 29 | c.AssertSchemaMigrationTableMissing(t, "public") 30 | c.AssertSchemaMigrationTable(t, "custom", "v1") 31 | c.AssertSQLContains(t, `SET search_path TO "custom"`) 32 | } 33 | -------------------------------------------------------------------------------- /krab/action_version.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/ohkrab/krab/cli" 8 | ) 9 | 10 | // ActionVersion prints full version. 11 | type ActionVersion struct { 12 | Ui cli.UI 13 | Cmd *CmdVersion 14 | } 15 | 16 | func (a *ActionVersion) Help() string { 17 | return `Usage: krab version 18 | 19 | Prints full version. 20 | ` 21 | } 22 | 23 | func (a *ActionVersion) Synopsis() string { 24 | return fmt.Sprintf("Print full version") 25 | } 26 | 27 | // Run in CLI. 28 | func (a *ActionVersion) Run(args []string) int { 29 | resp, err := a.Cmd.Do(context.Background(), CmdOpts{}) 30 | if err != nil { 31 | a.Ui.Error(err.Error()) 32 | return 1 33 | } 34 | 35 | response := resp.(ResponseVersion) 36 | 37 | a.Ui.Output(response.Name) 38 | a.Ui.Output(response.Build) 39 | 40 | return 0 41 | } 42 | -------------------------------------------------------------------------------- /krabdb/quote_test.go: -------------------------------------------------------------------------------- 1 | package krabdb 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestQuote(t *testing.T) { 9 | testCases := []struct { 10 | given any 11 | expected string 12 | }{ 13 | {nil, "null"}, 14 | {int64(420), "420"}, 15 | {uint64(420), "420"}, 16 | {float64(42.1), "42.1"}, 17 | {true, "true"}, 18 | {false, "false"}, 19 | {[]byte{255, 128, 0}, `'\xff8000'`}, 20 | {`krab`, `'krab'`}, 21 | {`oh'krab`, `'oh''krab'`}, 22 | {`oh\'krab`, `'oh\''krab'`}, 23 | { 24 | time.Date(2020, time.March, 1, 23, 59, 59, 999999999, time.UTC), 25 | `'2020-03-01 23:59:59.999999Z'`, 26 | }, 27 | } 28 | 29 | for i, tc := range testCases { 30 | actual := Quote(tc.given) 31 | 32 | if tc.expected != actual { 33 | t.Errorf("[%d] expected %s, but got %s", i, tc.expected, actual) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /krabfn/file.go: -------------------------------------------------------------------------------- 1 | package krabfn 2 | 3 | import ( 4 | "github.com/spf13/afero" 5 | "github.com/zclconf/go-cty/cty" 6 | "github.com/zclconf/go-cty/cty/function" 7 | ) 8 | 9 | // FnFileRead reads the whole file or returns error. 10 | // https://github.com/hashicorp/hcl/blob/main/guide/go_expression_eval.rst 11 | var FnFileRead = func(fs afero.Afero) function.Function { 12 | return function.New( 13 | &function.Spec{ 14 | Params: []function.Parameter{ 15 | {Name: "path", Type: cty.String}, 16 | }, 17 | Type: function.StaticReturnType(cty.String), 18 | Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { 19 | path := args[0].AsString() 20 | content, err := fs.ReadFile(path) 21 | if err != nil { 22 | return cty.NilVal, err 23 | } 24 | return cty.StringVal(string(content)), nil 25 | }, 26 | }, 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /test/fixtures/actions/actions.krab.hcl: -------------------------------------------------------------------------------- 1 | action "db" "create" { 2 | transaction = false 3 | description = "Create a database and assign database owner" 4 | 5 | arguments { 6 | arg "name" { 7 | description = "Database name" 8 | } 9 | 10 | arg "user" { 11 | description = "Database user" 12 | } 13 | } 14 | 15 | sql = "CREATE DATABASE {{`{{ .Args.name | quote_ident }}`}} OWNER {{`{{ .Args.user | quote_ident }}`}}" 16 | } 17 | 18 | action "user" "create" { 19 | description = "Create a database user with password" 20 | 21 | arguments { 22 | arg "user" { 23 | description = "Database user" 24 | } 25 | 26 | arg "password" { 27 | description = "Database password" 28 | } 29 | } 30 | 31 | sql = "CREATE USER {{`{{ .Args.user | quote_ident }}`}} WITH PASSWORD {{`{{ .Args.password | quote }}`}}" 32 | } 33 | -------------------------------------------------------------------------------- /krab/cmd_version.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/ohkrab/krab/krabhcl" 9 | ) 10 | 11 | // CmdVersion returns version information. 12 | type CmdVersion struct{} 13 | 14 | // ResponseVersion json 15 | type ResponseVersion struct { 16 | Name string `json:"name"` 17 | Build string `json:"build"` 18 | } 19 | 20 | func (c *CmdVersion) Addr() krabhcl.Addr { return krabhcl.Addr{Keyword: "version", Labels: []string{}} } 21 | 22 | func (c *CmdVersion) Name() []string { return []string{"version"} } 23 | 24 | func (c *CmdVersion) HttpMethod() string { return http.MethodGet } 25 | 26 | func (c *CmdVersion) Do(ctx context.Context, o CmdOpts) (any, error) { 27 | return ResponseVersion{ 28 | Name: fmt.Sprint(InfoName, " ", InfoVersion), 29 | Build: fmt.Sprint("Build ", InfoCommit, " ", InfoBuildDate), 30 | }, nil 31 | } 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.21.4-alpine3.18 AS build 2 | 3 | LABEL org.opencontainers.image.source https://github.com/ohkrab/krab 4 | 5 | WORKDIR /src 6 | COPY go.* ./ 7 | RUN go mod download 8 | RUN apk add --no-cache make 9 | 10 | ENV CGO_ENABLED=0 \ 11 | GOOS=linux \ 12 | GOARCH=amd64 13 | 14 | ARG BUILD_VERSION= 15 | ARG BUILD_DATE= 16 | ARG BUILD_COMMIT= 17 | 18 | COPY . ./ 19 | RUN go install github.com/a-h/templ/cmd/templ@latest 20 | RUN templ generate 21 | RUN go build \ 22 | -ldflags="-s -w -X 'github.com/ohkrab/krab/krab.InfoVersion=$BUILD_VERSION' -X 'github.com/ohkrab/krab/krab.InfoCommit=$BUILD_COMMIT' -X 'github.com/ohkrab/krab/krab.InfoBuildDate=$BUILD_DATE'" \ 23 | -o /tmp/krab . 24 | 25 | FROM alpine:3.18 26 | COPY --from=build /tmp/krab /usr/local/bin/krab 27 | ENTRYPOINT ["/usr/local/bin/krab"] 28 | 29 | RUN mkdir -p /etc/krab 30 | 31 | ENV KRAB_DIR=/etc/krab 32 | -------------------------------------------------------------------------------- /spec/test_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "testing" 5 | ) 6 | func TestTest(t *testing.T) { 7 | t.Skip() 8 | return 9 | c := mockCli(mockConfig(` 10 | test "spec name" { 11 | it "successful scenario" { 12 | query "SELECT 1 as c UNION ALL SELECT 2" { 13 | row "0" { 14 | col "must be 1" { assert = "c = 1" } 15 | } 16 | 17 | row "1" { 18 | col "must be 2" { assert = "c = 2" } 19 | } 20 | } 21 | } 22 | 23 | it "failed scenario" { 24 | query "SELECT 0 as c" { 25 | row "0" { 26 | col "must be 1" { assert = "c = 1" } 27 | } 28 | } 29 | } 30 | 31 | xit "skipped scenario" { 32 | query "SELECT 1 as c" { 33 | row "0" { 34 | col "must be 1" { assert = "c = 1" } 35 | } 36 | } 37 | } 38 | } 39 | `)) 40 | defer c.Teardown() 41 | 42 | c.AssertSuccessfulRun(t, []string{"test"}) 43 | } 44 | -------------------------------------------------------------------------------- /spec/action_migrate_status_arguments_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/ohkrab/krab/emojis" 8 | ) 9 | 10 | func TestActionMigrateStatusArguments(t *testing.T) { 11 | c := mockCli(mockConfig(` 12 | migration "create_animals" { 13 | version = "v1" 14 | 15 | up { sql = "CREATE TABLE animals(name VARCHAR)" } 16 | down { sql = "DROP TABLE animals" } 17 | } 18 | 19 | migration_set "animals" { 20 | arguments { 21 | arg "schema" {} 22 | } 23 | 24 | schema = "{{.Args.schema}}" 25 | 26 | migrations = [migration.create_animals] 27 | } 28 | `)) 29 | defer c.Teardown() 30 | 31 | c.AssertSuccessfulRun(t, []string{"migrate", "up", "animals", "-schema", "custom"}) 32 | c.AssertSuccessfulRun(t, []string{"migrate", "status", "animals", "-schema", "custom"}) 33 | c.AssertOutputContains(t, fmt.Sprint(emojis.CheckMark(), " v1 create_animals")) 34 | } 35 | -------------------------------------------------------------------------------- /cliargs/parser.go: -------------------------------------------------------------------------------- 1 | package cliargs 2 | 3 | import ( 4 | "flag" 5 | ) 6 | 7 | type parser struct { 8 | args []string 9 | flags *flag.FlagSet 10 | stringValues map[string]*string 11 | } 12 | 13 | func New(args []string) *parser { 14 | flags := flag.NewFlagSet("", flag.ExitOnError) 15 | return &parser{ 16 | args: args, 17 | flags: flags, 18 | stringValues: map[string]*string{}, 19 | } 20 | } 21 | 22 | func (p *parser) Parse() error { 23 | err := p.flags.Parse(p.args) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | return nil 29 | } 30 | 31 | func (p *parser) Add(name string) { 32 | p.stringValues[name] = p.flags.String(name, "", "") 33 | } 34 | 35 | func (p *parser) Args() []string { 36 | return p.flags.Args() 37 | } 38 | 39 | func (p *parser) Values() map[string]any { 40 | r := map[string]any{} 41 | 42 | for k, v := range p.stringValues { 43 | r[k] = *v 44 | } 45 | 46 | return r 47 | } 48 | -------------------------------------------------------------------------------- /views/err_404.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | templ Error404() { 4 |
5 | Logo 7 |

Oh, Krabbb!

8 |

10 | We couldn't find the page you were looking for. 11 |

12 | 14 | Return to Home 15 | 16 |
17 | } -------------------------------------------------------------------------------- /krab/validator.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | ) 7 | 8 | type Validator interface { 9 | Validate() error 10 | } 11 | 12 | // ErrorCoalesce returns first non empty error. 13 | func ErrorCoalesce(errs ...error) error { 14 | for _, err := range errs { 15 | if err != nil { 16 | return err 17 | } 18 | } 19 | 20 | return nil 21 | } 22 | 23 | // ValidateStringNonEmpty checks if string is not empty. 24 | func ValidateStringNonEmpty(what, s string) error { 25 | if len(s) > 0 { 26 | return nil 27 | } 28 | 29 | return fmt.Errorf("%s cannot be empty", what) 30 | } 31 | 32 | // ValidateRefName checks if reference name matches allowed format. 33 | func ValidateRefName(refName string) error { 34 | matched, err := regexp.Match("^[a-zA-Z_][a-zA-Z0-9_]*$", []byte(refName)) 35 | if err != nil { 36 | return err 37 | } 38 | if !matched { 39 | return fmt.Errorf("Reference `%s` has invalid format", refName) 40 | } 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /spec/action_migrate_down_transaction_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestActionMigrateDownTransactions(t *testing.T) { 8 | c := mockCli(mockConfig(` 9 | migration "create_animals" { 10 | version = "v1" 11 | 12 | up { sql = "CREATE TABLE animals(name VARCHAR)" } 13 | down { sql = "DROP TABLE animals" } 14 | } 15 | 16 | migration "add_index" { 17 | version = "v2" 18 | 19 | transaction = false 20 | 21 | up { sql = "CREATE INDEX CONCURRENTLY idx ON animals(name)" } 22 | down { sql = "DROP INDEX idx" } 23 | } 24 | 25 | migration_set "public" { 26 | migrations = [migration.create_animals, migration.add_index] 27 | } 28 | `)) 29 | defer c.Teardown() 30 | 31 | c.AssertSuccessfulRun(t, []string{"migrate", "up", "public"}) 32 | c.AssertSchemaMigrationTable(t, "public", "v1", "v2") 33 | 34 | c.AssertSuccessfulRun(t, []string{"migrate", "down", "public", "-version", "v2"}) 35 | c.AssertSchemaMigrationTable(t, "public", "v1") 36 | } 37 | -------------------------------------------------------------------------------- /krab/validator_test.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestValidateRefName(t *testing.T) { 10 | assert := assert.New(t) 11 | 12 | // allow alphanumeric and underscore 13 | assert.Nil(ValidateRefName("valid_ref")) 14 | assert.Nil(ValidateRefName("valid_123")) 15 | assert.Nil(ValidateRefName("ValidRef")) 16 | assert.Nil(ValidateRefName("___")) 17 | 18 | // cannot start with number 19 | assert.NotNil(ValidateRefName("123")) 20 | assert.NotNil(ValidateRefName("123_abc")) 21 | 22 | // cannot be empty 23 | assert.NotNil(ValidateRefName("")) 24 | 25 | // no other separators 26 | assert.NotNil(ValidateRefName("abc-def")) 27 | assert.NotNil(ValidateRefName("abc def")) 28 | } 29 | 30 | func TestValidateStringNonEmpty(t *testing.T) { 31 | assert := assert.New(t) 32 | 33 | // Length must be > 0 34 | assert.Nil(ValidateStringNonEmpty("field", "a")) 35 | assert.NotNil(ValidateStringNonEmpty("field", "")) 36 | } 37 | -------------------------------------------------------------------------------- /krab/subtype_do.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2" 7 | "github.com/zclconf/go-cty/cty" 8 | ) 9 | 10 | // Do subtype for other types. 11 | type Do struct { 12 | Migrate []*DoMigrate `hcl:"migrate,block"` 13 | CtyInputs map[string]cty.Value `hcl:"inputs,optional"` 14 | SQL string `hcl:"sql,optional"` 15 | } 16 | 17 | func (d *Do) Validate() error { 18 | for _, m := range d.Migrate { 19 | if err := m.Validate(); err != nil { 20 | return err 21 | } 22 | } 23 | 24 | return nil 25 | } 26 | 27 | type DoMigrate struct { 28 | Type string `hcl:"type,label"` 29 | SetExpr hcl.Expression `hcl:"migration_set"` 30 | CtyInputs map[string]cty.Value `hcl:"inputs,optional"` 31 | 32 | Set *MigrationSet 33 | } 34 | 35 | func (d *DoMigrate) Validate() error { 36 | switch d.Type { 37 | case "up", "down": 38 | return nil 39 | } 40 | 41 | return fmt.Errorf("Invalid type `%s` for `do` command", d.Type) 42 | } 43 | -------------------------------------------------------------------------------- /krabenv/config.go: -------------------------------------------------------------------------------- 1 | package krabenv 2 | 3 | import "os" 4 | 5 | func ConfigDir() (string, error) { 6 | if dir := os.Getenv("KRAB_DIR"); dir != "" { 7 | return dir, nil 8 | } 9 | 10 | return os.Getwd() 11 | } 12 | 13 | func DatabaseURL() string { 14 | return os.Getenv("DATABASE_URL") 15 | } 16 | 17 | func Env() string { 18 | return os.Getenv("KRAB_ENV") 19 | } 20 | 21 | func Test() bool { 22 | return Env() == "test" 23 | } 24 | 25 | func Ext() string { 26 | return ".krab.hcl" 27 | } 28 | 29 | func Auth() string { 30 | switch os.Getenv("KRAB_AUTH") { 31 | case "basic": 32 | return "basic" 33 | 34 | default: 35 | return "none" 36 | } 37 | } 38 | 39 | func HttpBasicAuthData() map[string]string { 40 | users := map[string]string{} 41 | name := os.Getenv("KRAB_AUTH_BASIC_USERNAME") 42 | pass := os.Getenv("KRAB_AUTH_BASIC_PASSWORD") 43 | if name == "" || pass == "" { 44 | panic("KRAB_AUTH_BASIC_USER or KRAB_AUTH_BASIC_PASSWORD is not set") 45 | } 46 | users[name] = pass 47 | return users 48 | } 49 | -------------------------------------------------------------------------------- /krabhcl/body.go: -------------------------------------------------------------------------------- 1 | package krabhcl 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2" 7 | ) 8 | 9 | type Body struct { 10 | hcl.Body 11 | } 12 | 13 | func (b *Body) DefRangesFromPartialContentBlocks(schema *hcl.BodySchema) []hcl.Range { 14 | ret := []hcl.Range{} 15 | content, _, diags := b.PartialContent(schema) 16 | if len(diags) > 0 { 17 | panic(fmt.Sprintf("krabhcl.Body failed to extract PartialContent from schema: %v", diags)) 18 | } 19 | for _, block := range content.Blocks { 20 | ret = append(ret, block.DefRange) 21 | } 22 | 23 | return ret 24 | } 25 | 26 | func (b *Body) DefRangesFromPartialContentAttributes(schema *hcl.BodySchema) map[string]hcl.Range { 27 | ret := map[string]hcl.Range{} 28 | content, _, diags := b.PartialContent(schema) 29 | if len(diags) > 0 { 30 | panic(fmt.Sprintf("krabhcl.Body failed to extract PartialContent from schema: %v", diags)) 31 | } 32 | for _, attr := range content.Attributes { 33 | ret[attr.Name] = attr.Range 34 | } 35 | 36 | return ret 37 | } 38 | -------------------------------------------------------------------------------- /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = ["server"] 7 | bin = "./tmp/main" 8 | cmd = "templ generate && go build -o ./tmp/main" 9 | delay = 0 10 | exclude_dir = ["assets", "tmp", "vendor", "testdata"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go", "_templ.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html", "sql", "templ"] 18 | include_file = [] 19 | kill_delay = "0s" 20 | log = "build-errors.log" 21 | poll = false 22 | poll_interval = 0 23 | rerun = false 24 | rerun_delay = 500 25 | send_interrupt = false 26 | stop_on_error = true 27 | 28 | [color] 29 | app = "" 30 | build = "yellow" 31 | main = "magenta" 32 | runner = "green" 33 | watcher = "cyan" 34 | 35 | [log] 36 | main_only = false 37 | time = false 38 | 39 | [misc] 40 | clean_on_exit = false 41 | 42 | [screen] 43 | clear_on_rebuild = false 44 | keep_scroll = true 45 | -------------------------------------------------------------------------------- /spec/action_migrate_down_hooks_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestActionMigrateDownHooks(t *testing.T) { 8 | c := mockCli(mockConfig(` 9 | migration "create_animals" { 10 | version = "v1" 11 | 12 | up { sql = "CREATE TABLE animals(name VARCHAR)" } 13 | down { sql = "DROP TABLE animals" } 14 | } 15 | 16 | migration "add_column" { 17 | version = "v2" 18 | 19 | up { sql = "ALTER TABLE animals ADD COLUMN emoji VARCHAR" } 20 | down { sql = "ALTER TABLE animals DROP COLUMN emoji" } 21 | } 22 | 23 | migration_set "tenants" { 24 | schema = "tenants" 25 | 26 | migrations = [migration.create_animals, migration.add_column] 27 | } 28 | `)) 29 | defer c.Teardown() 30 | 31 | c.AssertSuccessfulRun(t, []string{"migrate", "up", "tenants"}) 32 | c.AssertSchemaMigrationTableMissing(t, "public") 33 | c.AssertSchemaMigrationTable(t, "tenants", "v1", "v2") 34 | 35 | c.AssertSuccessfulRun(t, []string{"migrate", "down", "tenants", "-version", "v2"}) 36 | c.AssertSchemaMigrationTable(t, "tenants", "v1") 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Oh Krab! 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /views/tablespace_list.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "github.com/ohkrab/krab/web/dto" 4 | 5 | templ TablespaceList(tbs []*dto.TablespaceListItem) { 6 |

Tablespaces

7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | for _, tb := range tbs { 19 | @TablespaceListItem(tb) 20 | } 21 | 22 |
NameSizeLocationOwner
23 |
24 | } 25 | 26 | templ TablespaceListItem(tb *dto.TablespaceListItem) { 27 | 28 | { tb.Name } 29 | { tb.Size } 30 | { tb.Location } 31 | { tb.OwnerName } 32 | 33 | } -------------------------------------------------------------------------------- /tpls/templates.go: -------------------------------------------------------------------------------- 1 | package tpls 2 | 3 | import ( 4 | "strings" 5 | "text/template" 6 | ) 7 | 8 | // Templates is used for templates rendering. 9 | type Templates struct { 10 | values map[string]any 11 | template *template.Template 12 | } 13 | 14 | type root struct { 15 | Args map[string]any 16 | } 17 | 18 | // New created template renderer with values to replace. 19 | func New(values map[string]any, funcMap template.FuncMap) *Templates { 20 | t := &Templates{ 21 | values: values, 22 | template: template.New("").Funcs(funcMap), 23 | } 24 | return t 25 | } 26 | 27 | // Validate verifies if template is correct. 28 | func (t *Templates) Validate(s string) error { 29 | _, err := t.template.Parse(s) 30 | return err 31 | } 32 | 33 | // Render applies values and renders final output. 34 | func (t *Templates) Render(s string) string { 35 | sb := strings.Builder{} 36 | template, err := t.template.Parse(s) 37 | if err != nil { 38 | //TODO: handle error 39 | panic(err) 40 | } 41 | 42 | err = template.Execute(&sb, root{Args: t.values}) 43 | if err != nil { 44 | //TODO: handle error 45 | panic(err) 46 | } 47 | return sb.String() 48 | } 49 | -------------------------------------------------------------------------------- /krabdb/transaction.go: -------------------------------------------------------------------------------- 1 | package krabdb 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/jmoiron/sqlx" 8 | ) 9 | 10 | type TransactionExecerContext interface { 11 | ExecerContext 12 | 13 | Rollback() error 14 | Commit() error 15 | } 16 | 17 | // Transaction represents database transaction. 18 | type Transaction struct { 19 | tx *sqlx.Tx 20 | } 21 | 22 | func (t *Transaction) Rollback() error { 23 | return t.tx.Rollback() 24 | } 25 | 26 | func (t *Transaction) Commit() error { 27 | return t.tx.Commit() 28 | } 29 | 30 | func (t *Transaction) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { 31 | return t.tx.ExecContext(ctx, query, args...) 32 | } 33 | 34 | // NullTransaction represents fake transaction. 35 | type NullTransaction struct { 36 | db DB 37 | } 38 | 39 | func (t *NullTransaction) Rollback() error { 40 | return nil 41 | } 42 | 43 | func (t *NullTransaction) Commit() error { 44 | return nil 45 | } 46 | 47 | func (t *NullTransaction) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { 48 | return t.db.GetDatabase().ExecContext(ctx, query, args...) 49 | } 50 | -------------------------------------------------------------------------------- /spec/action_migrate_down_arguments_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestActionMigrateDownArugments(t *testing.T) { 8 | c := mockCli(mockConfig(` 9 | migration "create_animals" { 10 | version = "v1" 11 | 12 | up { sql = "CREATE TABLE animals(name VARCHAR)" } 13 | down { sql = "DROP TABLE animals" } 14 | } 15 | 16 | migration "add_column" { 17 | version = "v2" 18 | 19 | up { sql = "ALTER TABLE animals ADD COLUMN emoji VARCHAR" } 20 | down { sql = "ALTER TABLE animals DROP COLUMN emoji" } 21 | } 22 | 23 | migration_set "tenants" { 24 | arguments { 25 | arg "schema" {} 26 | } 27 | 28 | schema = "{{.Args.schema}}" 29 | 30 | migrations = [migration.create_animals, migration.add_column] 31 | } 32 | `)) 33 | defer c.Teardown() 34 | 35 | c.AssertSuccessfulRun(t, []string{"migrate", "up", "tenants", "-schema", "tenants"}) 36 | c.AssertSchemaMigrationTableMissing(t, "public") 37 | c.AssertSchemaMigrationTable(t, "tenants", "v1", "v2") 38 | 39 | c.AssertSuccessfulRun(t, []string{"migrate", "down", "tenants", "-schema", "tenants", "-version", "v2"}) 40 | c.AssertSchemaMigrationTable(t, "tenants", "v1") 41 | } 42 | -------------------------------------------------------------------------------- /views/schema_list.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "fmt" 4 | import "github.com/ohkrab/krab/web/dto" 5 | 6 | templ SchemaList(schemas []*dto.SchemaListItem) { 7 |

Schemas

8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | for _, s := range schemas { 18 | 19 | 25 | 26 | 27 | } 28 | 29 |
NameOwner
20 | 22 | { s.Name } 23 | 24 | { s.OwnerName }
30 |
31 | } -------------------------------------------------------------------------------- /krab/action_custom.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/ohkrab/krab/cli" 8 | "github.com/ohkrab/krab/cliargs" 9 | ) 10 | 11 | // ActionCustom keeps data needed to perform this action. 12 | type ActionCustom struct { 13 | Ui cli.UI 14 | Cmd *CmdAction 15 | } 16 | 17 | func (a *ActionCustom) Help() string { 18 | return fmt.Sprint( 19 | `Usage: krab action namespace name`, 20 | "\n\n", 21 | a.Cmd.Action.Arguments.Help(), 22 | ` 23 | Performs custom action. 24 | `, 25 | ) 26 | } 27 | 28 | func (a *ActionCustom) Synopsis() string { 29 | return fmt.Sprintf("Action") 30 | } 31 | 32 | // Run in CLI. 33 | func (a *ActionCustom) Run(args []string) int { 34 | ui := a.Ui 35 | flags := cliargs.New(args) 36 | 37 | for _, arg := range a.Cmd.Action.Arguments.Args { 38 | flags.Add(arg.Name) 39 | } 40 | 41 | err := flags.Parse() 42 | if err != nil { 43 | ui.Output(a.Help()) 44 | ui.Error(err.Error()) 45 | return 1 46 | } 47 | 48 | _, err = a.Cmd.Do(context.Background(), CmdOpts{NamedInputs: flags.Values()}) 49 | 50 | if err != nil { 51 | ui.Error(err.Error()) 52 | return 1 53 | } 54 | 55 | ui.Info("Done") 56 | 57 | return 0 58 | } 59 | -------------------------------------------------------------------------------- /test/fixtures/args/migrations.krab.hcl: -------------------------------------------------------------------------------- 1 | migration "create_animals" { 2 | version = "v1" 3 | 4 | up { sql = "CREATE TABLE animals(name VARCHAR)" } 5 | down { sql = "DROP TABLE animals" } 6 | } 7 | 8 | migration "create_animals_view" { 9 | version = "v2" 10 | 11 | up { sql = "CREATE MATERIALIZED VIEW anims AS SELECT name FROM animals" } 12 | down { sql = "DROP MATERIALIZED VIEW anims" } 13 | } 14 | 15 | migration "seed_animals" { 16 | version = "v3" 17 | 18 | up { sql = "INSERT INTO animals(name) VALUES('Elephant'),('Turtle'),('Cat')" } 19 | down { sql = "TRUNCATE animals" } 20 | } 21 | 22 | migration_set "animals" { 23 | arguments { 24 | arg "name" { 25 | description = "Materialized view to be refreshed" 26 | } 27 | } 28 | 29 | migrations = [ 30 | migration.create_animals, 31 | migration.create_animals_view, 32 | migration.seed_animals, 33 | ] 34 | } 35 | 36 | action "view" "refresh" { 37 | description = "Refresh a materialized view" 38 | 39 | arguments { 40 | arg "name" { 41 | description = "Materialized view to be refreshed" 42 | } 43 | } 44 | 45 | sql = "REFRESH MATERIALIZED VIEW {{ .Args.name | quote_ident }}" 46 | } 47 | -------------------------------------------------------------------------------- /krabdb/connection.go: -------------------------------------------------------------------------------- 1 | package krabdb 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/ohkrab/krab/krabenv" 8 | ) 9 | 10 | type Connection interface { 11 | Get(f func(db DB) error) error 12 | } 13 | 14 | type DefaultConnection struct{} 15 | 16 | func (d *DefaultConnection) Get(f func(db DB) error) error { 17 | db, err := Connect(krabenv.DatabaseURL()) 18 | if err != nil { 19 | return err 20 | } 21 | defer db.Close() 22 | 23 | return f(&Instance{database: db}) 24 | } 25 | 26 | func Connect(connectionString string) (*sqlx.DB, error) { 27 | db, err := sqlx.Connect("pgx", connectionString) 28 | if err != nil { 29 | return nil, err 30 | } 31 | return db, nil 32 | } 33 | 34 | type SwitchableDatabaseConnection struct { 35 | DatabaseName string 36 | } 37 | 38 | func (d *SwitchableDatabaseConnection) Get(f func(db DB) error) error { 39 | purl, err := url.Parse(krabenv.DatabaseURL()) 40 | if err != nil { 41 | return err 42 | } 43 | if purl.Path != "" { 44 | purl.Path = d.DatabaseName 45 | } 46 | db, err := Connect(purl.String()) 47 | if err != nil { 48 | return err 49 | } 50 | defer db.Close() 51 | 52 | return f(&Instance{database: db}) 53 | } 54 | -------------------------------------------------------------------------------- /spec/action_migrate_dsl_statement_ordering_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestActionMigrateDslStatementOrdering(t *testing.T) { 8 | c := mockCli(mockConfig(` 9 | migration "create_animals" { 10 | version = "v1" 11 | 12 | up { 13 | create_table "animals" { 14 | column "id" "bigint" {} 15 | } 16 | 17 | create_index "animals" "idx_id" { 18 | columns = ["id"] 19 | } 20 | 21 | sql = "ALTER INDEX idx_id RENAME TO idx_new" 22 | 23 | drop_index "idx_new" {} 24 | 25 | drop_table "animals" {} 26 | } 27 | 28 | down {} 29 | } 30 | 31 | migration_set "animals" { 32 | migrations = [ 33 | migration.create_animals 34 | ] 35 | } 36 | `)) 37 | defer c.Teardown() 38 | if c.AssertSuccessfulRun(t, []string{"migrate", "up", "animals"}) { 39 | c.AssertSchemaMigrationTable(t, "public", "v1") 40 | c.AssertSQLContains(t, ` 41 | CREATE TABLE "animals"( 42 | "id" bigint 43 | ) 44 | `) 45 | c.AssertSQLContains(t, ` 46 | CREATE INDEX "idx_id" ON "animals" 47 | `) 48 | c.AssertSQLContains(t, ` 49 | ALTER INDEX idx_id RENAME TO idx_new 50 | `) 51 | c.AssertSQLContains(t, ` 52 | DROP INDEX "idx_new" 53 | `) 54 | c.AssertSQLContains(t, ` 55 | DROP TABLE "animals" 56 | `) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/fixtures/tests/versions_test.krab.hcl: -------------------------------------------------------------------------------- 1 | test "version_inc()" { 2 | it "tests version changes" { 3 | query "SELECT version_inc(row(1,1,1)::sem_version) AS ver" { 4 | row "0" { 5 | col "increases major version, resets others" { assert = "ver = row(2,0,0)::sem_version" } 6 | } 7 | } 8 | 9 | query "SELECT version_inc(row(1,1,1)::sem_version, 'major') AS ver" { 10 | row "0" { 11 | col "increases major version, resets others" { assert = "ver = row(2,0,0)::sem_version" } 12 | } 13 | } 14 | 15 | query "SELECT version_inc(row(1,1,1)::sem_version, 'minor') AS ver" { 16 | row "0" { 17 | col "increases minor, resets patch" { assert = "ver = row(1,2,0)::sem_version" } 18 | } 19 | } 20 | 21 | query "SELECT version_inc(row(1,1,1)::sem_version, 'patch') AS ver" { 22 | row "0" { 23 | col "increases patch" { assert = "ver = row(1,1,2)::sem_version" } 24 | } 25 | } 26 | 27 | query "SELECT version_inc(null) AS ver" { 28 | row "0" { 29 | col "returns null on null input" { assert = "ver IS NULL" } 30 | } 31 | } 32 | } 33 | 34 | xit "test version on strings" { 35 | query "SELECT version_inc('1.1.1') AS ver" { 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /spec/action_migrate_up_transaction_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestActionMigrateUpTransactions(t *testing.T) { 8 | c := mockCli(mockConfig(` 9 | migration "create_animals" { 10 | version = "v1" 11 | 12 | up { sql = "CREATE TABLE animals(name VARCHAR)" } 13 | down { sql = "DROP TABLE animals" } 14 | } 15 | 16 | migration_set "public" { 17 | migrations = [migration.create_animals] 18 | } 19 | `)) 20 | defer c.Teardown() 21 | 22 | c.AssertSuccessfulRun(t, []string{"migrate", "up", "public"}) 23 | c.AssertSchemaMigrationTable(t, "public", "v1") 24 | 25 | c = mockCli(mockConfig(` 26 | migration "create_animals" { 27 | version = "v1" 28 | 29 | up { sql = "CREATE TABLE animals(name VARCHAR)" } 30 | down { sql = "DROP TABLE animals" } 31 | } 32 | 33 | migration "add_index" { 34 | version = "v2" 35 | 36 | transaction = false 37 | 38 | up { sql = "CREATE INDEX CONCURRENTLY idx ON animals(name)" } 39 | down { sql = "DROP INDEX idx" } 40 | } 41 | 42 | migration_set "public" { 43 | migrations = [migration.create_animals, migration.add_index] 44 | } 45 | `)) 46 | 47 | c.AssertSuccessfulRun(t, []string{"migrate", "up", "public"}) 48 | c.AssertSchemaMigrationTable(t, "public", "v1", "v2") 49 | } 50 | -------------------------------------------------------------------------------- /spec/action_migrate_up_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestActionMigrateUp(t *testing.T) { 8 | c := mockCli(mockConfig(` 9 | migration "do_nothing" { 10 | version = "v1" 11 | 12 | up {} 13 | down {} 14 | } 15 | 16 | migration_set "public" { 17 | migrations = [migration.do_nothing] 18 | } 19 | `)) 20 | defer c.Teardown() 21 | c.AssertSuccessfulRun(t, []string{"migrate", "up", "public"}) 22 | c.AssertOutputContains(t, "\x1b[0;32mOK \x1b[0mv1 do_nothing") 23 | c.AssertSchemaMigrationTable(t, "public", "v1") 24 | } 25 | 26 | func TestActionMigrateUpWithError(t *testing.T) { 27 | c := mockCli(mockConfig(`migration_set "public" { migrations = [] }`)) 28 | defer c.Teardown() 29 | c.AssertSuccessfulRun(t, []string{"migrate", "up", "public"}) 30 | 31 | c = mockCli(mockConfig(` 32 | migration "do_nothing" { 33 | version = "v1" 34 | 35 | up { sql = "SELECT invalid" } 36 | down {} 37 | } 38 | 39 | migration_set "public" { 40 | migrations = [migration.do_nothing] 41 | } 42 | `)) 43 | 44 | c.AssertFailedRun(t, []string{"migrate", "up", "public"}) 45 | c.AssertOutputContains(t, "\x1b[0;31mERR \x1b[0mv1 do_nothing") 46 | c.AssertUiErrorOutputContains(t, 47 | `column "invalid" does not exist`, 48 | ) 49 | c.AssertSchemaMigrationTable(t, "public") 50 | } 51 | -------------------------------------------------------------------------------- /.chglog/CHANGELOG.tpl.md: -------------------------------------------------------------------------------- 1 | {{ if .Versions -}} 2 | 3 | ## [Unreleased] 4 | 5 | {{ if .Unreleased.CommitGroups -}} 6 | {{ range .Unreleased.CommitGroups -}} 7 | ### {{ .Title }} 8 | {{ range .Commits -}} 9 | - {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }} 10 | {{ end }} 11 | {{ end -}} 12 | {{ end -}} 13 | {{ end -}} 14 | 15 | {{ range .Versions }} 16 | 17 | ## {{ if .Tag.Previous }}[{{ .Tag.Name }}]{{ else }}{{ .Tag.Name }}{{ end }} - {{ datetime "2006-01-02" .Tag.Date }} 18 | {{ range .CommitGroups -}} 19 | ### {{ .Title }} 20 | {{ range .Commits -}} 21 | - {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }} 22 | {{ end }} 23 | {{ end -}} 24 | 25 | {{- if .RevertCommits -}} 26 | ### Reverts 27 | {{ range .RevertCommits -}} 28 | - {{ .Revert.Header }} 29 | {{ end }} 30 | {{ end -}} 31 | 32 | {{- if .NoteGroups -}} 33 | {{ range .NoteGroups -}} 34 | ### {{ .Title }} 35 | {{ range .Notes }} 36 | {{ .Body }} 37 | {{ end }} 38 | {{ end -}} 39 | {{ end -}} 40 | {{ end -}} 41 | 42 | {{- if .Versions }} 43 | [Unreleased]: {{ .Info.RepositoryURL }}/compare/{{ $latest := index .Versions 0 }}{{ $latest.Tag.Name }}...HEAD 44 | {{ range .Versions -}} 45 | {{ if .Tag.Previous -}} 46 | [{{ .Tag.Name }}]: {{ $.Info.RepositoryURL }}/compare/{{ .Tag.Previous.Name }}...{{ .Tag.Name }} 47 | {{ end -}} 48 | {{ end -}} 49 | {{ end -}} -------------------------------------------------------------------------------- /krab/type_test_query.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2" 7 | "github.com/ohkrab/krab/krabhcl" 8 | ) 9 | 10 | type TestQuery struct { 11 | krabhcl.Source 12 | 13 | Query string 14 | Rows []*TestQueryRow 15 | } 16 | 17 | var schemaTestQuery = &hcl.BodySchema{ 18 | Blocks: []hcl.BlockHeaderSchema{ 19 | { 20 | Type: "row", 21 | LabelNames: []string{"scope"}, 22 | }, 23 | }, 24 | Attributes: []hcl.AttributeSchema{}, 25 | } 26 | 27 | func (q *TestQuery) DecodeHCL(ctx *hcl.EvalContext, block *hcl.Block) error { 28 | q.Source.Extract(block) 29 | q.Query = block.Labels[0] 30 | 31 | content, diags := block.Body.Content(schemaTestQuery) 32 | if diags.HasErrors() { 33 | return fmt.Errorf("failed to decode `test` block: %s", diags.Error()) 34 | } 35 | 36 | for _, b := range content.Blocks { 37 | switch b.Type { 38 | case "row": 39 | row := new(TestQueryRow) 40 | err := row.DecodeHCL(ctx, b) 41 | if err != nil { 42 | return err 43 | } 44 | q.Rows = append(q.Rows, row) 45 | 46 | default: 47 | return fmt.Errorf("Unknown block `%s` for `%s` block", b.Type, block.Type) 48 | } 49 | } 50 | 51 | for k, _ := range content.Attributes { 52 | switch k { 53 | 54 | default: 55 | return fmt.Errorf("Unknown attribute `%s` for `migration` block", k) 56 | } 57 | } 58 | 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /krab/type_test_query_row.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2" 7 | "github.com/ohkrab/krab/krabhcl" 8 | ) 9 | 10 | type TestQueryRow struct { 11 | krabhcl.Source 12 | 13 | Scope string 14 | Cols []*TestQueryCol 15 | } 16 | 17 | var schemaTestQueryRow = &hcl.BodySchema{ 18 | Blocks: []hcl.BlockHeaderSchema{ 19 | { 20 | Type: "col", 21 | LabelNames: []string{"message"}, 22 | }, 23 | }, 24 | Attributes: []hcl.AttributeSchema{}, 25 | } 26 | 27 | func (row *TestQueryRow) DecodeHCL(ctx *hcl.EvalContext, block *hcl.Block) error { 28 | row.Source.Extract(block) 29 | row.Scope = block.Labels[0] 30 | 31 | content, diags := block.Body.Content(schemaTestQueryRow) 32 | if diags.HasErrors() { 33 | return fmt.Errorf("failed to decode `test` block: %s", diags.Error()) 34 | } 35 | 36 | for _, b := range content.Blocks { 37 | switch b.Type { 38 | case "col": 39 | col := new(TestQueryCol) 40 | err := col.DecodeHCL(ctx, b) 41 | if err != nil { 42 | return err 43 | } 44 | row.Cols = append(row.Cols, col) 45 | 46 | default: 47 | return fmt.Errorf("Unknown block `%s` for `%s` block", b.Type, block.Type) 48 | } 49 | } 50 | 51 | for k, _ := range content.Attributes { 52 | switch k { 53 | 54 | default: 55 | return fmt.Errorf("Unknown attribute `%s` for `migration` block", k) 56 | } 57 | } 58 | 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /krab/cmd_registry.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "github.com/ohkrab/krab/krabdb" 5 | "github.com/ohkrab/krab/krabenv" 6 | "github.com/spf13/afero" 7 | ) 8 | 9 | // CmdRegistry is a list of registred commands. 10 | type CmdRegistry struct { 11 | Commands []Cmd 12 | 13 | FS afero.Afero 14 | VersionGenerator 15 | } 16 | 17 | // Register appends new command to registry. 18 | func (r *CmdRegistry) Register(c Cmd) { 19 | r.Commands = append(r.Commands, c) 20 | } 21 | 22 | // RegisterAll registers all commands in the registry. 23 | func (r *CmdRegistry) RegisterAll(config *Config, conn krabdb.Connection) { 24 | r.Register(&CmdVersion{}) 25 | 26 | r.Register(&CmdGenMigration{FS: r.FS, VersionGenerator: r.VersionGenerator}) 27 | 28 | for _, action := range config.Actions { 29 | action := action 30 | 31 | r.Register(&CmdAction{ 32 | Action: action, 33 | Connection: conn, 34 | }) 35 | } 36 | 37 | for _, set := range config.MigrationSets { 38 | set := set 39 | 40 | r.Register(&CmdMigrateStatus{ 41 | Set: set, 42 | Connection: conn, 43 | }) 44 | r.Register(&CmdMigrateDown{ 45 | Set: set, 46 | Connection: conn, 47 | }) 48 | r.Register(&CmdMigrateUp{ 49 | Set: set, 50 | Connection: conn, 51 | }) 52 | } 53 | 54 | if krabenv.Test() { 55 | r.Register(&CmdTestRun{ 56 | Suite: config.TestSuite, 57 | Connection: conn, 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /krab/type_test_query_col.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2" 7 | "github.com/ohkrab/krab/krabhcl" 8 | ) 9 | 10 | type TestQueryCol struct { 11 | krabhcl.Source 12 | 13 | Message string 14 | Assert string 15 | } 16 | 17 | var schemaTestQueryCol = &hcl.BodySchema{ 18 | Blocks: []hcl.BlockHeaderSchema{ 19 | }, 20 | Attributes: []hcl.AttributeSchema{ 21 | { 22 | Name: "assert", 23 | Required: true, 24 | }, 25 | }, 26 | } 27 | 28 | func (col *TestQueryCol) DecodeHCL(ctx *hcl.EvalContext, block *hcl.Block) error { 29 | col.Source.Extract(block) 30 | col.Message = block.Labels[0] 31 | 32 | content, diags := block.Body.Content(schemaTestQueryCol) 33 | if diags.HasErrors() { 34 | return fmt.Errorf("failed to decode `test` block: %s", diags.Error()) 35 | } 36 | 37 | for _, b := range content.Blocks { 38 | switch b.Type { 39 | 40 | default: 41 | return fmt.Errorf("Unknown block `%s` for `%s` block", b.Type, block.Type) 42 | } 43 | } 44 | 45 | for k, v := range content.Attributes { 46 | switch k { 47 | 48 | case "assert": 49 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 50 | val, err := expr.String() 51 | if err != nil { 52 | return err 53 | } 54 | col.Assert = val 55 | 56 | default: 57 | return fmt.Errorf("Unknown attribute `%s` for `migration` block", k) 58 | } 59 | } 60 | 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /krab/type_ddl_identity.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/hashicorp/hcl/v2" 8 | "github.com/ohkrab/krab/krabhcl" 9 | ) 10 | 11 | // DDLIdentity DSL. 12 | type DDLIdentity struct { 13 | krabhcl.Source 14 | } 15 | 16 | var schemaIdentity = &hcl.BodySchema{ 17 | Blocks: []hcl.BlockHeaderSchema{}, 18 | Attributes: []hcl.AttributeSchema{}, 19 | } 20 | 21 | // DecodeHCL parses HCL into struct. 22 | func (d *DDLIdentity) DecodeHCL(ctx *hcl.EvalContext, block *hcl.Block) error { 23 | d.Source.Extract(block) 24 | 25 | content, diags := block.Body.Content(schemaColumn) 26 | if diags.HasErrors() { 27 | return fmt.Errorf("failed to decode `%s` block: %s", block.Type, diags.Error()) 28 | } 29 | 30 | for _, b := range content.Blocks { 31 | switch b.Type { 32 | 33 | default: 34 | return fmt.Errorf("Unknown block `%s` for `%s` block", b.Type, block.Type) 35 | } 36 | } 37 | 38 | for k, _ := range content.Attributes { 39 | switch k { 40 | 41 | default: 42 | return fmt.Errorf("Unknown attribute `%s` for `%s` block", k, block.Type) 43 | } 44 | } 45 | 46 | return nil 47 | } 48 | 49 | // ToSQL converts migration definition to SQL. 50 | func (d *DDLIdentity) ToSQL(w io.StringWriter) { 51 | w.WriteString("GENERATED ALWAYS AS IDENTITY") 52 | } 53 | 54 | // ToKCL converts migration definition to KCL. 55 | func (d *DDLIdentity) ToKCL(w io.StringWriter) { 56 | w.WriteString(" identity {}") 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: Release nightly 2 | 3 | permissions: 4 | packages: write 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | release_nightly: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check Out Repo 16 | uses: actions/checkout@v3 17 | - 18 | name: Set up QEMU 19 | uses: docker/setup-qemu-action@v2 20 | - 21 | name: Set up Docker Buildx 22 | uses: docker/setup-buildx-action@v2 23 | 24 | - name: Login to Docker Hub 25 | uses: docker/login-action@v2 26 | with: 27 | username: ${{ secrets.DOCKERHUB_USERNAME }} 28 | password: ${{ secrets.DOCKERHUB_TOKEN }} 29 | 30 | - name: Login to Github Packages 31 | uses: docker/login-action@v2 32 | with: 33 | registry: ghcr.io 34 | username: ${{ github.actor }} 35 | password: ${{ secrets.GITHUB_TOKEN }} 36 | 37 | - name: Build image and push to GitHub Container Registry 38 | uses: docker/build-push-action@v4 39 | with: 40 | # relative path to the place where source code with Dockerfile is located 41 | context: . 42 | push: true 43 | tags: | 44 | ghcr.io/ohkrab/krab:nightly 45 | qbart/krab 46 | build-args: | 47 | BUILD_VERSION=nightly 48 | BUILD_COMMIT=${{ github.sha }} 49 | BUILD_DATE=${{ github.event.repository.updated_at }} 50 | -------------------------------------------------------------------------------- /krab/type_test_example_it.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2" 7 | "github.com/ohkrab/krab/krabhcl" 8 | ) 9 | 10 | // TestExampleIt represents one use case for test example that contain queries and assertions. 11 | type TestExampleIt struct { 12 | krabhcl.Source 13 | 14 | Name string 15 | Queries []*TestQuery 16 | } 17 | 18 | var schemaTestExampleIt = &hcl.BodySchema{ 19 | Blocks: []hcl.BlockHeaderSchema{ 20 | { 21 | Type: "query", 22 | LabelNames: []string{"sql"}, 23 | }, 24 | }, 25 | Attributes: []hcl.AttributeSchema{}, 26 | } 27 | 28 | func (it *TestExampleIt) DecodeHCL(ctx *hcl.EvalContext, block *hcl.Block) error { 29 | it.Source.Extract(block) 30 | 31 | it.Name = block.Labels[0] 32 | 33 | content, diags := block.Body.Content(schemaTestExampleIt) 34 | if diags.HasErrors() { 35 | return fmt.Errorf("failed to decode `test` block: %s", diags.Error()) 36 | } 37 | 38 | for _, b := range content.Blocks { 39 | switch b.Type { 40 | case "query": 41 | q := new(TestQuery) 42 | err := q.DecodeHCL(ctx, b) 43 | if err != nil { 44 | return err 45 | } 46 | it.Queries = append(it.Queries, q) 47 | 48 | default: 49 | return fmt.Errorf("Unknown block `%s` for `%s` block", b.Type, block.Type) 50 | } 51 | } 52 | 53 | for k := range content.Attributes { 54 | switch k { 55 | 56 | default: 57 | return fmt.Errorf("Unknown attribute `%s` for `migration` block", k) 58 | } 59 | } 60 | 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /spec/action_insert_fixtures_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestActionInsertFixtures(t *testing.T) { 10 | c := mockCli(mockConfig(` 11 | migration "create_fakes" { 12 | version = "v1" 13 | 14 | up { sql = "CREATE TABLE fakes(name VARCHAR)" } 15 | down { sql = "DROP TABLE fakes" } 16 | } 17 | 18 | migration_set "public" { 19 | migrations = [migration.create_fakes] 20 | } 21 | 22 | action "seed" "fakes" { 23 | description = "Insert fake data into the fakes table" 24 | 25 | sql = <<-SQL 26 | INSERT INTO fakes(name) VALUES 27 | ({{ fake "Food.Fruit" | quote }}), 28 | ({{ fake "Internet.SafeEmail" | quote }}), 29 | ({{ fake "Internet.Domain" | quote }}), 30 | ({{ fake "Internet.Ipv4" | quote }}), 31 | ({{ fake "Person.FirstName" | quote }}), 32 | ({{ fake "Person.LastName" | quote }}), 33 | ({{ fake "Person.Name" | quote }}), 34 | ({{ fake "Address.CountryCode" | quote }}), 35 | ({{ fake "Color.Hex" | quote }}) 36 | SQL 37 | } 38 | `)) 39 | defer c.Teardown() 40 | 41 | c.AssertSuccessfulRun(t, []string{"migrate", "up", "public"}) 42 | c.AssertSchemaMigrationTable(t, "public", "v1") 43 | 44 | c.AssertSuccessfulRun(t, []string{"action", "seed", "fakes"}) 45 | 46 | cols, rows := c.Query(t, "SELECT * FROM fakes") 47 | assert.ElementsMatch(t, []string{"name"}, cols, "Columns must match") 48 | if assert.Equal(t, 9, len(rows)) { 49 | for i := range rows { 50 | assert.NotEmpty(t, rows[i]["name"]) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /krab/type_ddl_drop_table.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/hashicorp/hcl/v2" 8 | "github.com/ohkrab/krab/krabdb" 9 | "github.com/ohkrab/krab/krabhcl" 10 | ) 11 | 12 | // DDLDropTable contains DSL for dropping tables. 13 | type DDLDropTable struct { 14 | krabhcl.Source 15 | 16 | Name string 17 | } 18 | 19 | var schemaDropTable = &hcl.BodySchema{} 20 | 21 | // DecodeHCL parses HCL into struct. 22 | func (d *DDLDropTable) DecodeHCL(ctx *hcl.EvalContext, block *hcl.Block) error { 23 | d.Source.Extract(block) 24 | 25 | d.Name = block.Labels[0] 26 | 27 | content, diags := block.Body.Content(schemaDropTable) 28 | if diags.HasErrors() { 29 | return fmt.Errorf("failed to decode `%s` block: %s", block.Type, diags.Error()) 30 | } 31 | 32 | for _, b := range content.Blocks { 33 | switch b.Type { 34 | 35 | default: 36 | return fmt.Errorf("Unknown block `%s` for `%s` block", b.Type, block.Type) 37 | } 38 | } 39 | 40 | for k, _ := range content.Attributes { 41 | switch k { 42 | 43 | default: 44 | return fmt.Errorf("Unknown attribute `%s` for `%s` block", k, block.Type) 45 | } 46 | } 47 | 48 | return nil 49 | } 50 | 51 | // ToSQL converts migration definition to SQL. 52 | func (d *DDLDropTable) ToSQL(w io.StringWriter) { 53 | w.WriteString("DROP TABLE ") 54 | w.WriteString(krabdb.QuoteIdent(d.Name)) 55 | } 56 | 57 | func (d *DDLDropTable) ToKCL(w io.StringWriter) { 58 | w.WriteString(" drop_table ") 59 | w.WriteString(krabdb.QuoteIdent(d.Name)) 60 | w.WriteString(" {}\n") 61 | } 62 | -------------------------------------------------------------------------------- /krab/action_gen_migration.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/ohkrab/krab/cli" 8 | "github.com/ohkrab/krab/cliargs" 9 | ) 10 | 11 | // ActionGenMigration generates migration file. 12 | type ActionGenMigration struct { 13 | Ui cli.UI 14 | Cmd *CmdGenMigration 15 | } 16 | 17 | func (a *ActionGenMigration) Help() string { 18 | return `Usage: krab gen migration 19 | 20 | Generates migration file. 21 | ` 22 | } 23 | 24 | func (a *ActionGenMigration) Synopsis() string { 25 | return fmt.Sprintf("Generate migration file") 26 | } 27 | 28 | // Run in CLI. 29 | func (a *ActionGenMigration) Run(args []string) int { 30 | ui := a.Ui 31 | flags := cliargs.New(args) 32 | 33 | for _, arg := range a.Cmd.Arguments().Args { 34 | flags.Add(arg.Name) 35 | } 36 | 37 | err := flags.Parse() 38 | if err != nil { 39 | ui.Output(a.Help()) 40 | ui.Error(err.Error()) 41 | return 1 42 | } 43 | 44 | resp, err := a.Cmd.Do(context.Background(), CmdOpts{NamedInputs: flags.Values(), PositionalInputs: flags.Args()}) 45 | if err != nil { 46 | a.Ui.Error(err.Error()) 47 | return 1 48 | } 49 | 50 | response := resp.(ResponseGenMigration) 51 | 52 | a.Ui.Output("File generated:") 53 | a.Ui.Info(response.Path) 54 | a.Ui.Output("Don't forget to add your migration to migration_set:") 55 | a.Ui.Output(` 56 | migration_set "public" { 57 | migrations = [ 58 | ...`) 59 | a.Ui.Info(fmt.Sprint(" ", response.Ref, ",")) 60 | a.Ui.Output(` ... 61 | ] 62 | } 63 | `) 64 | 65 | return 0 66 | } 67 | -------------------------------------------------------------------------------- /krab/action_migrate_status.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/ohkrab/krab/cli" 8 | "github.com/ohkrab/krab/cliargs" 9 | "github.com/ohkrab/krab/emojis" 10 | ) 11 | 12 | // ActionMigrateStatus keeps data needed to perform this action. 13 | type ActionMigrateStatus struct { 14 | Ui cli.UI 15 | Cmd *CmdMigrateStatus 16 | } 17 | 18 | func (a *ActionMigrateStatus) Help() string { 19 | return fmt.Sprint( 20 | `Usage: krab migrate status [set]`, 21 | "\n\n", 22 | a.Cmd.Set.Arguments.Help(), 23 | ` 24 | View migration status for given set. 25 | `, 26 | ) 27 | } 28 | 29 | func (a *ActionMigrateStatus) Synopsis() string { 30 | return fmt.Sprintf("Migration status for `%s`", a.Cmd.Set.RefName) 31 | } 32 | 33 | // Run in CLI. 34 | func (a *ActionMigrateStatus) Run(args []string) int { 35 | ui := a.Ui 36 | flags := cliargs.New(args) 37 | 38 | for _, arg := range a.Cmd.Set.Arguments.Args { 39 | flags.Add(arg.Name) 40 | } 41 | 42 | err := flags.Parse() 43 | if err != nil { 44 | ui.Output(a.Help()) 45 | ui.Error(err.Error()) 46 | return 1 47 | } 48 | 49 | resp, err := a.Cmd.Do(context.Background(), CmdOpts{NamedInputs: flags.Values()}) 50 | 51 | if err != nil { 52 | ui.Error(err.Error()) 53 | return 1 54 | } 55 | 56 | for _, status := range resp.([]ResponseMigrateStatus) { 57 | if status.Pending { 58 | ui.Output(cli.Red(fmt.Sprint("- ", status.Version, " ", status.Name))) 59 | } else { 60 | ui.Output(fmt.Sprint(emojis.CheckMark(), " ", status.Version, " ", status.Name)) 61 | } 62 | } 63 | 64 | return 0 65 | } 66 | -------------------------------------------------------------------------------- /krabdb/quote.go: -------------------------------------------------------------------------------- 1 | package krabdb 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/jackc/pgx/v5" 11 | ) 12 | 13 | // QuoteIdent escapes identifiers in PG. 14 | // 15 | // public -> "public" 16 | func QuoteIdent(s string) string { 17 | return pgx.Identifier{s}.Sanitize() 18 | } 19 | 20 | // QuoteIdentWithDots escapes identifiers in PG. 21 | // 22 | // public.test -> "public"."test" 23 | func QuoteIdentWithDots(s string) string { 24 | names := strings.Split(s, ".") 25 | for i, name := range names { 26 | names[i] = QuoteIdent(name) 27 | } 28 | return strings.Join(names, ".") 29 | } 30 | 31 | // QuoteIdentStrings escapes identifiers in PG. 32 | func QuoteIdentStrings(in []string) []string { 33 | out := make([]string, len(in)) 34 | for i, name := range in { 35 | out[i] = QuoteIdent(name) 36 | } 37 | return out 38 | } 39 | 40 | // Quote escapes values in PG. 41 | func Quote(o any) string { 42 | switch o := o.(type) { 43 | case nil: 44 | return "null" 45 | case int64: 46 | return strconv.FormatInt(o, 10) 47 | case uint64: 48 | return strconv.FormatUint(o, 10) 49 | case float64: 50 | return strconv.FormatFloat(o, 'f', -1, 64) 51 | case bool: 52 | return strconv.FormatBool(o) 53 | case []byte: 54 | return `'\x` + hex.EncodeToString(o) + "'" 55 | case string: 56 | return "'" + strings.ReplaceAll(o, "'", "''") + "'" 57 | case time.Time: 58 | return o.Truncate(time.Microsecond).Format("'2006-01-02 15:04:05.999999999Z07:00:00'") 59 | default: 60 | panic(fmt.Sprintf("Quote not implemented for type %T", o)) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /views/action_list.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "github.com/ohkrab/krab/web/dto" 4 | import "fmt" 5 | 6 | templ ActionList(actions []*dto.ActionListItem) { 7 |

Actions

8 |
9 | for _, action := range actions { 10 | @ActionListItem(action) 11 | } 12 |
13 | } 14 | 15 | templ ActionListItem(action *dto.ActionListItem) { 16 |
17 |
18 |

19 | { action.Namespace } { action.Name } 20 | if !action.Transaction { 21 | 22 | No transaction 23 | 24 | } 25 |

26 |

{ action.Description }

27 |
28 | 38 |
39 | } -------------------------------------------------------------------------------- /test/fixtures/tests/versions.krab.hcl: -------------------------------------------------------------------------------- 1 | migration "create_version_type" { 2 | version = "v1" 3 | 4 | up { 5 | sql = < 0 { 58 | for _, status := range result { 59 | uiMigrationStatusFromResponseUp(ui, status) 60 | } 61 | } 62 | 63 | if err != nil { 64 | ui.Error(err.Error()) 65 | return 1 66 | } 67 | 68 | if ok && len(result) == 0 { 69 | ui.Info("No pending migrations") 70 | } 71 | 72 | return 0 73 | } 74 | 75 | func uiMigrationStatusFromResponseUp(ui cli.UI, resp ResponseMigrateUp) { 76 | color := ctc.ForegroundGreen 77 | text := "OK " 78 | if !resp.Success { 79 | color = ctc.ForegroundRed 80 | text = "ERR " 81 | } 82 | 83 | ui.Output(fmt.Sprint( 84 | color, 85 | text, 86 | ctc.Reset, 87 | resp.Version, 88 | " ", 89 | resp.Name, 90 | )) 91 | } 92 | -------------------------------------------------------------------------------- /krab/type_test_example.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2" 7 | "github.com/ohkrab/krab/krabhcl" 8 | ) 9 | 10 | // TestExample represents test runner configuration. 11 | type TestExample struct { 12 | krabhcl.Source 13 | 14 | // Set *SetRuntimeParameters `hcl:"set,block"` 15 | Name string 16 | Its []*TestExampleIt 17 | Xits []*TestExampleIt 18 | } 19 | 20 | var schemaTestExample = &hcl.BodySchema{ 21 | Blocks: []hcl.BlockHeaderSchema{ 22 | { 23 | Type: "it", 24 | LabelNames: []string{"name"}, 25 | }, 26 | { 27 | Type: "xit", 28 | LabelNames: []string{"name"}, 29 | }, 30 | }, 31 | Attributes: []hcl.AttributeSchema{}, 32 | } 33 | 34 | func (t *TestExample) Addr() krabhcl.Addr { 35 | return krabhcl.Addr{Keyword: "test", Labels: []string{t.Name}} 36 | } 37 | 38 | func (t *TestExample) Validate() error { 39 | return ErrorCoalesce() 40 | } 41 | 42 | func (t *TestExample) DecodeHCL(ctx *hcl.EvalContext, block *hcl.Block) error { 43 | t.Source.Extract(block) 44 | t.Name = block.Labels[0] 45 | 46 | content, diags := block.Body.Content(schemaTestExample) 47 | if diags.HasErrors() { 48 | return fmt.Errorf("failed to decode `test` block: %s", diags.Error()) 49 | } 50 | 51 | for _, b := range content.Blocks { 52 | switch b.Type { 53 | case "it": 54 | it := new(TestExampleIt) 55 | err := it.DecodeHCL(ctx, b) 56 | if err != nil { 57 | return err 58 | } 59 | t.Its = append(t.Its, it) 60 | 61 | case "xit": 62 | it := new(TestExampleIt) 63 | err := it.DecodeHCL(ctx, b) 64 | if err != nil { 65 | return err 66 | } 67 | t.Xits = append(t.Xits, it) 68 | 69 | default: 70 | return fmt.Errorf("Unknown block `%s` for `%s` block", b.Type, block.Type) 71 | } 72 | } 73 | 74 | for k := range content.Attributes { 75 | switch k { 76 | 77 | default: 78 | return fmt.Errorf("Unknown attribute `%s` for `migration` block", k) 79 | } 80 | } 81 | 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /test/fixtures/migrations/simple.hcl: -------------------------------------------------------------------------------- 1 | connection "pg" "test" { 2 | uri = "postgres://localhost:5432/krab_test" 3 | } 4 | # bind "pg_migration" "up" { 5 | # cli = ["migrate"] 6 | # agent = true 7 | # } 8 | 9 | # # allow multiple source to migrate 10 | # command "pg_migrate_up" "default" { 11 | # migrate_up { 12 | # # connection_uri = envs.DATABASE_URI 13 | # # sets = [pg_migration_set.public] 14 | # } 15 | 16 | # migrate_up { 17 | # # connection_uri = pg_connection.default 18 | # # sets = [pg_migration_set.tenant] 19 | # } 20 | 21 | # migrate_up { 22 | # # connection_uri = params.get.database_uri 23 | # # sets = [pg_migration_set.tenant] 24 | # } 25 | # } 26 | 27 | # bind "pg_migration" "rollback" { 28 | # cli = ["db", "rollback"] 29 | 30 | # args = { 31 | # step = { 32 | # default = 1 33 | # type = "number" 34 | # } 35 | # } 36 | 37 | # triggers = [ 38 | # # command.pg_migrations_rollback.default 39 | # ] 40 | # } 41 | 42 | # command "pg_migrations_rollback" "default" { 43 | # input = { 44 | # # step = args.step 45 | # } 46 | 47 | # rollback { 48 | # # connection = pg_connection.default 49 | # # sets = [pg_migration_set.public] 50 | # } 51 | # } 52 | 53 | resource "pg_connection" "default" { 54 | # uri = envs.DATABASE_URI 55 | # = vault.app.config.db_uri 56 | # = param.database_uri? 57 | } 58 | 59 | 60 | # resource "pg_migration" "add_tenants" { 61 | # up { 62 | # sql = <Run { form.Namespace }/{ form.Name } action 8 |
9 |
10 | { form.Description } 11 |
12 | 13 | 14 | 15 |
16 | for _, arg := range form.Arguments { 17 |
18 |
19 | 20 | 21 |
22 |
23 | 25 |
26 |
27 | } 28 |
29 | 37 |
38 |
39 |
40 | } -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: default build test docker_test docker_build docker_push docker_nightly 2 | 3 | .PHONY: web 4 | web: 5 | KRAB_AUTH=none \ 6 | KRAB_AUTH_BASIC_USERNAME=krab \ 7 | KRAB_AUTH_BASIC_PASSWORD=secret \ 8 | DATABASE_URL="postgres://krab:secret@localhost:5432/krab?sslmode=disable" \ 9 | air 10 | 11 | .PHONY: gen 12 | gen: 13 | templ generate 14 | 15 | .PHONY: install 16 | install: 17 | go install github.com/cosmtrek/air@latest 18 | go install github.com/a-h/templ/cmd/templ@latest 19 | 20 | default: 21 | export DATABASE_URL="postgres://krab:secret@localhost:5432/krab?sslmode=disable" && \ 22 | export KRAB_ENV=test && \ 23 | export KRAB_DIR=./test/fixtures/tests && \ 24 | make build && \ 25 | ./bin/krab test && \ 26 | echo "ok" 27 | 28 | build: 29 | mkdir -p bin/ 30 | go build -o bin/krab main.go 31 | 32 | test: 33 | DATABASE_URL="postgres://krab:secret@localhost:5432/krab?sslmode=disable&prefer_simple_protocol=true" go test -v ./... && echo "☑️ " 34 | 35 | docker_test: 36 | docker run --rm -e DATABASE_URL="postgres://krab:secret@localhost:5432/krab?sslmode=disable" \ 37 | -v ${HOME}/oh/krab/test/fixtures/simple:/etc/krab:ro ohkrab/krab-cli:${BUILD_VERSION} version 38 | 39 | docker_build: 40 | docker build -t ohkrab/krab:${BUILD_VERSION} \ 41 | --build-arg BUILD_VERSION=${BUILD_VERSION} \ 42 | --build-arg BUILD_COMMIT=${BUILD_COMMIT} \ 43 | --build-arg BUILD_DATE=${BUILD_DATE} \ 44 | . 45 | 46 | docker_push: 47 | docker tag ohkrab/krab:${BUILD_VERSION} ohkrab/krab:latest 48 | docker push ohkrab/krab:${BUILD_VERSION} 49 | docker push ohkrab/krab:latest 50 | 51 | docker_nightly: 52 | docker build -t ohkrab/krab:nightly \ 53 | --build-arg BUILD_VERSION=nightly \ 54 | --build-arg BUILD_COMMIT=$$( git log -1 --pretty="format:%h" ) \ 55 | --build-arg BUILD_DATE=$$( date -u +"%Y-%m-%dT%H:%M:%SZ" ) \ 56 | . 57 | docker tag ohkrab/krab:nightly ohkrab/krab:latest 58 | docker push ohkrab/krab:nightly 59 | 60 | .PHONY: changelog 61 | changelog: 62 | git-chglog -o CHANGELOG.md --next-tag ${TAG} 63 | -------------------------------------------------------------------------------- /krab/type_ddl_foreign_key.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/hashicorp/hcl/v2" 9 | "github.com/ohkrab/krab/krabdb" 10 | "github.com/ohkrab/krab/krabhcl" 11 | ) 12 | 13 | // DDLForeignKey constraint DSL for table DDL. 14 | type DDLForeignKey struct { 15 | krabhcl.Source 16 | 17 | Columns []string 18 | References DDLReferences 19 | } 20 | 21 | var schemaForeignKey = &hcl.BodySchema{ 22 | Blocks: []hcl.BlockHeaderSchema{ 23 | {Type: "references", LabelNames: []string{"table"}}, 24 | }, 25 | Attributes: []hcl.AttributeSchema{ 26 | {Name: "columns", Required: true}, 27 | }, 28 | } 29 | 30 | // DecodeHCL parses HCL into struct. 31 | func (d *DDLForeignKey) DecodeHCL(ctx *hcl.EvalContext, block *hcl.Block) error { 32 | d.Source.Extract(block) 33 | 34 | d.Columns = []string{} 35 | 36 | content, diags := block.Body.Content(schemaForeignKey) 37 | if diags.HasErrors() { 38 | return fmt.Errorf("failed to decode `%s` block: %s", block.Type, diags.Error()) 39 | } 40 | 41 | for _, b := range content.Blocks { 42 | switch b.Type { 43 | case "references": 44 | err := d.References.DecodeHCL(ctx, b) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | default: 50 | return fmt.Errorf("Unknown block `%s` for `%s` block", b.Type, block.Type) 51 | } 52 | } 53 | 54 | for k, v := range content.Attributes { 55 | switch k { 56 | case "columns": 57 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 58 | val, err := expr.SliceString() 59 | if err != nil { 60 | return err 61 | } 62 | d.Columns = append(d.Columns, val...) 63 | 64 | default: 65 | return fmt.Errorf("Unknown attribute `%s` for `%s` block", k, block.Type) 66 | } 67 | } 68 | 69 | return nil 70 | } 71 | 72 | // ToSQL converts migration definition to SQL. 73 | func (d *DDLForeignKey) ToSQL(w io.StringWriter) { 74 | w.WriteString("FOREIGN KEY (") 75 | cols := krabdb.QuoteIdentStrings(d.Columns) 76 | w.WriteString(strings.Join(cols, ",")) 77 | w.WriteString(") ") 78 | d.References.ToSQL(w) 79 | } 80 | -------------------------------------------------------------------------------- /krab/action_migrate_down.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/ohkrab/krab/cli" 8 | "github.com/ohkrab/krab/cliargs" 9 | "github.com/wzshiming/ctc" 10 | ) 11 | 12 | // ActionMigrateDown keeps data needed to perform this action. 13 | type ActionMigrateDown struct { 14 | Ui cli.UI 15 | Cmd *CmdMigrateDown 16 | } 17 | 18 | func (a *ActionMigrateDown) Help() string { 19 | return fmt.Sprint( 20 | `Usage: krab migrate down [set] -version VERSION`, 21 | "\n\n", 22 | a.Cmd.Arguments().Help(), 23 | a.Cmd.Set.Arguments.Help(), 24 | ` 25 | Rollback migration in given [set] identified by VERSION. 26 | 27 | Example: 28 | 29 | krab migrate down default -version 20060102150405 30 | `, 31 | ) 32 | } 33 | 34 | func (a *ActionMigrateDown) Synopsis() string { 35 | return fmt.Sprintf("Migrate `%s` down", a.Cmd.Set.RefName) 36 | } 37 | 38 | // Run in CLI. 39 | func (a *ActionMigrateDown) Run(args []string) int { 40 | flags := cliargs.New(args) 41 | 42 | for _, arg := range a.Cmd.Set.Arguments.Args { 43 | flags.Add(arg.Name) 44 | } 45 | // default arguments always take precedence over custom ones 46 | for _, arg := range a.Cmd.Arguments().Args { 47 | flags.Add(arg.Name) 48 | } 49 | 50 | err := flags.Parse() 51 | if err != nil { 52 | a.Ui.Output(a.Help()) 53 | a.Ui.Error(err.Error()) 54 | return 1 55 | } 56 | 57 | resp, err := a.Cmd.Do(context.Background(), CmdOpts{NamedInputs: flags.Values()}) 58 | result, ok := resp.([]ResponseMigrateDown) 59 | 60 | if err != nil { 61 | a.Ui.Error(err.Error()) 62 | return 1 63 | } 64 | 65 | if ok { 66 | for _, status := range result { 67 | uiMigrationStatusFromResponseDown(a.Ui, status) 68 | } 69 | } 70 | 71 | return 0 72 | } 73 | 74 | func uiMigrationStatusFromResponseDown(ui cli.UI, resp ResponseMigrateDown) { 75 | color := ctc.ForegroundGreen 76 | text := "OK " 77 | if !resp.Success { 78 | color = ctc.ForegroundRed 79 | text = "ERR " 80 | } 81 | 82 | ui.Output(fmt.Sprint( 83 | color, 84 | text, 85 | ctc.Reset, 86 | resp.Version, 87 | " ", 88 | resp.Name, 89 | )) 90 | } 91 | -------------------------------------------------------------------------------- /krab/type_ddl_drop_index.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/hashicorp/hcl/v2" 8 | "github.com/ohkrab/krab/krabdb" 9 | "github.com/ohkrab/krab/krabhcl" 10 | ) 11 | 12 | // DDLDropIndex contains DSL for dropping indicies. 13 | type DDLDropIndex struct { 14 | krabhcl.Source 15 | 16 | Name string 17 | Cascade bool 18 | Concurrently bool 19 | } 20 | 21 | var schemaDropIndex = &hcl.BodySchema{ 22 | Blocks: []hcl.BlockHeaderSchema{}, 23 | Attributes: []hcl.AttributeSchema{ 24 | {Name: "cascade", Required: false}, 25 | {Name: "concurrently", Required: false}, 26 | }, 27 | } 28 | 29 | // DecodeHCL parses HCL into struct. 30 | func (d *DDLDropIndex) DecodeHCL(ctx *hcl.EvalContext, block *hcl.Block) error { 31 | d.Source.Extract(block) 32 | 33 | d.Name = block.Labels[0] 34 | d.Cascade = false 35 | d.Concurrently = false 36 | 37 | content, diags := block.Body.Content(schemaDropIndex) 38 | if diags.HasErrors() { 39 | return fmt.Errorf("failed to decode `%s` block: %s", block.Type, diags.Error()) 40 | } 41 | 42 | for _, b := range content.Blocks { 43 | switch b.Type { 44 | 45 | default: 46 | return fmt.Errorf("Unknown block `%s` for `%s` block", b.Type, block.Type) 47 | } 48 | } 49 | 50 | for k, v := range content.Attributes { 51 | switch k { 52 | case "concurrently": 53 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 54 | val, err := expr.Bool() 55 | if err != nil { 56 | return err 57 | } 58 | d.Concurrently = val 59 | 60 | case "cascade": 61 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 62 | val, err := expr.Bool() 63 | if err != nil { 64 | return err 65 | } 66 | d.Cascade = val 67 | 68 | default: 69 | return fmt.Errorf("Unknown attribute `%s` for `%s` block", k, block.Type) 70 | } 71 | } 72 | 73 | return nil 74 | } 75 | 76 | // ToSQL converts migration definition to SQL. 77 | func (d *DDLDropIndex) ToSQL(w io.StringWriter) { 78 | w.WriteString("DROP INDEX") 79 | if d.Concurrently { 80 | w.WriteString(" CONCURRENTLY") 81 | } 82 | w.WriteString(" ") 83 | w.WriteString(krabdb.QuoteIdentWithDots(d.Name)) 84 | if d.Cascade { 85 | w.WriteString(" CASCADE") 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /views/table_list.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "fmt" 4 | import "github.com/ohkrab/krab/web/dto" 5 | 6 | templ TableList(tables []*dto.TableListItem) { 7 |

Tables

8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | for _, t := range tables { 22 | @TableListItem(t) 23 | } 24 | 25 |
Name~RowsSizeTablespaceOwnerRow-Level Security
26 |
27 | } 28 | 29 | 30 | templ TableListItem(t *dto.TableListItem) { 31 | 32 | 33 | if t.Internal { 34 | { t.Name } 35 | } else { 36 | 38 | { t.Name } 39 | 40 | } 41 | 42 | 43 | { fmt.Sprint(t.EstimatedRows) } 44 | 45 | 46 |
47 |
48 | 49 |
50 |
51 |
{ t.Size }
52 |
53 | 54 | { t.TablespaceName } 55 | { t.OwnerName } 56 | 57 | if t.RLS { 58 | Yes 59 | } else { 60 | No 61 | } 62 | 63 | 64 | } -------------------------------------------------------------------------------- /spec/action_gen_migration_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestActionGenMigration(t *testing.T) { 11 | c := mockCli(mockConfig(``)) 12 | defer c.Teardown() 13 | c.AssertSuccessfulRun(t, []string{"gen", "migration", "-name", "create_maps"}) 14 | c.AssertOutputContains(t, "migration.create_maps") 15 | files := c.FSFiles() 16 | assert.Len(t, files, 1) 17 | for k, b := range files { 18 | expected := `migration "create_maps" { 19 | version = "20230101" 20 | 21 | up { 22 | create_table "maps" { 23 | } 24 | } 25 | 26 | down { 27 | drop_table "maps" {} 28 | } 29 | }` 30 | ok, err := c.fs.FileContainsBytes(k, []byte(expected)) 31 | assert.NoError(t, err) 32 | if !ok { 33 | fmt.Println("Expected:", expected) 34 | fmt.Println("Current:", string(b)) 35 | assert.FailNow(t, "Output file does not contain valid data") 36 | } 37 | } 38 | } 39 | 40 | func TestActionGenMigrationWithParams(t *testing.T) { 41 | c := mockCli(mockConfig(``)) 42 | defer c.Teardown() 43 | c.AssertSuccessfulRun(t, []string{ 44 | "gen", "migration", "-name", "create_maps", 45 | "id", "name:varchar", "project_id:bigint", "timestamps", 46 | }) 47 | c.AssertOutputContains(t, "migration.create_maps") 48 | files := c.FSFiles() 49 | assert.Len(t, files, 1) 50 | for k, b := range files { 51 | expected := `migration "create_maps" { 52 | version = "20230101" 53 | 54 | up { 55 | create_table "maps" { 56 | column "id" "bigint" { 57 | identity {} 58 | } 59 | column "name" "varchar" {} 60 | column "project_id" "bigint" {} 61 | column "created_at" "timestamptz" { 62 | null = false 63 | } 64 | column "updated_at" "timestamptz" { 65 | null = false 66 | } 67 | primary_key { 68 | columns = ["id"] 69 | } 70 | } 71 | } 72 | 73 | down { 74 | drop_table "maps" {} 75 | } 76 | }` 77 | ok, err := c.fs.FileContainsBytes(k, []byte(expected)) 78 | assert.NoError(t, err) 79 | if !ok { 80 | fmt.Println("Expected:", expected) 81 | fmt.Println("Current:", string(b)) 82 | assert.FailNow(t, "Output file does not contain valid data") 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /spec/action_migrate_dsl_index_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestActionMigrateDslIndex(t *testing.T) { 8 | c := mockCli(mockConfig(` 9 | migration "create_animals" { 10 | version = "v1" 11 | transaction = false 12 | 13 | up { 14 | create_table "animals" { 15 | column "id" "bigint" {} 16 | 17 | column "name" "varchar" {} 18 | 19 | column "extinct" "boolean" {} 20 | 21 | column "weight_kg" "int" {} 22 | } 23 | 24 | create_index "animals" "idx_uniq_name" { 25 | unique = true 26 | columns = ["name"] 27 | using = "btree" 28 | include = ["weight_kg"] 29 | } 30 | 31 | create_index "animals" "idx_heavy_animals" { 32 | columns = ["weight_kg"] 33 | where = "weight_kg > 5000" 34 | concurrently = true 35 | } 36 | } 37 | 38 | down { 39 | drop_index "public.idx_uniq_name" { 40 | cascade = true 41 | } 42 | 43 | drop_index "idx_heavy_animals" { 44 | concurrently = true 45 | } 46 | 47 | drop_table "animals" {} 48 | } 49 | } 50 | 51 | migration_set "animals" { 52 | migrations = [ 53 | migration.create_animals 54 | ] 55 | } 56 | `)) 57 | defer c.Teardown() 58 | if c.AssertSuccessfulRun(t, []string{"migrate", "up", "animals"}) { 59 | c.AssertSchemaMigrationTable(t, "public", "v1") 60 | c.AssertOutputContains(t, "\x1b[0;32mOK \x1b[0mv1 create_animals") 61 | c.AssertSQLContains(t, ` 62 | CREATE TABLE "animals"( 63 | "id" bigint, 64 | "name" varchar, 65 | "extinct" boolean, 66 | "weight_kg" int 67 | ) 68 | `) 69 | c.AssertSQLContains(t, ` 70 | CREATE UNIQUE INDEX "idx_uniq_name" ON "animals" USING btree ("name") INCLUDE ("weight_kg") 71 | `) 72 | c.AssertSQLContains(t, ` 73 | CREATE INDEX CONCURRENTLY "idx_heavy_animals" ON "animals" ("weight_kg") WHERE (weight_kg > 5000) 74 | `) 75 | 76 | if c.AssertSuccessfulRun(t, []string{"migrate", "down", "animals", "-version", "v1"}) { 77 | c.AssertSchemaMigrationTable(t, "public") 78 | c.AssertOutputContains(t, "\x1b[0;32mOK \x1b[0mv1 create_animals") 79 | c.AssertSQLContains(t, `DROP INDEX "public"."idx_uniq_name" CASCADE`) 80 | c.AssertSQLContains(t, `DROP INDEX CONCURRENTLY "idx_heavy_animals"`) 81 | c.AssertSQLContains(t, `DROP TABLE "animals"`) 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /krab/type_ddl_unique.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/hashicorp/hcl/v2" 9 | "github.com/ohkrab/krab/krabdb" 10 | "github.com/ohkrab/krab/krabhcl" 11 | ) 12 | 13 | // DDLUnique constraint DSL for table DDL. 14 | type DDLUnique struct { 15 | krabhcl.Source 16 | 17 | Columns []string 18 | Include []string 19 | } 20 | 21 | var schemaUnique = &hcl.BodySchema{ 22 | Blocks: []hcl.BlockHeaderSchema{}, 23 | Attributes: []hcl.AttributeSchema{ 24 | {Name: "columns", Required: true}, 25 | {Name: "include", Required: false}, 26 | }, 27 | } 28 | 29 | // DecodeHCL parses HCL into struct. 30 | func (d *DDLUnique) DecodeHCL(ctx *hcl.EvalContext, block *hcl.Block) error { 31 | d.Source.Extract(block) 32 | 33 | d.Columns = []string{} 34 | d.Include = []string{} 35 | 36 | content, diags := block.Body.Content(schemaUnique) 37 | if diags.HasErrors() { 38 | return fmt.Errorf("failed to decode `%s` block: %s", block.Type, diags.Error()) 39 | } 40 | 41 | for _, b := range content.Blocks { 42 | switch b.Type { 43 | 44 | default: 45 | return fmt.Errorf("Unknown block `%s` for `%s` block", b.Type, block.Type) 46 | } 47 | } 48 | 49 | for k, v := range content.Attributes { 50 | switch k { 51 | case "columns": 52 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 53 | val, err := expr.SliceString() 54 | if err != nil { 55 | return err 56 | } 57 | d.Columns = append(d.Columns, val...) 58 | 59 | case "include": 60 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 61 | val, err := expr.SliceString() 62 | if err != nil { 63 | return err 64 | } 65 | d.Include = append(d.Include, val...) 66 | 67 | default: 68 | return fmt.Errorf("Unknown attribute `%s` for `%s` block", k, block.Type) 69 | } 70 | } 71 | 72 | return nil 73 | } 74 | 75 | // ToSQL converts migration definition to SQL. 76 | func (d *DDLUnique) ToSQL(w io.StringWriter) { 77 | w.WriteString("UNIQUE (") 78 | cols := krabdb.QuoteIdentStrings(d.Columns) 79 | w.WriteString(strings.Join(cols, ",")) 80 | w.WriteString(")") 81 | 82 | if len(d.Include) > 0 { 83 | w.WriteString(" INCLUDE (") 84 | include := krabdb.QuoteIdentStrings(d.Include) 85 | w.WriteString(strings.Join(include, ",")) 86 | w.WriteString(")") 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /krab/file.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2" 7 | ) 8 | 9 | // File represents all resource definitions within a single file. 10 | type File struct { 11 | File *hcl.File 12 | 13 | Migrations []*Migration 14 | MigrationSets []*MigrationSet 15 | Actions []*Action 16 | TestSuite *TestSuite 17 | TestExamples []*TestExample 18 | } 19 | 20 | var schemaFile = &hcl.BodySchema{ 21 | Blocks: []hcl.BlockHeaderSchema{ 22 | { 23 | Type: "migration", 24 | LabelNames: []string{"name"}, 25 | }, 26 | { 27 | Type: "migration_set", 28 | LabelNames: []string{"name"}, 29 | }, 30 | { 31 | Type: "action", 32 | LabelNames: []string{"namespace", "name"}, 33 | }, 34 | { 35 | Type: "test", 36 | LabelNames: []string{"name"}, 37 | }, 38 | }, 39 | Attributes: []hcl.AttributeSchema{}, 40 | } 41 | 42 | // Decode parses HCL into struct. 43 | func (f *File) Decode(ctx *hcl.EvalContext) error { 44 | f.Migrations = []*Migration{} 45 | f.MigrationSets = []*MigrationSet{} 46 | f.Actions = []*Action{} 47 | f.TestSuite = &TestSuite{} 48 | f.TestExamples = []*TestExample{} 49 | 50 | content, diags := f.File.Body.Content(schemaFile) 51 | if diags.HasErrors() { 52 | return fmt.Errorf("failed to decode file body: %s", diags.Error()) 53 | } 54 | 55 | for _, b := range content.Blocks { 56 | switch b.Type { 57 | case "migration": 58 | migration := new(Migration) 59 | err := migration.DecodeHCL(ctx, b) 60 | if err != nil { 61 | return err 62 | } 63 | f.Migrations = append(f.Migrations, migration) 64 | 65 | case "migration_set": 66 | migrationSet := new(MigrationSet) 67 | err := migrationSet.DecodeHCL(ctx, b) 68 | if err != nil { 69 | return err 70 | } 71 | f.MigrationSets = append(f.MigrationSets, migrationSet) 72 | 73 | case "action": 74 | action := new(Action) 75 | err := action.DecodeHCL(ctx, b) 76 | if err != nil { 77 | return err 78 | } 79 | f.Actions = append(f.Actions, action) 80 | 81 | case "test": 82 | test := new(TestExample) 83 | err := test.DecodeHCL(ctx, b) 84 | if err != nil { 85 | return err 86 | } 87 | f.TestExamples = append(f.TestExamples, test) 88 | 89 | default: 90 | return fmt.Errorf("Unknown block `%s`", b.Type) 91 | } 92 | } 93 | 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ohkrab/krab 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/a-h/templ v0.3.833 7 | github.com/emirpasic/gods v1.18.1 8 | github.com/go-chi/chi/v5 v5.2.1 9 | github.com/go-chi/render v1.0.3 10 | github.com/google/uuid v1.6.0 11 | github.com/hashicorp/hcl/v2 v2.23.0 12 | github.com/hashicorp/hcl2 v0.0.0-20191002203319-fb75b3253c80 13 | github.com/jackc/pgx/v5 v5.7.2 14 | github.com/jaswdr/faker v1.19.1 15 | github.com/jmoiron/sqlx v1.4.0 16 | github.com/mitchellh/cli v1.1.5 17 | github.com/pkg/errors v0.9.1 18 | github.com/spf13/afero v1.14.0 19 | github.com/stretchr/testify v1.8.2 20 | github.com/wzshiming/ctc v1.2.3 21 | github.com/zclconf/go-cty v1.16.2 22 | ) 23 | 24 | require ( 25 | dario.cat/mergo v1.0.1 // indirect 26 | github.com/Masterminds/goutils v1.1.1 // indirect 27 | github.com/Masterminds/semver/v3 v3.3.1 // indirect 28 | github.com/Masterminds/sprig/v3 v3.3.0 // indirect 29 | github.com/agext/levenshtein v1.2.3 // indirect 30 | github.com/ajg/form v1.5.1 // indirect 31 | github.com/apparentlymart/go-textseg v1.0.0 // indirect 32 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect 33 | github.com/armon/go-radix v1.0.0 // indirect 34 | github.com/bgentry/speakeasy v0.2.0 // indirect 35 | github.com/davecgh/go-spew v1.1.1 // indirect 36 | github.com/fatih/color v1.18.0 // indirect 37 | github.com/hashicorp/errwrap v1.1.0 // indirect 38 | github.com/hashicorp/go-multierror v1.1.1 // indirect 39 | github.com/huandu/xstrings v1.5.0 // indirect 40 | github.com/jackc/pgpassfile v1.0.0 // indirect 41 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 42 | github.com/jackc/puddle/v2 v2.2.2 // indirect 43 | github.com/mattn/go-colorable v0.1.14 // indirect 44 | github.com/mattn/go-isatty v0.0.20 // indirect 45 | github.com/mitchellh/copystructure v1.2.0 // indirect 46 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 47 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 48 | github.com/pmezard/go-difflib v1.0.0 // indirect 49 | github.com/posener/complete v1.2.3 // indirect 50 | github.com/shopspring/decimal v1.4.0 // indirect 51 | github.com/spf13/cast v1.7.1 // indirect 52 | github.com/wzshiming/winseq v0.0.0-20200720163736-7fa652d2b50e // indirect 53 | golang.org/x/crypto v0.36.0 // indirect 54 | golang.org/x/mod v0.24.0 // indirect 55 | golang.org/x/sync v0.12.0 // indirect 56 | golang.org/x/sys v0.31.0 // indirect 57 | golang.org/x/text v0.23.0 // indirect 58 | golang.org/x/tools v0.31.0 // indirect 59 | gopkg.in/yaml.v3 v3.0.1 // indirect 60 | ) 61 | -------------------------------------------------------------------------------- /krab/sql_statement.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "io" 5 | "sort" 6 | "strings" 7 | 8 | "github.com/hashicorp/hcl/v2" 9 | ) 10 | 11 | // ToSQL converts DSL struct to SQL. 12 | type ToSQL interface { 13 | ToSQL(w io.StringWriter) 14 | } 15 | 16 | // SQLStatement represents raw SQL statement. 17 | type SQLStatement string 18 | 19 | // SQLStatements represents list of raw SQL statements. 20 | type SQLStatements []SQLStatement 21 | 22 | // Append adds new SQL statement to the list from object that satisfies ToSQL interface. 23 | func (s *SQLStatements) Append(sql ToSQL) { 24 | sb := &strings.Builder{} 25 | sql.ToSQL(sb) 26 | *s = append(*s, SQLStatement(sb.String())) 27 | } 28 | 29 | // SQLStatementsSorter sorts SQLStatement by the order how they are defined in a file. 30 | type SQLStatementsSorter struct { 31 | Statements SQLStatements 32 | Bytes []int 33 | } 34 | 35 | // Len is the number of elements in the collection. 36 | func (s *SQLStatementsSorter) Len() int { 37 | return len(s.Statements) 38 | } 39 | 40 | // Less reports whether the element with index i 41 | // must sort before the element with index j. 42 | // 43 | // If both Less(i, j) and Less(j, i) are false, 44 | // then the elements at index i and j are considered equal. 45 | // Sort may place equal elements in any order in the final result, 46 | // while Stable preserves the original input order of equal elements. 47 | // 48 | // Less must describe a transitive ordering: 49 | // - if both Less(i, j) and Less(j, k) are true, then Less(i, k) must be true as well. 50 | // - if both Less(i, j) and Less(j, k) are false, then Less(i, k) must be false as well. 51 | // 52 | // Note that floating-point comparison (the < operator on float32 or float64 values) 53 | // is not a transitive ordering when not-a-number (NaN) values are involved. 54 | // See Float64Slice.Less for a correct implementation for floating-point values. 55 | func (s *SQLStatementsSorter) Less(i int, j int) bool { 56 | return s.Bytes[i] < s.Bytes[j] 57 | } 58 | 59 | // Swap swaps the elements with indexes i and j. 60 | func (s *SQLStatementsSorter) Swap(i int, j int) { 61 | s.Bytes[i], s.Bytes[j] = s.Bytes[j], s.Bytes[i] 62 | s.Statements[i], s.Statements[j] = s.Statements[j], s.Statements[i] 63 | } 64 | 65 | // Insert ToSQL at given range. 66 | func (s *SQLStatementsSorter) Insert(r hcl.Range, sql ToSQL) { 67 | s.Statements.Append(sql) 68 | s.Bytes = append(s.Bytes, r.Start.Byte) 69 | } 70 | 71 | // Sort sorts statements by byte range. 72 | func (s *SQLStatementsSorter) Sort() SQLStatements { 73 | sort.Sort(s) 74 | return s.Statements 75 | } 76 | -------------------------------------------------------------------------------- /krabtpl/fake.go: -------------------------------------------------------------------------------- 1 | package krabtpl 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/jaswdr/faker" 8 | "github.com/wzshiming/ctc" 9 | ) 10 | 11 | // Fake generates fake data 12 | func Fake(fake faker.Faker) any { 13 | return func(id string) string { 14 | switch id { 15 | case "Address.Country": 16 | return fake.Address().Country() 17 | case "Address.CountryCode": 18 | return fake.Address().CountryCode() 19 | 20 | case "Color.Name": 21 | return fake.Color().ColorName() 22 | case "Color.Hex": 23 | return fake.Color().Hex() 24 | case "Color.RGB": 25 | return fake.Color().RGB() 26 | 27 | case "Company.Name": 28 | return fake.Company().Name() 29 | 30 | case "Emoji.Emoji": 31 | return fake.Emoji().Emoji() 32 | case "Emoji.EmojiCode": 33 | return fake.Emoji().EmojiCode() 34 | 35 | case "File.Path": 36 | return fake.File().AbsoluteFilePathForUnix(4) 37 | case "File.Extension": 38 | return fake.File().Extension() 39 | case "File.FilenameWithExtension": 40 | return fake.File().FilenameWithExtension() 41 | case "File.MimeType": 42 | return fake.MimeType().MimeType() 43 | 44 | case "Food.Fruit": 45 | return fake.Food().Fruit() 46 | case "Food.Vegetable": 47 | return fake.Food().Vegetable() 48 | 49 | case "Hash.MD5": 50 | return fake.Hash().MD5() 51 | case "Hash.SHA256": 52 | return fake.Hash().SHA256() 53 | 54 | case "Internet.Domain": 55 | return fake.Internet().Domain() 56 | case "Internet.Email": 57 | return fake.Internet().Email() 58 | case "Internet.Ipv4": 59 | return fake.Internet().Ipv4() 60 | case "Internet.Ipv6": 61 | return fake.Internet().Ipv6() 62 | case "Internet.MacAddress": 63 | return fake.Internet().MacAddress() 64 | case "Internet.SafeEmail": 65 | return fake.Internet().SafeEmail() 66 | case "Internet.Slug": 67 | return fake.Internet().Slug() 68 | case "Internet.URL": 69 | return fake.Internet().URL() 70 | case "Internet.UserAgent": 71 | return fake.UserAgent().UserAgent() 72 | 73 | case "Lorem.Paragraph": 74 | return fake.Lorem().Paragraph(5) 75 | case "Lorem.Word": 76 | return fake.Lorem().Word() 77 | 78 | case "Person.FirstName": 79 | return fake.Person().FirstName() 80 | case "Person.LastName": 81 | return fake.Person().LastName() 82 | case "Person.Name": 83 | return fake.Person().Name() 84 | 85 | case "Time.ISO8601": 86 | return fake.Time().ISO8601(time.Now()) 87 | case "Time.Timezone": 88 | return fake.Time().Timezone() 89 | } 90 | 91 | panic(fmt.Sprintf("Invalid generator name: %s%s%s", ctc.ForegroundRed, id, ctc.Reset)) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /test/fixtures/simple/migrations.krab.hcl: -------------------------------------------------------------------------------- 1 | migration "add_tenants" { 2 | version = "202006_01" 3 | 4 | up { 5 | sql = "CREATE TABLE tenants(name varchar PRIMARY KEY)" 6 | } 7 | 8 | down { 9 | sql = "DROP TABLE tenants" 10 | } 11 | } 12 | 13 | migration "add_tenants_index" { 14 | version = "202108_01" 15 | transaction = false 16 | 17 | up { 18 | sql = "CREATE INDEX CONCURRENTLY idx_tenants_name ON tenants(name)" 19 | } 20 | 21 | down { 22 | sql = "DROP INDEX CONCURRENTLY idx_tenants_name" 23 | } 24 | } 25 | 26 | migration_set "public" { 27 | migrations = [ 28 | migration.add_tenants, 29 | migration.add_tenants_index, 30 | ] 31 | } 32 | 33 | migration "add_users" { 34 | version = "202006_01" 35 | 36 | up { 37 | sql = "CREATE TABLE users(email varchar PRIMARY KEY)" 38 | } 39 | 40 | down { 41 | sql = "DROP TABLE users" 42 | } 43 | } 44 | 45 | migration_set "tenant" { 46 | arguments { 47 | arg "schema" { 48 | type = "string" 49 | description = "Schema where to create everything" 50 | } 51 | } 52 | 53 | schema = "{{.Args.schema}}" 54 | 55 | migrations = [ 56 | migration.create_assets 57 | ] 58 | } 59 | 60 | migration "create_assets" { 61 | version = "tenants_v1" 62 | 63 | up { 64 | # create_table "assets" { 65 | # column "id" { 66 | # type = "int" 67 | # identity { 68 | # always = true 69 | # } 70 | # } 71 | 72 | # column "name" { 73 | # type = "varchar" 74 | # null = false 75 | # } 76 | 77 | # column "size" { 78 | # type = "int" 79 | # default = 0 80 | # } 81 | 82 | 83 | # constraint "ensure_positive_size" { 84 | # check = "size > 0" 85 | # } 86 | # } 87 | 88 | # alter_table "users" { 89 | # add_column "email" { 90 | # type = "varchar" 91 | # null = true 92 | # } 93 | 94 | # drop_column "deprecated_field" {} 95 | 96 | # primary_key = ["email", "name"] 97 | 98 | # create_index "idx_uniq_emails" { 99 | # unique = true 100 | # columns = ["email"] 101 | # } 102 | 103 | # constraint "users_pk" { 104 | # columns = ["email"] 105 | # check "valid_nu 106 | # } 107 | # } 108 | } 109 | 110 | # down = up.reverse 111 | down {} 112 | 113 | # hooks { 114 | # after "up" { 115 | # do = wasm.file("../wasm/migrate_from_old_system.wasm") 116 | # } 117 | # } 118 | } 119 | -------------------------------------------------------------------------------- /krab/type_ddl_primary_key.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/hashicorp/hcl/v2" 9 | "github.com/ohkrab/krab/krabdb" 10 | "github.com/ohkrab/krab/krabhcl" 11 | ) 12 | 13 | // DDLPrimaryKey constraint DSL for table DDL. 14 | type DDLPrimaryKey struct { 15 | krabhcl.Source 16 | 17 | Columns []string 18 | Include []string 19 | } 20 | 21 | var schemaPrimaryKey = &hcl.BodySchema{ 22 | Blocks: []hcl.BlockHeaderSchema{}, 23 | Attributes: []hcl.AttributeSchema{ 24 | {Name: "columns", Required: true}, 25 | {Name: "include", Required: false}, 26 | }, 27 | } 28 | 29 | // DecodeHCL parses HCL into struct. 30 | func (d *DDLPrimaryKey) DecodeHCL(ctx *hcl.EvalContext, block *hcl.Block) error { 31 | d.Source.Extract(block) 32 | 33 | d.Columns = []string{} 34 | d.Include = []string{} 35 | 36 | content, diags := block.Body.Content(schemaPrimaryKey) 37 | if diags.HasErrors() { 38 | return fmt.Errorf("failed to decode `%s` block: %s", block.Type, diags.Error()) 39 | } 40 | 41 | for _, b := range content.Blocks { 42 | switch b.Type { 43 | 44 | default: 45 | return fmt.Errorf("Unknown block `%s` for `%s` block", b.Type, block.Type) 46 | } 47 | } 48 | 49 | for k, v := range content.Attributes { 50 | switch k { 51 | case "columns": 52 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 53 | val, err := expr.SliceString() 54 | if err != nil { 55 | return err 56 | } 57 | d.Columns = append(d.Columns, val...) 58 | 59 | case "include": 60 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 61 | val, err := expr.SliceString() 62 | if err != nil { 63 | return err 64 | } 65 | d.Include = append(d.Include, val...) 66 | 67 | default: 68 | return fmt.Errorf("Unknown attribute `%s` for `%s` block", k, block.Type) 69 | } 70 | } 71 | 72 | return nil 73 | } 74 | 75 | // ToSQL converts migration definition to SQL. 76 | func (d *DDLPrimaryKey) ToSQL(w io.StringWriter) { 77 | w.WriteString("PRIMARY KEY (") 78 | cols := krabdb.QuoteIdentStrings(d.Columns) 79 | w.WriteString(strings.Join(cols, ",")) 80 | w.WriteString(")") 81 | 82 | if len(d.Include) > 0 { 83 | w.WriteString(" INCLUDE (") 84 | include := krabdb.QuoteIdentStrings(d.Include) 85 | w.WriteString(strings.Join(include, ",")) 86 | w.WriteString(")") 87 | } 88 | } 89 | 90 | // ToKCL converts migration definition to KCL. 91 | func (d *DDLPrimaryKey) ToKCL(w io.StringWriter) { 92 | w.WriteString(" primary_key {\n") 93 | w.WriteString(" columns = [") 94 | w.WriteString(strings.Join(krabdb.QuoteIdentStrings(d.Columns), "")) 95 | w.WriteString("]\n") 96 | w.WriteString(" }\n") 97 | } 98 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## [Unreleased] 3 | 4 | 5 | 6 | ## [0.9.0] - 2023-11-18 7 | ### Feat 8 | - Add action description to UI 9 | - Use valid db conn 10 | - Tables 11 | - Set default config path 12 | - Send form to action 13 | - Actions UI 14 | - Action list placeholder 15 | - Drop react ui 16 | - New UI 17 | 18 | ### Fix 19 | - Buld templ in dockerfiles 20 | - Helper spec signature 21 | - Install templ 22 | - Add version 23 | - RDS does not allow super user access, so use pg_roles 24 | - Dockerfile 25 | 26 | 27 | 28 | ## [v0.8.0] - 2023-05-05 29 | 30 | 31 | ## [v0.7.0] - 2023-02-11 32 | 33 | 34 | ## [v0.6.2] - 2023-02-04 35 | 36 | 37 | ## [v0.6.1] - 2023-02-01 38 | 39 | 40 | ## [v0.6.0] - 2023-01-28 41 | 42 | 43 | ## [v0.5.0] - 2022-10-04 44 | 45 | 46 | ## [v0.4.2] - 2021-12-08 47 | 48 | 49 | ## [v0.4.1] - 2021-12-05 50 | 51 | 52 | ## [v0.4.0] - 2021-12-03 53 | 54 | 55 | ## [v0.3.1] - 2021-10-01 56 | 57 | 58 | ## [v0.3.0] - 2021-08-22 59 | 60 | 61 | ## [v0.2.4] - 2021-07-04 62 | 63 | 64 | ## [v0.2.3] - 2021-07-03 65 | 66 | 67 | ## [v0.2.2] - 2021-07-02 68 | 69 | 70 | ## [v0.2.1] - 2021-07-02 71 | 72 | 73 | ## 0.2.0 - 2021-06-28 74 | 75 | [Unreleased]: https://github.com/ohkrab/krab/compare/0.9.0...HEAD 76 | [0.9.0]: https://github.com/ohkrab/krab/compare/v0.8.0...0.9.0 77 | [v0.8.0]: https://github.com/ohkrab/krab/compare/v0.7.0...v0.8.0 78 | [v0.7.0]: https://github.com/ohkrab/krab/compare/v0.6.2...v0.7.0 79 | [v0.6.2]: https://github.com/ohkrab/krab/compare/v0.6.1...v0.6.2 80 | [v0.6.1]: https://github.com/ohkrab/krab/compare/v0.6.0...v0.6.1 81 | [v0.6.0]: https://github.com/ohkrab/krab/compare/v0.5.0...v0.6.0 82 | [v0.5.0]: https://github.com/ohkrab/krab/compare/v0.4.2...v0.5.0 83 | [v0.4.2]: https://github.com/ohkrab/krab/compare/v0.4.1...v0.4.2 84 | [v0.4.1]: https://github.com/ohkrab/krab/compare/v0.4.0...v0.4.1 85 | [v0.4.0]: https://github.com/ohkrab/krab/compare/v0.3.1...v0.4.0 86 | [v0.3.1]: https://github.com/ohkrab/krab/compare/v0.3.0...v0.3.1 87 | [v0.3.0]: https://github.com/ohkrab/krab/compare/v0.2.4...v0.3.0 88 | [v0.2.4]: https://github.com/ohkrab/krab/compare/v0.2.3...v0.2.4 89 | [v0.2.3]: https://github.com/ohkrab/krab/compare/v0.2.2...v0.2.3 90 | [v0.2.2]: https://github.com/ohkrab/krab/compare/v0.2.1...v0.2.2 91 | [v0.2.1]: https://github.com/ohkrab/krab/compare/0.2.0...v0.2.1 92 | -------------------------------------------------------------------------------- /krabdb/advisory_lock.go: -------------------------------------------------------------------------------- 1 | package krabdb 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | // TryAdvisoryXactLock will try to obtain transaction-level exclusive lock. 11 | // It will not wait for the lock to become available. It will either obtain the lock immediately and return true, 12 | // or return false if the lock cannot be acquired immediately. 13 | // If acquired, is automatically released at the end of the current transaction and cannot be released explicitly. 14 | // 15 | // https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS 16 | // 17 | func TryAdvisoryXactLock(ctx context.Context, tx *sqlx.Tx, id int64) (bool, error) { 18 | res, err := tx.QueryContext(ctx, "SELECT pg_try_advisory_xact_lock($1)", id) 19 | if err != nil { 20 | return false, errors.Wrap(err, "Failed to obtain advisory lock") 21 | } 22 | defer res.Close() 23 | 24 | for res.Next() { 25 | var success bool 26 | err = res.Scan(&success) 27 | return success, err 28 | } 29 | 30 | return false, errors.New("Failed to obtain advisory lock") 31 | } 32 | 33 | // TryAdvisoryLock will try to obtain session-level exclusive lock. 34 | // This will either obtain the lock immediately and return true, 35 | // or return false without waiting if the lock cannot be acquired immediately. 36 | // 37 | // https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS 38 | // 39 | func TryAdvisoryLock(ctx context.Context, q QueryerContext, id int64) (bool, error) { 40 | var res []bool 41 | err := q.SelectContext(ctx, &res, "SELECT pg_try_advisory_lock($1)", id) 42 | if err != nil { 43 | return false, errors.Wrap(err, "Failed to obtain advisory lock") 44 | } 45 | 46 | for _, success := range res { 47 | return success, nil 48 | } 49 | 50 | return false, errors.New("Failed to obtain advisory lock") 51 | } 52 | 53 | // TryAdvisoryLock will release a previously-acquired exclusive session-level advisory lock. 54 | // Returns true if the lock is successfully released. If the lock was not held, 55 | // false is returned, and in addition, an SQL warning will be reported by the server. 56 | // 57 | // https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS 58 | // 59 | func AdvisoryUnlock(ctx context.Context, q QueryerContext, id int64) (bool, error) { 60 | var res []bool 61 | err := q.SelectContext(ctx, &res, "SELECT pg_advisory_unlock($1)", id) 62 | if err != nil { 63 | return false, errors.Wrap(err, "Failed to release advisory lock") 64 | } 65 | 66 | for _, success := range res { 67 | return success, nil 68 | } 69 | 70 | return false, errors.New("Failed to release advisory lock") 71 | } 72 | -------------------------------------------------------------------------------- /krab/cmd_migrate_status.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/emirpasic/gods/sets/hashset" 8 | "github.com/ohkrab/krab/krabdb" 9 | "github.com/ohkrab/krab/krabhcl" 10 | "github.com/ohkrab/krab/krabtpl" 11 | "github.com/ohkrab/krab/tpls" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | // CmdMigrateStatus returns migration status information. 16 | type CmdMigrateStatus struct { 17 | Set *MigrationSet 18 | Connection krabdb.Connection 19 | } 20 | 21 | // ResponseMigrateStatus json 22 | type ResponseMigrateStatus struct { 23 | Name string `json:"name"` 24 | Version string `json:"version"` 25 | Pending bool `json:"pending"` 26 | } 27 | 28 | func (c *CmdMigrateStatus) Addr() krabhcl.Addr { return c.Set.Addr() } 29 | 30 | func (c *CmdMigrateStatus) Name() []string { 31 | return append([]string{"migrate", "status"}, c.Set.Addr().Labels...) 32 | } 33 | 34 | func (c *CmdMigrateStatus) HttpMethod() string { return http.MethodGet } 35 | 36 | func (c *CmdMigrateStatus) Do(ctx context.Context, o CmdOpts) (any, error) { 37 | err := c.Set.Arguments.Validate(o.NamedInputs) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | var result []ResponseMigrateStatus 43 | err = c.Connection.Get(func(db krabdb.DB) error { 44 | resp, err := c.run(ctx, db, o.NamedInputs) 45 | result = resp 46 | return err 47 | }) 48 | 49 | return result, err 50 | } 51 | 52 | func (c *CmdMigrateStatus) run(ctx context.Context, db krabdb.DB, inputs NamedInputs) ([]ResponseMigrateStatus, error) { 53 | result := []ResponseMigrateStatus{} 54 | 55 | tpl := tpls.New(inputs, krabtpl.Functions()) 56 | versions := NewSchemaMigrationTable(tpl.Render(c.Set.Schema)) 57 | 58 | hooksRunner := HookRunner{} 59 | err := hooksRunner.SetSearchPath(ctx, db, tpl.Render(c.Set.Schema)) 60 | if err != nil { 61 | return nil, errors.Wrap(err, "Failed to run SetSearchPath hook") 62 | } 63 | migrationRefsInDb, err := versions.SelectAll(ctx, db) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | appliedMigrations := hashset.New() 69 | 70 | for _, migration := range migrationRefsInDb { 71 | appliedMigrations.Add(migration.Version) 72 | } 73 | 74 | for _, migration := range c.Set.Migrations { 75 | pending := !appliedMigrations.Contains(migration.Version) 76 | 77 | if pending { 78 | result = append(result, ResponseMigrateStatus{ 79 | Name: migration.RefName, 80 | Version: migration.Version, 81 | Pending: true, 82 | }) 83 | } else { 84 | result = append(result, ResponseMigrateStatus{ 85 | Name: migration.RefName, 86 | Version: migration.Version, 87 | Pending: false, 88 | }) 89 | } 90 | 91 | } 92 | 93 | return result, nil 94 | } 95 | -------------------------------------------------------------------------------- /res/crab-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 36 | 38 | 40 | 41 | 43 | image/svg+xml 44 | 46 | 47 | 48 | 49 | 54 | 55 | -------------------------------------------------------------------------------- /views/database_list.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "strconv" 4 | import "fmt" 5 | import "github.com/ohkrab/krab/web/dto" 6 | 7 | templ DatabaseList(databases []*dto.DatabaseListItem) { 8 |

Databases

9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | for _, db := range databases { 25 | @DatabaseListItem(db) 26 | } 27 | 28 |
DatabaseSizeTablespaceConnection LimitOwnerEncodingCollationCharacter Type
29 |
30 | } 31 | 32 | templ DatabaseListItem(db *dto.DatabaseListItem) { 33 | 34 | 35 | if db.CanConnect { 36 | 38 | { db.Name } 39 | 40 | } else { 41 | { db.Name } 42 | } 43 | if db.IsTemplate { 44 | 46 | Template 47 | 48 | } 49 | 50 | 51 |
52 |
53 | 54 |
55 |
56 |
{ db.Size }
57 |
58 | 59 | { db.TablespaceName } 60 | 61 | if db.ConnectionLimit == -1 { 62 | No limits 63 | } else { 64 | { strconv.Itoa(int(db.ConnectionLimit)) } 65 | } 66 | 67 | { db.OwnerName } 68 | { db.Encoding } 69 | { db.Collation } 70 | { db.CharacterType } 71 | 72 | } -------------------------------------------------------------------------------- /krabcli/app.go: -------------------------------------------------------------------------------- 1 | package krabcli 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | mcli "github.com/mitchellh/cli" 8 | "github.com/ohkrab/krab/cli" 9 | "github.com/ohkrab/krab/krab" 10 | "github.com/ohkrab/krab/krabdb" 11 | "github.com/ohkrab/krab/web" 12 | ) 13 | 14 | type Command mcli.Command 15 | 16 | type App struct { 17 | Ui cli.UI 18 | CLI *mcli.CLI 19 | Config *krab.Config 20 | Registry *krab.CmdRegistry 21 | connection krabdb.Connection 22 | } 23 | 24 | func New( 25 | ui cli.UI, 26 | args []string, 27 | config *krab.Config, 28 | registry *krab.CmdRegistry, 29 | connection krabdb.Connection, 30 | srv *web.Server, 31 | ) *App { 32 | c := mcli.NewCLI(krab.InfoName, krab.InfoVersion) 33 | c.Args = args 34 | c.Commands = make(map[string]mcli.CommandFactory, 0) 35 | 36 | app := &App{ 37 | Ui: ui, 38 | CLI: c, 39 | Config: config, 40 | Registry: registry, 41 | connection: connection, 42 | } 43 | app.RegisterAll(srv) 44 | 45 | return app 46 | } 47 | 48 | func (a *App) RegisterAll(srv *web.Server) { 49 | a.RegisterCmd("server", func() Command { 50 | return srv 51 | }) 52 | 53 | for _, cmd := range a.Registry.Commands { 54 | name := strings.Join(cmd.Name(), " ") 55 | 56 | switch c := cmd.(type) { 57 | case *krab.CmdVersion: 58 | a.RegisterCmd(name, func() Command { 59 | return &krab.ActionVersion{Ui: a.Ui, Cmd: c} 60 | }) 61 | 62 | case *krab.CmdMigrateDown: 63 | a.RegisterCmd(name, func() Command { 64 | return &krab.ActionMigrateDown{Ui: a.Ui, Cmd: c} 65 | }) 66 | 67 | case *krab.CmdMigrateUp: 68 | a.RegisterCmd(name, func() Command { 69 | return &krab.ActionMigrateUp{Ui: a.Ui, Cmd: c} 70 | }) 71 | 72 | case *krab.CmdMigrateStatus: 73 | a.RegisterCmd(name, func() Command { 74 | return &krab.ActionMigrateStatus{Ui: a.Ui, Cmd: c} 75 | }) 76 | 77 | case *krab.CmdAction: 78 | a.RegisterCmd(name, func() Command { 79 | return &krab.ActionCustom{Ui: a.Ui, Cmd: c} 80 | }) 81 | 82 | case *krab.CmdTestRun: 83 | a.RegisterCmd(name, func() Command { 84 | return &krab.ActionTestRun{Ui: a.Ui, Cmd: c} 85 | }) 86 | 87 | case *krab.CmdGenMigration: 88 | a.RegisterCmd(name, func() Command { 89 | return &krab.ActionGenMigration{Ui: a.Ui, Cmd: c} 90 | }) 91 | 92 | default: 93 | panic(fmt.Sprintf("Not implemented: failed to register CLI action for command %T", c)) 94 | } 95 | } 96 | } 97 | 98 | func (a *App) Run() (int, error) { 99 | return a.CLI.Run() 100 | } 101 | 102 | func (a *App) RegisterCmd(names string, cmd func() Command) { 103 | a.CLI.Commands[names] = func() (mcli.Command, error) { 104 | return cmd(), nil 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /krab/type_ddl_references.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/hashicorp/hcl/v2" 9 | "github.com/ohkrab/krab/krabdb" 10 | "github.com/ohkrab/krab/krabhcl" 11 | ) 12 | 13 | // DDLReferences DSL for ForeignKey. 14 | type DDLReferences struct { 15 | krabhcl.Source 16 | 17 | Table string 18 | Columns []string 19 | OnDelete string 20 | OnUpdate string 21 | } 22 | 23 | var schemaForeignKeyReferences = &hcl.BodySchema{ 24 | Blocks: []hcl.BlockHeaderSchema{}, 25 | Attributes: []hcl.AttributeSchema{ 26 | {Name: "columns", Required: true}, 27 | {Name: "on_delete", Required: false}, 28 | {Name: "on_update", Required: false}, 29 | }, 30 | } 31 | 32 | // DecodeHCL parses HCL into struct. 33 | func (d *DDLReferences) DecodeHCL(ctx *hcl.EvalContext, block *hcl.Block) error { 34 | d.Source.Extract(block) 35 | 36 | d.Columns = []string{} 37 | d.Table = block.Labels[0] 38 | 39 | content, diags := block.Body.Content(schemaForeignKeyReferences) 40 | if diags.HasErrors() { 41 | return fmt.Errorf("failed to decode `%s` block: %s", block.Type, diags.Error()) 42 | } 43 | 44 | for _, b := range content.Blocks { 45 | switch b.Type { 46 | 47 | default: 48 | return fmt.Errorf("Unknown block `%s` for `%s` block", b.Type, block.Type) 49 | } 50 | } 51 | 52 | for k, v := range content.Attributes { 53 | switch k { 54 | case "columns": 55 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 56 | val, err := expr.SliceString() 57 | if err != nil { 58 | return err 59 | } 60 | d.Columns = append(d.Columns, val...) 61 | 62 | case "on_delete": 63 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 64 | val, err := expr.String() 65 | if err != nil { 66 | return err 67 | } 68 | d.OnDelete = val 69 | 70 | case "on_update": 71 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 72 | val, err := expr.String() 73 | if err != nil { 74 | return err 75 | } 76 | d.OnUpdate = val 77 | 78 | default: 79 | return fmt.Errorf("Unknown attribute `%s` for `%s` block", k, block.Type) 80 | } 81 | } 82 | 83 | return nil 84 | } 85 | 86 | // ToSQL converts migration definition to SQL. 87 | func (d *DDLReferences) ToSQL(w io.StringWriter) { 88 | w.WriteString("REFERENCES ") 89 | w.WriteString(krabdb.QuoteIdent(d.Table)) 90 | w.WriteString("(") 91 | cols := krabdb.QuoteIdentStrings(d.Columns) 92 | w.WriteString(strings.Join(cols, ",")) 93 | w.WriteString(")") 94 | 95 | if d.OnDelete != "" { 96 | w.WriteString(" ON DELETE ") 97 | w.WriteString(d.OnDelete) 98 | } 99 | 100 | if d.OnUpdate != "" { 101 | w.WriteString(" ON UPDATE ") 102 | w.WriteString(d.OnUpdate) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /krab/cmd_test_run.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/ohkrab/krab/krabdb" 9 | "github.com/ohkrab/krab/krabhcl" 10 | "github.com/wzshiming/ctc" 11 | ) 12 | 13 | // CmdTestRun returns migration status information. 14 | type CmdTestRun struct { 15 | Connection krabdb.Connection 16 | Suite *TestSuite 17 | } 18 | 19 | // ResponseTestRun json 20 | type ResponseTestRun struct { 21 | } 22 | 23 | func (c *CmdTestRun) Addr() krabhcl.Addr { return c.Suite.Addr() } 24 | 25 | func (c *CmdTestRun) Name() []string { return []string{"test"} } 26 | 27 | func (c *CmdTestRun) HttpMethod() string { return http.MethodPost } 28 | 29 | func (c *CmdTestRun) Do(ctx context.Context, o CmdOpts) (any, error) { 30 | var result ResponseTestRun 31 | 32 | // for _, do := range c.Suite.Before.Dos { 33 | // for _, migrate := range do.Migrate { 34 | // addr, err := krabhcl.Expression{Expr: migrate.SetExpr}.Addr() 35 | // if err != nil { 36 | // return nil, fmt.Errorf("Failed to parse MigrationSet reference: %w", err) 37 | // } 38 | // 39 | // for _, cmd := range c.Registry.Commands { 40 | // if addr.Equal(cmd.Addr()) { 41 | // if cmd.Name()[1] == migrate.Type { 42 | // inputs := InputsFromCtyInputs(do.CtyInputs) 43 | // migrateInputs := InputsFromCtyInputs(migrate.CtyInputs) 44 | // inputs.Merge(migrateInputs) 45 | // result, err := cmd.Do(ctx, CmdOpts{NamedInputs: inputs}) 46 | // if err != nil { 47 | // return nil, fmt.Errorf("Failed to execute before hook: %w", err) 48 | // } 49 | // respUp, ok := result.([]ResponseMigrateUp) 50 | // if ok { 51 | // for _, migration := range respUp { 52 | // fmt.Println(ctc.ForegroundYellow, "UP ", migration.Success, migration.Version, migration.Name, ctc.Reset) 53 | // } 54 | // } 55 | // respDown, ok := result.([]ResponseMigrateDown) 56 | // if ok { 57 | // for _, migration := range respDown { 58 | // fmt.Println(ctc.ForegroundYellow, "DOWN", migration.Success, migration.Version, migration.Name, ctc.Reset) 59 | // } 60 | // } 61 | // } 62 | // } 63 | // } 64 | // } 65 | // } 66 | 67 | err := c.Connection.Get(func(db krabdb.DB) error { 68 | resp, err := c.run(ctx, db, o.NamedInputs) 69 | result = resp 70 | return err 71 | }) 72 | 73 | return result, err 74 | } 75 | 76 | func (c *CmdTestRun) run(ctx context.Context, db krabdb.DB, inputs NamedInputs) (ResponseTestRun, error) { 77 | result := ResponseTestRun{} 78 | 79 | for _, testCase := range c.Suite.Tests { 80 | fmt.Printf("%s%s%s\n", ctc.ForegroundBlue, testCase.Name, ctc.Reset) 81 | for _, it := range testCase.Xits { 82 | fmt.Printf(" %sSKIP %s%s\n", ctc.ForegroundYellow, it.Name, ctc.Reset) 83 | } 84 | for _, it := range testCase.Its { 85 | fmt.Printf(" %s%s%s\n", ctc.ForegroundRed, it.Name, ctc.Reset) 86 | } 87 | } 88 | 89 | // _, err := db.ExecContext(ctx, sql) 90 | 91 | return result, nil 92 | } 93 | -------------------------------------------------------------------------------- /krab/action.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/hashicorp/hcl/v2" 8 | "github.com/ohkrab/krab/krabhcl" 9 | ) 10 | 11 | // Action represents custom action to execute. 12 | type Action struct { 13 | krabhcl.Source 14 | 15 | Namespace string 16 | RefName string 17 | 18 | Arguments *Arguments 19 | 20 | Description string 21 | SQL string 22 | Transaction bool // wrap operation in transaction 23 | } 24 | 25 | func (a *Action) Addr() krabhcl.Addr { 26 | return krabhcl.Addr{Keyword: "action", Labels: []string{a.Namespace, a.RefName}} 27 | } 28 | 29 | var schemaAction = &hcl.BodySchema{ 30 | Blocks: []hcl.BlockHeaderSchema{ 31 | { 32 | Type: "arguments", 33 | LabelNames: []string{}, 34 | }, 35 | }, 36 | Attributes: []hcl.AttributeSchema{ 37 | { 38 | Name: "sql", 39 | Required: true, 40 | }, 41 | { 42 | Name: "transaction", 43 | Required: false, 44 | }, 45 | { 46 | Name: "description", 47 | Required: true, 48 | }, 49 | }, 50 | } 51 | 52 | // DecodeHCL parses HCL into struct. 53 | func (a *Action) DecodeHCL(ctx *hcl.EvalContext, block *hcl.Block) error { 54 | a.Source.Extract(block) 55 | 56 | a.Namespace = block.Labels[0] 57 | a.RefName = block.Labels[1] 58 | a.Arguments = &Arguments{} 59 | a.Transaction = true 60 | 61 | content, diags := block.Body.Content(schemaAction) 62 | if diags.HasErrors() { 63 | return fmt.Errorf("failed to decode `%s` block: %s", block.Type, diags.Error()) 64 | } 65 | 66 | for _, b := range content.Blocks { 67 | switch b.Type { 68 | case "arguments": 69 | err := a.Arguments.DecodeHCL(ctx, b) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | default: 75 | return fmt.Errorf("Unknown block `%s` for `%s` block", b.Type, block.Type) 76 | } 77 | } 78 | 79 | for k, v := range content.Attributes { 80 | switch k { 81 | case "sql": 82 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 83 | val, err := expr.String() 84 | if err != nil { 85 | return err 86 | } 87 | a.SQL = val 88 | 89 | case "description": 90 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 91 | val, err := expr.String() 92 | if err != nil { 93 | return err 94 | } 95 | a.Description = val 96 | 97 | case "transaction": 98 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 99 | val, err := expr.Bool() 100 | if err != nil { 101 | return err 102 | } 103 | a.Transaction = val 104 | 105 | default: 106 | return fmt.Errorf("Unknown attribute `%s` for `%s` block", k, block.Type) 107 | } 108 | } 109 | 110 | return nil 111 | } 112 | 113 | func (a *Action) Validate() error { 114 | return ErrorCoalesce( 115 | ValidateRefName(a.Namespace), 116 | ValidateRefName(a.RefName), 117 | ValidateStringNonEmpty("description", a.Description), 118 | ) 119 | } 120 | 121 | func (m *Action) ToSQL(w io.StringWriter) { 122 | w.WriteString(m.SQL) 123 | } 124 | -------------------------------------------------------------------------------- /krab/type_migration_set.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2" 7 | "github.com/ohkrab/krab/krabhcl" 8 | ) 9 | 10 | // MigrationSet represents collection of migrations. 11 | type MigrationSet struct { 12 | krabhcl.Source 13 | 14 | RefName string 15 | Schema string 16 | // SchemaMigrationTableName `hcl:"schema_migrations_table,optional"` 17 | 18 | Arguments *Arguments 19 | Hooks *Hooks 20 | 21 | MigrationAddrs []*krabhcl.Addr 22 | Migrations []*Migration // populated from refs in expression 23 | } 24 | 25 | var schemaMigrationSet = &hcl.BodySchema{ 26 | Blocks: []hcl.BlockHeaderSchema{ 27 | { 28 | Type: "arguments", 29 | LabelNames: []string{}, 30 | }, 31 | }, 32 | Attributes: []hcl.AttributeSchema{ 33 | { 34 | Name: "migrations", 35 | Required: true, 36 | }, 37 | { 38 | Name: "schema", 39 | Required: false, 40 | }, 41 | }, 42 | } 43 | 44 | func (ms *MigrationSet) Addr() krabhcl.Addr { 45 | return krabhcl.Addr{Keyword: "migration_set", Labels: []string{ms.RefName}} 46 | } 47 | 48 | // DecodeHCL parses HCL into struct. 49 | func (ms *MigrationSet) DecodeHCL(ctx *hcl.EvalContext, block *hcl.Block) error { 50 | ms.Source.Extract(block) 51 | 52 | ms.RefName = block.Labels[0] 53 | ms.Schema = "public" 54 | ms.Arguments = &Arguments{} 55 | ms.Hooks = &Hooks{} 56 | ms.Migrations = []*Migration{} 57 | ms.MigrationAddrs = []*krabhcl.Addr{} 58 | 59 | content, diags := block.Body.Content(schemaMigrationSet) 60 | if diags.HasErrors() { 61 | return fmt.Errorf("failed to decode `%s` block: %s", block.Type, diags.Error()) 62 | } 63 | 64 | for _, b := range content.Blocks { 65 | switch b.Type { 66 | case "arguments": 67 | err := ms.Arguments.DecodeHCL(ctx, b) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | default: 73 | return fmt.Errorf("Unknown block `%s` for `%s` block", b.Type, block.Type) 74 | } 75 | } 76 | 77 | for k, v := range content.Attributes { 78 | switch k { 79 | case "migrations": 80 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 81 | val, err := expr.SliceAddr() 82 | if err != nil { 83 | return err 84 | } 85 | ms.MigrationAddrs = append(ms.MigrationAddrs, val...) 86 | 87 | case "schema": 88 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 89 | val, err := expr.String() 90 | if err != nil { 91 | return err 92 | } 93 | ms.Schema = val 94 | 95 | default: 96 | return fmt.Errorf("Unknown attribute `%s` for `migration_set` block", k) 97 | } 98 | } 99 | 100 | return nil 101 | } 102 | 103 | func (ms *MigrationSet) Validate() error { 104 | return ErrorCoalesce( 105 | ValidateRefName(ms.RefName), 106 | ) 107 | } 108 | 109 | // FindMigrationByVersion looks up for the migration in current set. 110 | func (ms *MigrationSet) FindMigrationByVersion(version string) *Migration { 111 | for _, m := range ms.Migrations { 112 | if m.Version == version { 113 | return m 114 | } 115 | } 116 | 117 | return nil 118 | } 119 | -------------------------------------------------------------------------------- /spec/testdb_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | _ "github.com/jackc/pgx/v5" 8 | _ "github.com/jackc/pgx/v5/stdlib" 9 | "github.com/jmoiron/sqlx" 10 | "github.com/ohkrab/krab/krabdb" 11 | "github.com/ohkrab/krab/krabenv" 12 | ) 13 | 14 | type mockDBConnection struct { 15 | recorder []string 16 | assertedSQLIndex int 17 | } 18 | 19 | func (m *mockDBConnection) Get(f func(db krabdb.DB) error) error { 20 | db, err := krabdb.Connect(krabenv.DatabaseURL()) 21 | if err != nil { 22 | return err 23 | } 24 | defer db.Close() 25 | 26 | return f(&testDB{recorder: &m.recorder, db: db}) 27 | } 28 | 29 | type testDB struct { 30 | db *sqlx.DB 31 | recorder *[]string 32 | } 33 | 34 | func (d *testDB) GetDatabase() *sqlx.DB { 35 | return d.db 36 | } 37 | 38 | func (d *testDB) SelectContext(ctx context.Context, dest any, query string, args ...any) error { 39 | *d.recorder = append(*d.recorder, query) 40 | return sqlx.SelectContext(ctx, d.GetDatabase(), dest, query, args...) 41 | } 42 | 43 | func (d *testDB) QueryContext(ctx context.Context, query string, args ...any) (*sqlx.Rows, error) { 44 | *d.recorder = append(*d.recorder, query) 45 | return d.GetDatabase().QueryxContext(ctx, query, args...) 46 | } 47 | 48 | func (d *testDB) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { 49 | *d.recorder = append(*d.recorder, query) 50 | return d.GetDatabase().ExecContext(ctx, query, args...) 51 | } 52 | 53 | func (d *testDB) NewTx(ctx context.Context, createTransaction bool) (krabdb.TransactionExecerContext, error) { 54 | if createTransaction { 55 | tx, err := d.GetDatabase().BeginTxx(ctx, nil) 56 | if err != nil { 57 | return nil, err 58 | } 59 | return &mockTransaction{tx: tx, recorder: d.recorder}, nil 60 | } 61 | 62 | return &mockNullTransaction{db: d, recorder: d.recorder}, nil 63 | } 64 | 65 | func sqlxRowsMapScan(rows *sqlx.Rows) []map[string]any { 66 | res := []map[string]any{} 67 | for rows.Next() { 68 | row := map[string]any{} 69 | rows.MapScan(row) 70 | res = append(res, row) 71 | } 72 | 73 | return res 74 | } 75 | 76 | type mockTransaction struct { 77 | tx *sqlx.Tx 78 | recorder *[]string 79 | } 80 | 81 | func (t *mockTransaction) Rollback() error { 82 | return t.tx.Rollback() 83 | } 84 | 85 | func (t *mockTransaction) Commit() error { 86 | return t.tx.Commit() 87 | } 88 | 89 | func (t *mockTransaction) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { 90 | *t.recorder = append(*t.recorder, query) 91 | return t.tx.ExecContext(ctx, query, args...) 92 | } 93 | 94 | type mockNullTransaction struct { 95 | db krabdb.DB 96 | recorder *[]string 97 | } 98 | 99 | func (t *mockNullTransaction) Rollback() error { 100 | return nil 101 | } 102 | 103 | func (t *mockNullTransaction) Commit() error { 104 | return nil 105 | } 106 | 107 | func (t *mockNullTransaction) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { 108 | *t.recorder = append(*t.recorder, query) 109 | return t.db.ExecContext(ctx, query, args...) 110 | } 111 | -------------------------------------------------------------------------------- /krab/config.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // Config represents all configuration loaded from directory. 8 | type Config struct { 9 | MigrationSets map[string]*MigrationSet 10 | Migrations map[string]*Migration 11 | Actions map[string]*Action 12 | TestSuite *TestSuite 13 | TestExamples map[string]*TestExample 14 | } 15 | 16 | // NewConfig returns new configuration that was read from Parser. 17 | // Transient attributes are updated with parsed data. 18 | func NewConfig(files []*File) (*Config, error) { 19 | c := &Config{ 20 | MigrationSets: map[string]*MigrationSet{}, 21 | Migrations: map[string]*Migration{}, 22 | Actions: map[string]*Action{}, 23 | TestSuite: &TestSuite{}, 24 | TestExamples: map[string]*TestExample{}, 25 | } 26 | 27 | // append files 28 | for _, f := range files { 29 | if err := c.appendFile(f); err != nil { 30 | return nil, err 31 | } 32 | } 33 | 34 | // connect by refs 35 | for _, set := range c.MigrationSets { 36 | for _, addr := range set.MigrationAddrs { 37 | migration, found := c.Migrations[addr.OnlyRefNames()] 38 | if !found { 39 | return nil, fmt.Errorf("Migration Set references '%s' migration that does not exist", addr.OnlyRefNames()) 40 | } 41 | set.Migrations = append(set.Migrations, migration) 42 | } 43 | } 44 | 45 | // validate 46 | for _, validatable := range c.MigrationSets { 47 | if err := validatable.Validate(); err != nil { 48 | return nil, err 49 | } 50 | } 51 | 52 | for _, validatable := range c.Migrations { 53 | if err := validatable.Validate(); err != nil { 54 | return nil, err 55 | } 56 | } 57 | 58 | for _, validatable := range c.Actions { 59 | if err := validatable.Validate(); err != nil { 60 | return nil, err 61 | } 62 | } 63 | 64 | for _, validatable := range c.TestExamples { 65 | if err := validatable.Validate(); err != nil { 66 | return nil, err 67 | } 68 | } 69 | 70 | return c, nil 71 | } 72 | 73 | func (c *Config) appendFile(file *File) error { 74 | for _, m := range file.Migrations { 75 | if _, found := c.Migrations[m.RefName]; found { 76 | return fmt.Errorf("Migration with the name '%s' already exists", m.RefName) 77 | } 78 | 79 | c.Migrations[m.RefName] = m 80 | } 81 | 82 | for _, s := range file.MigrationSets { 83 | if _, found := c.MigrationSets[s.RefName]; found { 84 | return fmt.Errorf("Migration Set with the name '%s' already exists", s.RefName) 85 | } 86 | 87 | c.MigrationSets[s.RefName] = s 88 | } 89 | 90 | for _, a := range file.Actions { 91 | if _, found := c.Actions[a.Addr().OnlyRefNames()]; found { 92 | return fmt.Errorf("Action with the name '%s' already exists", a.Addr().OnlyRefNames()) 93 | } 94 | 95 | c.Actions[a.Addr().OnlyRefNames()] = a 96 | } 97 | 98 | c.TestSuite = file.TestSuite 99 | c.TestSuite.Tests = []*TestExample{} 100 | 101 | for _, t := range file.TestExamples { 102 | if _, found := c.TestExamples[t.Addr().OnlyRefNames()]; found { 103 | return fmt.Errorf("Test with the name '%s' already exists", t.Addr().OnlyRefNames()) 104 | } 105 | c.TestSuite.Tests = append(c.TestSuite.Tests, t) 106 | } 107 | 108 | return nil 109 | } 110 | -------------------------------------------------------------------------------- /krabhcl/expression.go: -------------------------------------------------------------------------------- 1 | package krabhcl 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2" 7 | "github.com/zclconf/go-cty/cty/gocty" 8 | ) 9 | 10 | type Expression struct { 11 | Expr hcl.Expression 12 | EvalContext *hcl.EvalContext 13 | } 14 | 15 | func (e Expression) Addr() (Addr, error) { 16 | traversals := e.Expr.Variables() 17 | if len(traversals) != 1 { 18 | return Addr{}, fmt.Errorf("Failed to extract single addr from HCL expression") 19 | } 20 | 21 | t := traversals[0] 22 | parsedAddr, err := ParseTraversalToAddr(t) 23 | if err != nil { 24 | return Addr{}, err 25 | } 26 | return parsedAddr, nil 27 | } 28 | 29 | func (e Expression) Bool() (bool, error) { 30 | val, diags := e.Expr.Value(e.EvalContext) 31 | if diags.HasErrors() { 32 | return false, fmt.Errorf("%v %w", e.Expr.Range(), diags.Errs()[0]) 33 | } 34 | var boolean bool 35 | if err := gocty.FromCtyValue(val, &boolean); err != nil { 36 | return false, fmt.Errorf("%v %w", e.Expr.Range(), err) 37 | } 38 | return boolean, nil 39 | } 40 | 41 | func (e Expression) Int64() (int64, error) { 42 | val, diags := e.Expr.Value(e.EvalContext) 43 | if diags.HasErrors() { 44 | return 0, fmt.Errorf("%v %w", e.Expr.Range(), diags.Errs()[0]) 45 | } 46 | var number int64 47 | if err := gocty.FromCtyValue(val, &number); err != nil { 48 | return 0, fmt.Errorf("%v %w", e.Expr.Range(), err) 49 | } 50 | return number, nil 51 | } 52 | 53 | func (e Expression) AsFloat64() (float64, error) { 54 | val, diags := e.Expr.Value(e.EvalContext) 55 | if diags.HasErrors() { 56 | return 0, fmt.Errorf("%v %w", e.Expr.Range(), diags.Errs()[0]) 57 | } 58 | var number float64 59 | if err := gocty.FromCtyValue(val, &number); err != nil { 60 | return 0, fmt.Errorf("%v %w", e.Expr.Range(), err) 61 | } 62 | return number, nil 63 | } 64 | 65 | func (e Expression) String() (string, error) { 66 | val, diags := e.Expr.Value(e.EvalContext) 67 | if diags.HasErrors() { 68 | return "", fmt.Errorf("%v %w", e.Expr.Range(), diags.Errs()[0]) 69 | } 70 | var str string 71 | err := gocty.FromCtyValue(val, &str) 72 | if err != nil { 73 | return "", fmt.Errorf("%v %w", e.Expr.Range(), err) 74 | } 75 | return str, nil 76 | } 77 | 78 | func (e Expression) SliceAddr() ([]*Addr, error) { 79 | addrs := []*Addr{} 80 | traversals := e.Expr.Variables() 81 | for _, t := range traversals { 82 | addr, err := ParseTraversalToAddr(t) 83 | if err != nil { 84 | return nil, err 85 | } 86 | addrs = append(addrs, &addr) 87 | } 88 | return addrs, nil 89 | } 90 | 91 | func (e Expression) SliceString() ([]string, error) { 92 | val, diags := e.Expr.Value(e.EvalContext) 93 | if diags.HasErrors() { 94 | return nil, fmt.Errorf("%v %w", e.Expr.Range(), diags.Errs()[0]) 95 | } 96 | if val.Type().IsTupleType() && val.IsWhollyKnown() { 97 | vals := val.AsValueSlice() 98 | ss := []string{} 99 | for _, v := range vals { 100 | var str string 101 | if err := gocty.FromCtyValue(v, &str); err == nil { 102 | ss = append(ss, str) 103 | } else { 104 | return nil, fmt.Errorf("%v %w", e.Expr.Range(), err) 105 | } 106 | } 107 | return ss, nil 108 | } 109 | return nil, fmt.Errorf("%v, Inwalid types in a list, expected strings", e.Expr.Range()) 110 | } 111 | -------------------------------------------------------------------------------- /krab/parser.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/hashicorp/hcl/v2" 9 | "github.com/hashicorp/hcl/v2/hclsyntax" 10 | "github.com/hashicorp/hcl2/hclparse" 11 | "github.com/ohkrab/krab/krabenv" 12 | "github.com/ohkrab/krab/krabfn" 13 | "github.com/spf13/afero" 14 | ) 15 | 16 | // Parser represents HCL simple parser. 17 | type Parser struct { 18 | p *hclparse.Parser 19 | FS afero.Afero 20 | } 21 | 22 | // NewParser initializes HCL parser and default file system. 23 | func NewParser() *Parser { 24 | return &Parser{ 25 | p: hclparse.NewParser(), 26 | FS: afero.Afero{Fs: afero.OsFs{}}, 27 | } 28 | } 29 | 30 | // LoadConfigDir parses files in a dir and returns Config. 31 | func (p *Parser) LoadConfigDir(path string) (*Config, error) { 32 | paths, err := p.dirFiles(path) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | files, err := p.loadConfigFiles(paths...) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | cfg, err := NewConfig(files) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | return cfg, nil 48 | } 49 | 50 | func (p *Parser) loadConfigFiles(paths ...string) ([]*File, error) { 51 | var files []*File 52 | evalContext := krabfn.EvalContext(p.FS) 53 | 54 | for _, path := range paths { 55 | f, err := p.loadConfigFile(path, evalContext) 56 | if err != nil { 57 | return nil, err 58 | } 59 | files = append(files, f) 60 | } 61 | 62 | return files, nil 63 | } 64 | 65 | func (p *Parser) loadConfigFile(path string, evalContext *hcl.EvalContext) (*File, error) { 66 | src, err := p.FS.ReadFile(path) 67 | if err != nil { 68 | return nil, fmt.Errorf("[%w] Failed to load file %s", err, path) 69 | } 70 | 71 | hclFile, diags := hclsyntax.ParseConfig(src, path, hcl.Pos{Line: 1, Column: 1, Byte: 0}) 72 | if diags.HasErrors() { 73 | return nil, fmt.Errorf("[%s] Failed to decode file %s", err.Error(), path) 74 | } 75 | file := &File{File: hclFile} 76 | if err := file.Decode(evalContext); err != nil { 77 | return nil, fmt.Errorf("[%w] Failed to decode file %s", err, path) 78 | } 79 | 80 | return file, nil 81 | } 82 | 83 | func (p *Parser) dirFiles(dir string) ([]string, error) { 84 | paths := []string{} 85 | 86 | infos, err := p.FS.ReadDir(dir) 87 | if err != nil { 88 | return nil, fmt.Errorf("Directory %s does not exist or cannot be read", dir) 89 | } 90 | 91 | for _, info := range infos { 92 | if info.IsDir() { 93 | fullPath := filepath.Join(dir, info.Name()) 94 | nestedPaths, err := p.dirFiles(fullPath) 95 | if err != nil { 96 | return nil, err 97 | } 98 | paths = append(paths, nestedPaths...) 99 | continue 100 | } 101 | 102 | name := info.Name() 103 | ext := fileExt(name) 104 | if ext == "" || isIgnoredFile(name) { 105 | continue 106 | } 107 | 108 | fullPath := filepath.Join(dir, name) 109 | paths = append(paths, fullPath) 110 | } 111 | 112 | return paths, nil 113 | } 114 | 115 | func fileExt(path string) string { 116 | if strings.HasSuffix(path, krabenv.Ext()) { 117 | return krabenv.Ext() 118 | } 119 | 120 | return "" // unrecognized 121 | } 122 | 123 | func isIgnoredFile(name string) bool { 124 | return strings.HasPrefix(name, ".") || // dotfiles 125 | strings.HasSuffix(name, "~") || // vim/backups 126 | strings.HasPrefix(name, "#") && strings.HasSuffix(name, "#") // emacs 127 | } 128 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | permissions: 4 | contents: write 5 | packages: write 6 | 7 | on: [push] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | services: 13 | postgres: 14 | image: postgres:12.3-alpine 15 | ports: 16 | - 5432:5432 17 | env: 18 | POSTGRES_PASSWORD: secret 19 | POSTGRES_USER: krab 20 | POSTGRES_DB: krab 21 | options: >- 22 | --health-cmd pg_isready 23 | --health-interval 10s 24 | --health-timeout 5s 25 | --health-retries 5 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - name: Setup go 31 | uses: actions/setup-go@v4 32 | with: 33 | go-version: 1.21.4 34 | 35 | - uses: actions/cache@v3 36 | if: ${{ !env.ACT }} 37 | with: 38 | path: | 39 | ~/.cache/go-build 40 | ~/go/pkg/mod 41 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 42 | restore-keys: | 43 | ${{ runner.os }}-go- 44 | 45 | - name: Run tests 46 | env: 47 | DATABASE_URL: "postgres://krab:secret@localhost:5432/krab?sslmode=disable&prefer_simple_protocol=true" 48 | run: | 49 | mkdir -p bin/ 50 | make install 51 | make gen 52 | go test -v ./... 53 | 54 | release: 55 | runs-on: ubuntu-latest 56 | needs: test 57 | if: ${{ startsWith(github.ref, 'refs/tags/') }} 58 | steps: 59 | - 60 | name: Checkout 61 | uses: actions/checkout@v4 62 | - 63 | name: Set up QEMU 64 | uses: docker/setup-qemu-action@v2 65 | - 66 | name: Set up Docker Buildx 67 | uses: docker/setup-buildx-action@v2 68 | 69 | - name: Login to Docker Hub 70 | uses: docker/login-action@v2 71 | with: 72 | username: ${{ secrets.DOCKERHUB_USERNAME }} 73 | password: ${{ secrets.DOCKERHUB_TOKEN }} 74 | 75 | - name: Login to Github Packages 76 | uses: docker/login-action@v2 77 | with: 78 | registry: ghcr.io 79 | username: ${{ github.actor }} 80 | password: ${{ secrets.GITHUB_TOKEN }} 81 | 82 | - name: Set tag 83 | run: echo "IMAGE_TAG=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 84 | 85 | - name: Build image and push to GitHub Container Registry 86 | uses: docker/build-push-action@v4 87 | with: 88 | # relative path to the place where source code with Dockerfile is located 89 | context: . 90 | push: true 91 | tags: | 92 | ghcr.io/ohkrab/krab:${{ env.IMAGE_TAG }} 93 | qbart/krab:${{ env.IMAGE_TAG }} 94 | build-args: | 95 | BUILD_VERSION=${{ env.IMAGE_TAG }} 96 | BUILD_COMMIT=${{ github.sha }} 97 | BUILD_DATE=${{ github.event.repository.updated_at }} 98 | - 99 | name: Set up Go 100 | uses: actions/setup-go@v4 101 | with: 102 | go-version: 1.21.4 103 | - 104 | name: Run GoReleaser 105 | uses: goreleaser/goreleaser-action@v2 106 | with: 107 | distribution: goreleaser 108 | version: latest 109 | args: release --rm-dist 110 | env: 111 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 112 | -------------------------------------------------------------------------------- /krab/cmd_gen_migration.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/ohkrab/krab/krabenv" 11 | "github.com/ohkrab/krab/krabhcl" 12 | "github.com/spf13/afero" 13 | ) 14 | 15 | // CmdGenMigration generates migation file. 16 | type CmdGenMigration struct { 17 | FS afero.Afero 18 | VersionGenerator 19 | } 20 | 21 | type genMigrationColumn struct { 22 | dbname string 23 | dbtype string 24 | null bool 25 | } 26 | 27 | // ResponseGenMigration json 28 | type ResponseGenMigration struct { 29 | Path string `json:"path"` 30 | Ref string `json:"ref"` 31 | } 32 | 33 | func (c *CmdGenMigration) Arguments() *Arguments { 34 | return &Arguments{ 35 | Args: []*Argument{ 36 | { 37 | Name: "name", 38 | Type: "string", 39 | Description: "Migration name", 40 | }, 41 | }, 42 | } 43 | } 44 | 45 | func (c *CmdGenMigration) Addr() krabhcl.Addr { return krabhcl.NullAddr } 46 | 47 | func (c *CmdGenMigration) Name() []string { 48 | return append([]string{"gen", "migration"}) 49 | } 50 | 51 | func (c *CmdGenMigration) HttpMethod() string { return "" } 52 | 53 | func (c *CmdGenMigration) Do(ctx context.Context, o CmdOpts) (any, error) { 54 | err := c.Arguments().Validate(o.NamedInputs) 55 | if err != nil { 56 | return nil, err 57 | } 58 | return c.run(ctx, o) 59 | } 60 | 61 | func (c *CmdGenMigration) run(ctx context.Context, o CmdOpts) (ResponseGenMigration, error) { 62 | result := ResponseGenMigration{} 63 | 64 | dir, err := krabenv.ConfigDir() 65 | if err != nil { 66 | return result, err 67 | } 68 | dir = filepath.Join(dir, "db", "migrations") 69 | err = c.FS.MkdirAll(dir, 0755) 70 | if err != nil { 71 | return result, err 72 | } 73 | 74 | columns := []*DDLColumn{} 75 | pks := []*DDLPrimaryKey{} 76 | for _, v := range o.PositionalInputs { 77 | splits := strings.Split(v, ":") 78 | if len(splits) == 1 { 79 | switch splits[0] { 80 | case "id": 81 | columns = append(columns, &DDLColumn{Name: "id", Type: "bigint", Null: true, Identity: &DDLIdentity{}}) 82 | pks = append(pks, &DDLPrimaryKey{Columns: []string{"id"}}) 83 | case "timestamps": 84 | columns = append(columns, &DDLColumn{Name: "created_at", Type: "timestamptz", Null: false}) 85 | columns = append(columns, &DDLColumn{Name: "updated_at", Type: "timestamptz", Null: false}) 86 | default: 87 | return result, fmt.Errorf("Invalid column: %s", splits[0]) 88 | } 89 | } else { 90 | columns = append(columns, &DDLColumn{Name: splits[0], Type: splits[1], Null: true}) 91 | } 92 | } 93 | 94 | ref := o.NamedInputs["name"].(string) 95 | table := ref 96 | words := strings.SplitN(ref, "_", 2) 97 | if len(words) == 2 && words[0] == "create" { 98 | table = words[1] 99 | } 100 | 101 | version := c.VersionGenerator.Next() 102 | migration := &Migration{ 103 | RefName: ref, 104 | Version: version, 105 | Up: MigrationUpOrDown{ 106 | CreateTables: []*DDLCreateTable{ 107 | { 108 | Name: table, 109 | Columns: columns, 110 | PrimaryKeys: pks, 111 | }, 112 | }, 113 | }, 114 | Down: MigrationUpOrDown{ 115 | DropTables: []*DDLDropTable{ 116 | { 117 | Name: table, 118 | }, 119 | }, 120 | }, 121 | } 122 | 123 | result.Ref = fmt.Sprint("migration.", ref) 124 | result.Path = filepath.Join(dir, fmt.Sprint(version, "_", ref, krabenv.Ext())) 125 | 126 | buf := bytes.Buffer{} 127 | migration.ToKCL(&buf) 128 | 129 | c.FS.WriteFile(result.Path, buf.Bytes(), 0644) 130 | 131 | return result, nil 132 | } 133 | -------------------------------------------------------------------------------- /krab/type_ddl_column.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/hashicorp/hcl/v2" 8 | "github.com/ohkrab/krab/krabdb" 9 | "github.com/ohkrab/krab/krabhcl" 10 | ) 11 | 12 | // DDLColumn DSL for table DDL. 13 | type DDLColumn struct { 14 | krabhcl.Source 15 | 16 | Name string 17 | Type string 18 | Null bool 19 | Default string 20 | Identity *DDLIdentity 21 | Generated *DDLGeneratedColumn 22 | } 23 | 24 | var schemaColumn = &hcl.BodySchema{ 25 | Blocks: []hcl.BlockHeaderSchema{ 26 | { 27 | Type: "generated", 28 | LabelNames: []string{}, 29 | }, 30 | { 31 | Type: "identity", 32 | LabelNames: []string{}, 33 | }, 34 | }, 35 | Attributes: []hcl.AttributeSchema{ 36 | {Name: "null", Required: false}, 37 | {Name: "default", Required: false}, 38 | }, 39 | } 40 | 41 | // DecodeHCL parses HCL into struct. 42 | func (d *DDLColumn) DecodeHCL(ctx *hcl.EvalContext, block *hcl.Block) error { 43 | d.Source.Extract(block) 44 | 45 | d.Name = block.Labels[0] 46 | d.Type = block.Labels[1] 47 | d.Null = true 48 | d.Identity = nil 49 | d.Generated = nil 50 | 51 | content, diags := block.Body.Content(schemaColumn) 52 | if diags.HasErrors() { 53 | return fmt.Errorf("failed to decode `%s` block: %s", block.Type, diags.Error()) 54 | } 55 | 56 | for _, b := range content.Blocks { 57 | switch b.Type { 58 | case "identity": 59 | d.Identity = &DDLIdentity{} 60 | err := d.Identity.DecodeHCL(ctx, b) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | case "generated": 66 | d.Generated = &DDLGeneratedColumn{} 67 | err := d.Generated.DecodeHCL(ctx, b) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | default: 73 | return fmt.Errorf("Unknown block `%s` for `%s` block", b.Type, block.Type) 74 | } 75 | } 76 | 77 | for k, v := range content.Attributes { 78 | switch k { 79 | case "null": 80 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 81 | val, err := expr.Bool() 82 | if err != nil { 83 | return err 84 | } 85 | d.Null = val 86 | 87 | case "default": 88 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 89 | val, err := expr.String() 90 | if err != nil { 91 | return err 92 | } 93 | d.Default = val 94 | 95 | default: 96 | return fmt.Errorf("Unknown attribute `%s` for `%s` block", k, block.Type) 97 | } 98 | } 99 | 100 | return nil 101 | } 102 | 103 | // ToSQL converts migration definition to SQL. 104 | func (d *DDLColumn) ToSQL(w io.StringWriter) { 105 | w.WriteString(krabdb.QuoteIdent(d.Name)) 106 | w.WriteString(" ") 107 | w.WriteString(d.Type) 108 | 109 | if !d.Null { 110 | w.WriteString(" NOT NULL") 111 | } 112 | 113 | if d.Identity != nil { 114 | w.WriteString(" ") 115 | d.Identity.ToSQL(w) 116 | } 117 | 118 | if d.Generated != nil { 119 | w.WriteString(" ") 120 | d.Generated.ToSQL(w) 121 | } 122 | 123 | if d.Default != "" { 124 | w.WriteString(" DEFAULT ") 125 | w.WriteString(d.Default) 126 | } 127 | } 128 | 129 | // ToKCL converts migration definition to KCL. 130 | func (d *DDLColumn) ToKCL(w io.StringWriter) { 131 | w.WriteString(" column ") 132 | w.WriteString(krabdb.QuoteIdent(d.Name)) 133 | w.WriteString(" ") 134 | w.WriteString(krabdb.QuoteIdent(d.Type)) 135 | w.WriteString(" {") 136 | multiline := false 137 | if d.Identity != nil { 138 | w.WriteString("\n") 139 | d.Identity.ToKCL(w) 140 | w.WriteString("\n") 141 | multiline = true 142 | } 143 | if !d.Null { 144 | w.WriteString("\n") 145 | w.WriteString(" null = false") 146 | w.WriteString("\n") 147 | multiline = true 148 | } 149 | if multiline { 150 | w.WriteString(" }") 151 | } else { 152 | w.WriteString("}") 153 | } 154 | w.WriteString("\n") 155 | } 156 | -------------------------------------------------------------------------------- /krab/arguments.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/hashicorp/hcl/v2" 9 | "github.com/ohkrab/krab/krabhcl" 10 | ) 11 | 12 | type Argument struct { 13 | Name string 14 | Type string 15 | Description string 16 | } 17 | 18 | var schemaArgument = hcl.BodySchema{ 19 | Blocks: []hcl.BlockHeaderSchema{}, 20 | Attributes: []hcl.AttributeSchema{ 21 | { 22 | Name: "type", 23 | Required: false, 24 | }, 25 | { 26 | Name: "description", 27 | Required: false, 28 | }, 29 | }, 30 | } 31 | 32 | // Arguments represents command line arguments or params that you can pass to action. 33 | type Arguments struct { 34 | Args []*Argument 35 | } 36 | 37 | var schemaArguments = hcl.BodySchema{ 38 | Blocks: []hcl.BlockHeaderSchema{ 39 | { 40 | Type: "arg", 41 | LabelNames: []string{"name"}, 42 | }, 43 | }, 44 | } 45 | 46 | // DecodeHCL parses HCL into struct. 47 | func (a *Arguments) DecodeHCL(ctx *hcl.EvalContext, block *hcl.Block) error { 48 | a.Args = []*Argument{} 49 | 50 | content, diags := block.Body.Content(&schemaArguments) 51 | if diags.HasErrors() { 52 | return fmt.Errorf("failed to decode `%s` block: %s", block.Type, diags.Error()) 53 | } 54 | 55 | for _, b := range content.Blocks { 56 | switch b.Type { 57 | case "arg": 58 | arg := new(Argument) 59 | err := arg.DecodeHCL(ctx, b) 60 | if err != nil { 61 | return err 62 | } 63 | a.Args = append(a.Args, arg) 64 | 65 | default: 66 | return fmt.Errorf("Unknown block `%s` for `%s` block", b.Type, block.Type) 67 | } 68 | } 69 | 70 | return nil 71 | } 72 | 73 | func (a *Arguments) Validate(values NamedInputs) error { 74 | for _, a := range a.Args { 75 | value, ok := values[a.Name] 76 | if ok { 77 | if err := a.Validate(value); err != nil { 78 | return err 79 | } 80 | } else { 81 | return fmt.Errorf("Argument value for `%s` (%s) is missing", a.Name, a.Description) 82 | } 83 | } 84 | 85 | return nil 86 | } 87 | 88 | func (a *Arguments) Help() string { 89 | sb := strings.Builder{} 90 | if len(a.Args) > 0 { 91 | for _, arg := range a.Args { 92 | sb.WriteString(" -") 93 | sb.WriteString(arg.Name) 94 | sb.WriteString(" (") 95 | sb.WriteString(arg.Type) 96 | sb.WriteString(", required") 97 | sb.WriteString(") ") 98 | sb.WriteString(arg.Description) 99 | sb.WriteString("\n") 100 | } 101 | } 102 | 103 | return sb.String() 104 | } 105 | 106 | // DecodeHCL parses HCL into struct. 107 | func (a *Argument) DecodeHCL(ctx *hcl.EvalContext, block *hcl.Block) error { 108 | a.Name = block.Labels[0] 109 | a.Type = "string" 110 | a.Description = "" 111 | 112 | content, diags := block.Body.Content(&schemaArgument) 113 | if diags.HasErrors() { 114 | return fmt.Errorf("failed to decode `%s` block: %s", block.Type, diags.Error()) 115 | } 116 | 117 | // no blocks to decode 118 | 119 | for k, v := range content.Attributes { 120 | switch k { 121 | case "type": 122 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 123 | val, err := expr.String() 124 | if err != nil { 125 | return err 126 | } 127 | a.Type = val 128 | 129 | case "description": 130 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 131 | val, err := expr.String() 132 | if err != nil { 133 | return err 134 | } 135 | a.Description = val 136 | 137 | default: 138 | return fmt.Errorf("Unknown attribute `%s` for `migration` block", k) 139 | } 140 | } 141 | 142 | return nil 143 | } 144 | 145 | func (a *Argument) Validate(value any) error { 146 | switch value.(type) { 147 | case string: 148 | if len(value.(string)) == 0 { 149 | return fmt.Errorf("Value for -%s is required", a.Name) 150 | } 151 | default: 152 | return errors.New("Argument type not implemented") 153 | } 154 | return nil 155 | } 156 | -------------------------------------------------------------------------------- /spec/parser_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestParserWithDuplicatedRefNames(t *testing.T) { 11 | assert := assert.New(t) 12 | 13 | p := mockParser( 14 | "src/public.krab.hcl", 15 | ` 16 | migration "abc" { 17 | version = "2006" 18 | up { sql = "" } 19 | down { sql = "" } 20 | } 21 | 22 | migration "abc" { 23 | version = "2006" 24 | up { sql = "" } 25 | down { sql = "" } 26 | } 27 | `) 28 | _, err := p.LoadConfigDir("src") 29 | if assert.Error(err) { 30 | assert.Contains(err.Error(), "Migration with the name 'abc' already exists") 31 | } 32 | } 33 | 34 | func TestParserMigrationSetWithDuplicatedRefName(t *testing.T) { 35 | assert := assert.New(t) 36 | 37 | p := mockParser( 38 | "src/sets.krab.hcl", 39 | ` 40 | migration_set "abc" { 41 | migrations = [] 42 | } 43 | 44 | migration_set "abc" { 45 | migrations = [] 46 | } 47 | `) 48 | _, err := p.LoadConfigDir("src") 49 | if assert.Error(err) { 50 | assert.Contains(err.Error(), "Migration Set with the name 'abc' already exists", "Names must be unique") 51 | } 52 | } 53 | 54 | func TestParserMigrationSetWithMissingMigrationReference(t *testing.T) { 55 | assert := assert.New(t) 56 | 57 | p := mockParser( 58 | "src/sets.krab.hcl", 59 | ` 60 | migration_set "abc" { 61 | migrations = [migration.does_not_exist] 62 | } 63 | `) 64 | _, err := p.LoadConfigDir("src") 65 | if assert.Error(err, "Parsing config should fail") { 66 | assert.Contains(err.Error(), "Migration Set references 'does_not_exist' migration that does not exist", "Missing migration") 67 | } 68 | } 69 | 70 | func TestParserWithMigrationsDefinedInSQLFiles(t *testing.T) { 71 | assert := assert.New(t) 72 | 73 | p := mockParser( 74 | "src/migrations.krab.hcl", 75 | ` 76 | migration "abc" { 77 | version = "2006" 78 | up { 79 | sql = file_read("src/up.sql") 80 | } 81 | down { 82 | sql = file_read("src/down.sql") 83 | } 84 | } 85 | `, 86 | "src/up.sql", 87 | "CREATE TABLE abc", 88 | "src/down.sql", 89 | "DROP TABLE abc", 90 | ) 91 | 92 | config, err := p.LoadConfigDir("src") 93 | if assert.NoError(err, "Parsing config should not fail") { 94 | 95 | migration, exists := config.Migrations["abc"] 96 | if assert.True(exists) { 97 | assert.Equal(migration.RefName, "abc") 98 | var up strings.Builder 99 | var down strings.Builder 100 | migration.Up.ToSQL(&up) 101 | migration.Down.ToSQL(&down) 102 | assert.Equal(up.String(), "CREATE TABLE abc") 103 | assert.Equal(down.String(), "DROP TABLE abc") 104 | } 105 | } 106 | } 107 | 108 | func TestParserWithMigrationsDefinedInSQLFilesThatAreMissing(t *testing.T) { 109 | assert := assert.New(t) 110 | 111 | p := mockParser( 112 | "src/migrations.krab.hcl", 113 | ` 114 | migration "abc" { 115 | version = "2006" 116 | up { 117 | sql = file_read("src/up.sql") 118 | } 119 | down { 120 | sql = file_read("src/down.sql") 121 | } 122 | } 123 | `, 124 | ) 125 | 126 | _, err := p.LoadConfigDir("src") 127 | if assert.Error(err, "Parsing config should fail") { 128 | assert.Contains( 129 | err.Error(), 130 | `Call to function "file_read" failed`, 131 | ) 132 | } 133 | } 134 | 135 | func TestParserRecursiveDir(t *testing.T) { 136 | assert := assert.New(t) 137 | 138 | p := mockParser( 139 | "src/a.krab.hcl", 140 | ` 141 | migration "abc" { 142 | version = "v1" 143 | up {} 144 | down {} 145 | } 146 | `, 147 | "src/nested/b.krab.hcl", 148 | ` 149 | migration "def" { 150 | version = "v2" 151 | up {} 152 | down {} 153 | } 154 | `, 155 | ) 156 | 157 | config, err := p.LoadConfigDir("src") 158 | if assert.NoError(err, "Parsing config should not fail") { 159 | _, abcOk := config.Migrations["abc"] 160 | _, defOk := config.Migrations["def"] 161 | 162 | assert.True(abcOk, "`abc` migration exists") 163 | assert.True(defOk, "`def` migration exists") 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /krab/cmd_migrate_down.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/ohkrab/krab/krabdb" 9 | "github.com/ohkrab/krab/krabhcl" 10 | "github.com/ohkrab/krab/krabtpl" 11 | "github.com/ohkrab/krab/tpls" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | // CmdMigrateDown returns migration status information. 16 | type CmdMigrateDown struct { 17 | Set *MigrationSet 18 | Connection krabdb.Connection 19 | } 20 | 21 | // ResponseMigrateDown json 22 | type ResponseMigrateDown struct { 23 | Name string `json:"name"` 24 | Version string `json:"version"` 25 | Success bool `json:"success"` 26 | } 27 | 28 | func (c *CmdMigrateDown) Arguments() *Arguments { 29 | return &Arguments{ 30 | Args: []*Argument{ 31 | { 32 | Name: "version", 33 | Type: "string", 34 | Description: "Migration version to rollback", 35 | }, 36 | }, 37 | } 38 | } 39 | 40 | func (c *CmdMigrateDown) Addr() krabhcl.Addr { return c.Set.Addr() } 41 | 42 | func (c *CmdMigrateDown) Name() []string { 43 | return append([]string{"migrate", "down"}, c.Set.Addr().Labels...) 44 | } 45 | 46 | func (c *CmdMigrateDown) HttpMethod() string { return http.MethodPost } 47 | 48 | func (c *CmdMigrateDown) Do(ctx context.Context, o CmdOpts) (any, error) { 49 | err := c.Set.Arguments.Validate(o.NamedInputs) 50 | if err != nil { 51 | return nil, err 52 | } 53 | err = c.Arguments().Validate(o.NamedInputs) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | var result []ResponseMigrateDown 59 | err = c.Connection.Get(func(db krabdb.DB) error { 60 | resp, err := c.run(ctx, db, o.NamedInputs) 61 | result = resp 62 | return err 63 | }) 64 | 65 | return result, err 66 | } 67 | 68 | func (c *CmdMigrateDown) run(ctx context.Context, db krabdb.DB, inputs NamedInputs) ([]ResponseMigrateDown, error) { 69 | result := []ResponseMigrateDown{} 70 | 71 | tpl := tpls.New(inputs, krabtpl.Functions()) 72 | versions := NewSchemaMigrationTable(tpl.Render(c.Set.Schema)) 73 | 74 | migration := c.Set.FindMigrationByVersion(inputs["version"].(string)) 75 | if migration == nil { 76 | return nil, fmt.Errorf("Migration `%s` not found in `%s` set", 77 | inputs["version"].(string), 78 | c.Set.RefName) 79 | } 80 | lockID := int64(1) 81 | 82 | _, err := krabdb.TryAdvisoryLock(ctx, db, lockID) 83 | if err != nil { 84 | return nil, errors.Wrap(err, "Possibly another migration in progress") 85 | } 86 | defer krabdb.AdvisoryUnlock(ctx, db, lockID) 87 | 88 | hooksRunner := HookRunner{} 89 | err = hooksRunner.SetSearchPath(ctx, db, tpl.Render(c.Set.Schema)) 90 | if err != nil { 91 | return nil, errors.Wrap(err, "Failed to run SetSearchPath hook") 92 | } 93 | 94 | // schema migration 95 | tx, err := db.NewTx(ctx, migration.Transaction) 96 | if err != nil { 97 | return nil, errors.Wrap(err, "Failed to start transaction") 98 | } 99 | err = hooksRunner.SetSearchPath(ctx, tx, tpl.Render(c.Set.Schema)) 100 | if err != nil { 101 | return nil, errors.Wrap(err, "Failed to run SetSearchPath hook") 102 | } 103 | 104 | migrationExists, _ := versions.Exists(ctx, db, SchemaMigration{migration.Version}) 105 | if migrationExists { 106 | sqls := migration.Down.ToSQLStatements() 107 | for _, sql := range sqls { 108 | // fmt.Println(ctc.ForegroundYellow, string(sql), ctc.Reset) 109 | _, err := tx.ExecContext(ctx, string(sql)) 110 | if err != nil { 111 | result = append(result, ResponseMigrateDown{ 112 | Name: migration.RefName, 113 | Version: migration.Version, 114 | Success: false, 115 | }) 116 | tx.Rollback() 117 | return nil, errors.Wrap(err, "Failed to execute migration") 118 | } 119 | } 120 | 121 | err := versions.Delete(ctx, tx, migration.Version) 122 | if err != nil { 123 | result = append(result, ResponseMigrateDown{ 124 | Name: migration.RefName, 125 | Version: migration.Version, 126 | Success: false, 127 | }) 128 | tx.Rollback() 129 | return nil, errors.Wrap(err, "Failed to delete migration") 130 | } 131 | 132 | result = append(result, ResponseMigrateDown{ 133 | Name: migration.RefName, 134 | Version: migration.Version, 135 | Success: true, 136 | }) 137 | } else { 138 | tx.Rollback() 139 | return nil, errors.New("Migration has not been run yet, nothing to rollback") 140 | } 141 | 142 | err = tx.Commit() 143 | 144 | return result, err 145 | } 146 | -------------------------------------------------------------------------------- /krab/schema_migration_table.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/ohkrab/krab/krabdb" 9 | ) 10 | 11 | const DefaultSchemaMigrationTableName = "schema_migrations" 12 | 13 | type SchemaMigrationTable struct { 14 | Name string 15 | } 16 | 17 | // NewSchemaMigrationTable creates SchemaMigrationTable with default table name and specified schema. 18 | func NewSchemaMigrationTable(schema string) SchemaMigrationTable { 19 | return SchemaMigrationTable{ 20 | Name: strings.Join( 21 | []string{ 22 | schema, 23 | DefaultSchemaMigrationTableName, 24 | }, 25 | ".", 26 | ), 27 | } 28 | } 29 | 30 | // SchemaMigration represents a single row from migrations table. 31 | type SchemaMigration struct { 32 | Version string `db:"version"` 33 | } 34 | 35 | // Init creates a migrations table. 36 | func (s SchemaMigrationTable) Init(ctx context.Context, db krabdb.ExecerContext) error { 37 | parts := strings.Split(s.Name, ".") 38 | if len(parts) > 1 { 39 | schema := parts[0] 40 | _, err := db.ExecContext(ctx, fmt.Sprintf( 41 | "CREATE SCHEMA IF NOT EXISTS %s", 42 | krabdb.QuoteIdent(schema), 43 | )) 44 | if err != nil { 45 | return err 46 | } 47 | } 48 | 49 | _, err := db.ExecContext(ctx, fmt.Sprintf( 50 | "CREATE TABLE IF NOT EXISTS %s(version varchar PRIMARY KEY, migrated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP)", 51 | krabdb.QuoteIdentWithDots(s.Name), 52 | )) 53 | 54 | return err 55 | } 56 | 57 | // Truncate truncates migrations table. 58 | func (s SchemaMigrationTable) Truncate(ctx context.Context, db krabdb.ExecerContext) error { 59 | _, err := db.ExecContext(ctx, fmt.Sprintf( 60 | "TRUNCATE %s", 61 | krabdb.QuoteIdentWithDots(s.Name), 62 | )) 63 | return err 64 | } 65 | 66 | // Exists checks if migration exists in database. 67 | func (s SchemaMigrationTable) Exists(ctx context.Context, db krabdb.QueryerContext, migration SchemaMigration) (bool, error) { 68 | var schema []SchemaMigration 69 | err := db.SelectContext( 70 | ctx, 71 | &schema, 72 | fmt.Sprintf("SELECT version FROM %s WHERE version = $1", krabdb.QuoteIdentWithDots(s.Name)), 73 | migration.Version, 74 | ) 75 | return len(schema) > 0, err 76 | } 77 | 78 | // SelectLastN fetches last N migrations in Z-A order. 79 | func (s SchemaMigrationTable) SelectLastN(ctx context.Context, db krabdb.QueryerContext, limit int) ([]SchemaMigration, error) { 80 | var schema []SchemaMigration 81 | err := db.SelectContext( 82 | ctx, 83 | &schema, 84 | fmt.Sprintf("SELECT version FROM %s ORDER BY 1 DESC LIMIT %d", krabdb.QuoteIdentWithDots(s.Name), limit), 85 | ) 86 | return schema, err 87 | } 88 | 89 | // SelectAll fetches all migrations from a database. 90 | func (s SchemaMigrationTable) SelectAll(ctx context.Context, db krabdb.QueryerContext) ([]SchemaMigration, error) { 91 | var schema []SchemaMigration 92 | err := db.SelectContext( 93 | ctx, 94 | &schema, 95 | fmt.Sprintf("SELECT version FROM %s ORDER BY 1", krabdb.QuoteIdentWithDots(s.Name)), 96 | ) 97 | return schema, err 98 | } 99 | 100 | // Insert saves migration to a database. 101 | func (s SchemaMigrationTable) Insert(ctx context.Context, db krabdb.ExecerContext, version string) error { 102 | _, err := db.ExecContext( 103 | ctx, 104 | fmt.Sprintf("INSERT INTO %s(version) VALUES ($1) RETURNING *", krabdb.QuoteIdentWithDots(s.Name)), 105 | version, 106 | ) 107 | return err 108 | } 109 | 110 | // Delete removes migration from a database. 111 | func (s SchemaMigrationTable) Delete(ctx context.Context, db krabdb.ExecerContext, version string) error { 112 | _, err := db.ExecContext( 113 | ctx, 114 | fmt.Sprintf("DELETE FROM %s WHERE version = $1 RETURNING *", krabdb.QuoteIdentWithDots(s.Name)), 115 | version, 116 | ) 117 | return err 118 | } 119 | 120 | // FilterPending removes `refsInDb` migrations from `all` and return new slice with pending ones only. 121 | func (s SchemaMigrationTable) FilterPending(all []*Migration, refsInDb []SchemaMigration) []*Migration { 122 | pendingMigrations := make([]*Migration, 0) 123 | 124 | for _, migration := range all { 125 | var found *Migration 126 | for _, ref := range refsInDb { 127 | if migration.Version == ref.Version { 128 | found = migration 129 | break 130 | } 131 | } 132 | 133 | if found == nil { 134 | pendingMigrations = append(pendingMigrations, migration) 135 | } 136 | } 137 | 138 | return pendingMigrations 139 | } 140 | -------------------------------------------------------------------------------- /krab/type_ddl_create_index.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/hashicorp/hcl/v2" 8 | "github.com/ohkrab/krab/krabdb" 9 | "github.com/ohkrab/krab/krabhcl" 10 | ) 11 | 12 | // DDLCreateIndex contains DSL for creating indicies. 13 | type DDLCreateIndex struct { 14 | krabhcl.Source 15 | 16 | Table string 17 | Name string 18 | Unique bool 19 | Concurrently bool 20 | Columns []string 21 | Include []string 22 | Using string 23 | Where string 24 | } 25 | 26 | var schemaCreateIndex = &hcl.BodySchema{ 27 | Blocks: []hcl.BlockHeaderSchema{ 28 | { 29 | Type: "column", 30 | LabelNames: []string{"name", "type"}, 31 | }, 32 | }, 33 | Attributes: []hcl.AttributeSchema{ 34 | {Name: "columns", Required: true}, 35 | {Name: "include", Required: false}, 36 | {Name: "unique", Required: false}, 37 | {Name: "using", Required: false}, 38 | {Name: "where", Required: false}, 39 | {Name: "concurrently", Required: false}, 40 | }, 41 | } 42 | 43 | // DecodeHCL parses HCL into struct. 44 | func (d *DDLCreateIndex) DecodeHCL(ctx *hcl.EvalContext, block *hcl.Block) error { 45 | d.Source.Extract(block) 46 | 47 | d.Table = block.Labels[0] 48 | d.Name = block.Labels[1] 49 | d.Unique = false 50 | d.Concurrently = false 51 | d.Columns = []string{} 52 | d.Include = []string{} 53 | d.Using = "" 54 | d.Where = "" 55 | 56 | content, diags := block.Body.Content(schemaCreateIndex) 57 | if diags.HasErrors() { 58 | return fmt.Errorf("failed to decode `%s` block: %s", block.Type, diags.Error()) 59 | } 60 | 61 | for _, b := range content.Blocks { 62 | switch b.Type { 63 | 64 | default: 65 | return fmt.Errorf("Unknown block `%s` for `%s` block", b.Type, block.Type) 66 | } 67 | } 68 | 69 | for k, v := range content.Attributes { 70 | switch k { 71 | case "columns": 72 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 73 | val, err := expr.SliceString() 74 | if err != nil { 75 | return err 76 | } 77 | d.Columns = append(d.Columns, val...) 78 | 79 | case "include": 80 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 81 | val, err := expr.SliceString() 82 | if err != nil { 83 | return err 84 | } 85 | d.Include = append(d.Include, val...) 86 | 87 | case "unique": 88 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 89 | val, err := expr.Bool() 90 | if err != nil { 91 | return err 92 | } 93 | d.Unique = val 94 | 95 | case "using": 96 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 97 | val, err := expr.String() 98 | if err != nil { 99 | return err 100 | } 101 | d.Using = val 102 | 103 | case "where": 104 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 105 | val, err := expr.String() 106 | if err != nil { 107 | return err 108 | } 109 | d.Where = val 110 | 111 | case "concurrently": 112 | expr := krabhcl.Expression{Expr: v.Expr, EvalContext: ctx} 113 | val, err := expr.Bool() 114 | if err != nil { 115 | return err 116 | } 117 | d.Concurrently = val 118 | 119 | default: 120 | return fmt.Errorf("Unknown attribute `%s` for `%s` block", k, block.Type) 121 | } 122 | } 123 | 124 | return nil 125 | } 126 | 127 | // ToSQL converts migration definition to SQL. 128 | func (d *DDLCreateIndex) ToSQL(w io.StringWriter) { 129 | w.WriteString("CREATE") 130 | if d.Unique { 131 | w.WriteString(" UNIQUE") 132 | } 133 | w.WriteString(" INDEX") 134 | if d.Concurrently { 135 | w.WriteString(" CONCURRENTLY") 136 | } 137 | w.WriteString(" ") 138 | w.WriteString(krabdb.QuoteIdent(d.Name)) 139 | w.WriteString(" ON ") 140 | w.WriteString(krabdb.QuoteIdent(d.Table)) 141 | if d.Using != "" { 142 | w.WriteString(" USING ") 143 | w.WriteString(d.Using) 144 | } 145 | w.WriteString(" (") 146 | for i, col := range d.Columns { 147 | w.WriteString(krabdb.QuoteIdent(col)) 148 | if i < len(d.Columns)-1 { 149 | w.WriteString(",") 150 | } 151 | } 152 | w.WriteString(")") 153 | 154 | if len(d.Include) > 0 { 155 | w.WriteString(" INCLUDE (") 156 | for i, col := range d.Include { 157 | w.WriteString(krabdb.QuoteIdent(col)) 158 | if i < len(d.Columns)-1 { 159 | w.WriteString(",") 160 | } 161 | } 162 | w.WriteString(")") 163 | } 164 | 165 | if d.Where != "" { 166 | w.WriteString(" WHERE (") 167 | w.WriteString(d.Where) 168 | w.WriteString(")") 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /spec/action_migrate_dsl_table_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestActionMigrateDslTable(t *testing.T) { 8 | c := mockCli(mockConfig(` 9 | migration "create_categories" { 10 | version = "v1" 11 | 12 | up { 13 | create_table "categories" { 14 | column "id" "bigint" {} 15 | column "name" "varchar" { null = false } 16 | 17 | primary_key { columns = ["id"] } 18 | } 19 | } 20 | 21 | down { 22 | drop_table "categories" {} 23 | } 24 | } 25 | 26 | migration "create_animals" { 27 | version = "v2" 28 | 29 | up { 30 | create_table "animals" { 31 | unlogged = true 32 | 33 | column "id" "bigint" { 34 | identity {} 35 | } 36 | 37 | column "name" "varchar" { null = true } 38 | 39 | column "extinct" "boolean" { 40 | null = false 41 | default = "TRUE" 42 | } 43 | 44 | column "weight_kg" "int" { null = false } 45 | 46 | column "weight_g" "int" { 47 | generated { 48 | as = "weight_kg * 1000" 49 | } 50 | } 51 | 52 | column "category_id" "bigint" { 53 | null = false 54 | } 55 | 56 | unique { 57 | columns = ["name"] 58 | include = ["weight_kg"] 59 | } 60 | 61 | primary_key { 62 | columns = ["id"] 63 | include = ["name"] 64 | } 65 | 66 | check "ensure_positive_weight" { 67 | expression = "weight_kg > 0" 68 | } 69 | 70 | foreign_key { 71 | columns = ["category_id"] 72 | 73 | references "categories" { 74 | columns = ["id"] 75 | 76 | on_delete = "cascade" 77 | on_update = "cascade" 78 | } 79 | } 80 | } 81 | } 82 | 83 | down { 84 | drop_table "animals" {} 85 | } 86 | } 87 | 88 | migration_set "animals" { 89 | migrations = [ 90 | migration.create_categories, 91 | migration.create_animals 92 | ] 93 | } 94 | `)) 95 | defer c.Teardown() 96 | if c.AssertSuccessfulRun(t, []string{"migrate", "up", "animals"}) { 97 | c.AssertSchemaMigrationTable(t, "public", "v1", "v2") 98 | c.AssertOutputContains(t, "\x1b[0;32mOK \x1b[0mv1 create_categories") 99 | c.AssertOutputContains(t, "\x1b[0;32mOK \x1b[0mv2 create_animals") 100 | c.AssertSQLContains(t, ` 101 | CREATE TABLE "categories"( 102 | "id" bigint, 103 | "name" varchar NOT NULL 104 | , PRIMARY KEY ("id") 105 | ) 106 | `) 107 | c.AssertSQLContains(t, ` 108 | CREATE UNLOGGED TABLE "animals"( 109 | "id" bigint GENERATED ALWAYS AS IDENTITY, 110 | "name" varchar, 111 | "extinct" boolean NOT NULL DEFAULT TRUE, 112 | "weight_kg" int NOT NULL, 113 | "weight_g" int GENERATED ALWAYS AS (weight_kg * 1000) STORED, 114 | "category_id" bigint NOT NULL 115 | , PRIMARY KEY ("id") INCLUDE ("name") 116 | , FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE cascade ON UPDATE cascade 117 | , UNIQUE ("name") INCLUDE ("weight_kg") 118 | , CONSTRAINT "ensure_positive_weight" CHECK (weight_kg > 0) 119 | ) 120 | `) 121 | 122 | if c.AssertSuccessfulRun(t, []string{"migrate", "down", "animals", "-version", "v2"}) { 123 | c.AssertSchemaMigrationTable(t, "public", "v1") 124 | c.AssertOutputContains(t, "\x1b[0;32mOK \x1b[0mv2 create_animals") 125 | c.AssertSQLContains(t, ` 126 | DROP TABLE "animals" 127 | `) 128 | } 129 | } 130 | } 131 | 132 | func TestActionMigrateDslTableDefaults(t *testing.T) { 133 | c := mockCli(mockConfig(` 134 | migration "create_animals" { 135 | version = "v2" 136 | 137 | up { 138 | create_table "animals" { 139 | column "id" "bigint" { default = "1" } 140 | column "x" "real" { default = "1.2" } 141 | column "y" "double precision" { default = "1.3" } 142 | column "name" "varchar" { default = "'hello'" } 143 | column "extinct" "boolean" { default = "TRUE" } 144 | column "map" "jsonb" { default = "'{}'" } 145 | column "list" "jsonb" { default = "'[]'" } 146 | } 147 | } 148 | 149 | down { 150 | drop_table "animals" {} 151 | } 152 | } 153 | 154 | migration_set "animals" { 155 | migrations = [ 156 | migration.create_animals 157 | ] 158 | } 159 | `)) 160 | defer c.Teardown() 161 | if c.AssertSuccessfulRun(t, []string{"migrate", "up", "animals"}) { 162 | c.AssertSQLContains(t, "DEFAULT 1") 163 | c.AssertSQLContains(t, "DEFAULT 1.2") 164 | c.AssertSQLContains(t, "DEFAULT 1.3") 165 | c.AssertSQLContains(t, "DEFAULT 'hello'") 166 | c.AssertSQLContains(t, "DEFAULT TRUE") 167 | c.AssertSQLContains(t, "DEFAULT '{}'") 168 | c.AssertSQLContains(t, "DEFAULT '[]'") 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /krab/cmd_migrate_up.go: -------------------------------------------------------------------------------- 1 | package krab 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/ohkrab/krab/krabdb" 8 | "github.com/ohkrab/krab/krabhcl" 9 | "github.com/ohkrab/krab/krabtpl" 10 | "github.com/ohkrab/krab/tpls" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | // CmdMigrateUp returns migration status information. 15 | type CmdMigrateUp struct { 16 | Set *MigrationSet 17 | Connection krabdb.Connection 18 | } 19 | 20 | // ResponseMigrateUp json 21 | type ResponseMigrateUp struct { 22 | Name string `json:"name"` 23 | Version string `json:"version"` 24 | Success bool `json:"success"` 25 | } 26 | 27 | func (c *CmdMigrateUp) Addr() krabhcl.Addr { return c.Set.Addr() } 28 | 29 | func (c *CmdMigrateUp) Name() []string { 30 | return append([]string{"migrate", "up"}, c.Set.Addr().Labels...) 31 | } 32 | 33 | func (c *CmdMigrateUp) HttpMethod() string { return http.MethodPost } 34 | 35 | func (c *CmdMigrateUp) Do(ctx context.Context, o CmdOpts) (any, error) { 36 | err := c.Set.Arguments.Validate(o.NamedInputs) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | var result []ResponseMigrateUp 42 | err = c.Connection.Get(func(db krabdb.DB) error { 43 | resp, err := c.run(ctx, db, o.NamedInputs) 44 | result = resp 45 | return err 46 | }) 47 | 48 | return result, err 49 | } 50 | 51 | func (c *CmdMigrateUp) run(ctx context.Context, db krabdb.DB, inputs NamedInputs) ([]ResponseMigrateUp, error) { 52 | result := []ResponseMigrateUp{} 53 | 54 | tpl := tpls.New(inputs, krabtpl.Functions()) 55 | versions := NewSchemaMigrationTable(tpl.Render(c.Set.Schema)) 56 | 57 | // locking 58 | lockID := int64(1) 59 | 60 | _, err := krabdb.TryAdvisoryLock(ctx, db, lockID) 61 | if err != nil { 62 | return nil, errors.Wrap(err, "Possibly another migration in progress") 63 | } 64 | defer krabdb.AdvisoryUnlock(ctx, db, lockID) 65 | 66 | hooksRunner := HookRunner{} 67 | err = hooksRunner.SetSearchPath(ctx, db, tpl.Render(c.Set.Schema)) 68 | if err != nil { 69 | return nil, errors.Wrap(err, "Failed to run SetSearchPath hook") 70 | } 71 | 72 | // schema migration 73 | err = versions.Init(ctx, db) 74 | if err != nil { 75 | return nil, errors.Wrap(err, "Failed to create default table for migrations") 76 | } 77 | 78 | migrationRefsInDb, err := versions.SelectAll(ctx, db) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | pendingMigrations := versions.FilterPending(c.Set.Migrations, migrationRefsInDb) 84 | 85 | for _, pending := range pendingMigrations { 86 | tx, err := db.NewTx(ctx, pending.Transaction) 87 | if err != nil { 88 | result = append(result, ResponseMigrateUp{ 89 | Name: pending.RefName, 90 | Version: pending.Version, 91 | Success: false, 92 | }) 93 | return result, errors.Wrap(err, "Failed to start transaction") 94 | } 95 | err = hooksRunner.SetSearchPath(ctx, tx, tpl.Render(c.Set.Schema)) 96 | if err != nil { 97 | result = append(result, ResponseMigrateUp{ 98 | Name: pending.RefName, 99 | Version: pending.Version, 100 | Success: false, 101 | }) 102 | return result, errors.Wrap(err, "Failed to run SetSearchPath hook") 103 | } 104 | 105 | err = c.migrateUp(ctx, tx, pending, versions) 106 | if err != nil { 107 | result = append(result, ResponseMigrateUp{ 108 | Name: pending.RefName, 109 | Version: pending.Version, 110 | Success: false, 111 | }) 112 | tx.Rollback() 113 | return result, err 114 | } 115 | 116 | err = tx.Commit() 117 | if err != nil { 118 | result = append(result, ResponseMigrateUp{ 119 | Name: pending.RefName, 120 | Version: pending.Version, 121 | Success: false, 122 | }) 123 | return result, err 124 | } 125 | 126 | result = append(result, ResponseMigrateUp{ 127 | Name: pending.RefName, 128 | Version: pending.Version, 129 | Success: true, 130 | }) 131 | } 132 | 133 | return result, nil 134 | } 135 | 136 | func (c *CmdMigrateUp) migrateUp(ctx context.Context, tx krabdb.TransactionExecerContext, migration *Migration, versions SchemaMigrationTable) error { 137 | sqls := migration.Up.ToSQLStatements() 138 | for _, sql := range sqls { 139 | // fmt.Println(ctc.ForegroundYellow, string(sql), ctc.Reset) 140 | _, err := tx.ExecContext(ctx, string(sql)) 141 | if err != nil { 142 | return errors.Wrap(err, "Failed to execute migration") 143 | } 144 | } 145 | 146 | err := versions.Insert(ctx, tx, migration.Version) 147 | if err != nil { 148 | return errors.Wrap(err, "Failed to insert migration") 149 | } 150 | 151 | return nil 152 | } 153 | -------------------------------------------------------------------------------- /spec/action_migrate_down_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestActionMigrateDown(t *testing.T) { 10 | assert := assert.New(t) 11 | 12 | c := mockCli(mockConfig(` 13 | migration "create_animals" { 14 | version = "v1" 15 | 16 | up { sql = "CREATE TABLE animals(name VARCHAR)" } 17 | down { sql = "DROP TABLE animals" } 18 | } 19 | 20 | migration "add_column" { 21 | version = "v2" 22 | 23 | up { sql = "ALTER TABLE animals ADD COLUMN emoji VARCHAR" } 24 | down { sql = "ALTER TABLE animals DROP COLUMN emoji" } 25 | } 26 | 27 | migration_set "public" { 28 | migrations = [migration.create_animals, migration.add_column] 29 | } 30 | `)) 31 | defer c.Teardown() 32 | c.AssertSuccessfulRun(t, []string{"migrate", "up", "public"}) 33 | c.AssertOutputContains(t, "\x1b[0;32mOK \x1b[0mv1 create_animals") 34 | c.AssertOutputContains(t, "\x1b[0;32mOK \x1b[0mv2 add_column") 35 | c.AssertSchemaMigrationTable(t, "public", "v1", "v2") 36 | c.Insert(t, "animals", "name, emoji", "('Elephant', '🐘')") 37 | cols, rows := c.Query(t, "SELECT * from animals") 38 | 39 | assert.ElementsMatch([]string{"name", "emoji"}, cols, "Columns must match") 40 | if assert.Equal(1, len(rows)) { 41 | assert.Equal("Elephant", rows[0]["name"]) 42 | assert.Equal("🐘", rows[0]["emoji"]) 43 | } 44 | 45 | c.AssertSuccessfulRun(t, []string{"migrate", "down", "public", "-version", "v2"}) 46 | c.AssertOutputContains(t, "\x1b[0;32mOK \x1b[0mv2 add_column") 47 | c.AssertSchemaMigrationTable(t, "public", "v1") 48 | 49 | cols, rows = c.Query(t, "SELECT * from animals") 50 | 51 | assert.ElementsMatch([]string{"name"}, cols, "Columns must match") 52 | if assert.Equal(1, len(rows)) { 53 | assert.Equal("Elephant", rows[0]["name"]) 54 | assert.Nil(rows[0]["emoji"]) 55 | } 56 | } 57 | 58 | func TestActionMigrateDownOnError(t *testing.T) { 59 | assert := assert.New(t) 60 | 61 | c := mockCli(mockConfig(` 62 | migration "create_animals" { 63 | version = "v1" 64 | 65 | up { sql = "CREATE TABLE animals(name VARCHAR)" } 66 | down { sql = "DROP TABLE animals" } 67 | } 68 | 69 | migration "add_column" { 70 | version = "v2" 71 | 72 | up { sql = "ALTER TABLE animals ADD COLUMN emoji VARCHAR" } 73 | down { sql = "ALTER TABLE animals DROP COLUMN emoji; ALTER TABLE animals DROP COLUMN habitat" } 74 | } 75 | 76 | migration_set "public" { 77 | migrations = [migration.create_animals, migration.add_column] 78 | } 79 | `)) 80 | defer c.Teardown() 81 | c.AssertSuccessfulRun(t, []string{"migrate", "up", "public"}) 82 | c.AssertOutputContains(t, "\x1b[0;32mOK \x1b[0mv1 create_animals") 83 | c.AssertOutputContains(t, "\x1b[0;32mOK \x1b[0mv2 add_column") 84 | c.AssertSchemaMigrationTable(t, "public", "v1", "v2") 85 | c.Insert(t, "animals", "name, emoji", "('Elephant', '🐘')") 86 | cols, rows := c.Query(t, "SELECT * from animals") 87 | 88 | assert.ElementsMatch([]string{"name", "emoji"}, cols, "Columns must match") 89 | if assert.Equal(1, len(rows)) { 90 | assert.Equal("Elephant", rows[0]["name"]) 91 | assert.Equal("🐘", rows[0]["emoji"]) 92 | } 93 | 94 | c.AssertFailedRun(t, []string{"migrate", "down", "public", "-version", "v2"}) 95 | c.AssertUiErrorOutputContains(t, 96 | `column "habitat" of relation "animals" does not exist`, 97 | ) 98 | 99 | // state after 100 | c.AssertSchemaMigrationTable(t, "public", "v1", "v2") 101 | cols, rows = c.Query(t, "SELECT * from animals") 102 | assert.ElementsMatch([]string{"name", "emoji"}, cols, "Columns must match") 103 | } 104 | 105 | func TestActionMigrateDownWhenSchemaDoesNotExist(t *testing.T) { 106 | assert := assert.New(t) 107 | 108 | c := mockCli(mockConfig(` 109 | migration "create_animals" { 110 | version = "v1" 111 | 112 | up { sql = "CREATE TABLE animals(name VARCHAR)" } 113 | down { sql = "DROP TABLE animals" } 114 | } 115 | 116 | migration "add_column" { 117 | version = "v2" 118 | 119 | up { sql = "ALTER TABLE animals ADD COLUMN emoji VARCHAR" } 120 | down { sql = "ALTER TABLE animals DROP COLUMN emoji" } 121 | } 122 | 123 | migration_set "public" { 124 | migrations = [migration.create_animals, migration.add_column] 125 | } 126 | `)) 127 | defer c.Teardown() 128 | c.AssertSuccessfulRun(t, []string{"migrate", "up", "public"}) 129 | c.AssertSchemaMigrationTable(t, "public", "v1", "v2") 130 | c.AssertSuccessfulRun(t, []string{"migrate", "down", "public", "-version", "v2"}) 131 | c.AssertSchemaMigrationTable(t, "public", "v1") 132 | 133 | c.Insert(t, "animals", "name", "('Crab')") 134 | _, rows := c.Query(t, "SELECT * from animals") 135 | if assert.Equal(1, len(rows)) { 136 | assert.Equal("Crab", rows[0]["name"]) 137 | } 138 | 139 | c.AssertFailedRun(t, []string{"migrate", "down", "public", "-version", "v2"}) 140 | c.AssertSchemaMigrationTable(t, "public", "v1") 141 | c.AssertUiErrorOutputContains(t, 142 | `Migration has not been run yet, nothing to rollback`, 143 | ) 144 | } 145 | --------------------------------------------------------------------------------