From 6a664613dbd584b2d863896691f2cef5b09b51fc Mon Sep 17 00:00:00 2001 From: mvvasilev Date: Fri, 25 Oct 2024 23:57:19 +0300 Subject: [PATCH] Initial commit: Add some basic CLI and Environment Var code --- .gitignore | 1 + .vscode/launch.json | 16 ++ README.md | 116 ++++++++++++- go.mod | 3 + main.go | 16 ++ pilgrim/cli.go | 279 ++++++++++++++++++++++++++++++ pilgrim/context.go | 213 +++++++++++++++++++++++ pilgrim/env.go | 95 ++++++++++ pilgrim/error.go | 15 ++ pilgrim/test/cli_test.go | 226 ++++++++++++++++++++++++ pilgrim/test/context_test.go | 139 +++++++++++++++ pilgrim/test/env_test.go | 149 ++++++++++++++++ pilgrim/test/mock/context_mock.go | 207 ++++++++++++++++++++++ pilgrim/test/url_parts_test.go | 66 +++++++ pilgrim/test/utils_test.go | 24 +++ pilgrim/url_parts.go | 142 +++++++++++++++ 16 files changed, 1705 insertions(+), 2 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 go.mod create mode 100644 main.go create mode 100644 pilgrim/cli.go create mode 100644 pilgrim/context.go create mode 100644 pilgrim/env.go create mode 100644 pilgrim/error.go create mode 100644 pilgrim/test/cli_test.go create mode 100644 pilgrim/test/context_test.go create mode 100644 pilgrim/test/env_test.go create mode 100644 pilgrim/test/mock/context_mock.go create mode 100644 pilgrim/test/url_parts_test.go create mode 100644 pilgrim/test/utils_test.go create mode 100644 pilgrim/url_parts.go diff --git a/.gitignore b/.gitignore index 5b90e79..59fffca 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ *.dll *.so *.dylib +target/** # Test binary, built with `go test -c` *.test diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..215a662 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "args": ["--url=\\?test"], + "program": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index bb49e05..1998869 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,115 @@ -# pilgrim +# Pilgrim: An SQL Migration Tool Worth The Pilgrimage -A database migration tool written in golang \ No newline at end of file +## Usage + +```bash +$ pilgrim --url 'mariadb://localhost:3306/database' --up +$ pilgrim --url 'driver://user:pass@host:port/database?arg1=val1&arg2=val2' --dir ./migrations --up +$ pilgrim --url 'postgres://localhost:5432/database' --down '2024-09-27/1_CreateTableABC.sql' +$ pilgrim --driver 'driver' --username 'user' --password 'pass' --host 'host' --port 'port' --segments 'database' --args 'arg1=val1&arg2=val2' +$ pilgrim --validate_migration_order --validate_latest +``` + +### Configuration + +The order of precedence for configured values is as follows: + +1. Values provided in explicit cli arguments ( `--username`, `--password`, `--host`, `--port`, etc. ) +2. Values provided in cli `--url` +3. Values provided in `PILGRIM_*` environment variables + - Values in `PILGRIM_URL` are of lowest precedence and are overridden by their explicit `PILGRIM_*` equivalents + +By default, pilgrim will search for a `.pilgrim` file in the directory it has been run in and will use the values within to replace values that have not been provided in a higher precedence form. + +#### CLI Arguments + +| Argument | Meaning | +|---------------------------------|---------------------------------------------------------------------------------------------------------------------------------| +| `--url` | The database url | +| `--driver` | The database driver ( must be one of `mysql`, `mssql`, `postgres`, `mariadb`, `oracle`, or `sqlite` ) | +| `--username` | The username to use | +| `--password` | The password to use | +| `--host` | The host/ip of the db server | +| `--port` | The port of the db server | +| `--segments` | The segments ( typically the db name ) | +| `--arguments` | The arguments for the db server | +| `--dir` | The directory of the db migrations | +| `--down` | The migration to downgrade to ( conflicts with `--up` ). Defaults to `false` | +| `--up` | The migration to upgrade to ( conflicts with `--down` ). Defaults to `false` | +| `--script` | The migration script to upgrade/downgrade to. Can also be `latest`. ( Defaults to `latest` ) | +| `--schema` | The migration table schema. Must exist prior to running migrations. Default value depends on database driver ( default schema ) | +| `--table` | The migration table name. Default value is "pilgrim_migrations" | +| `--disable_checksum_validation` | Disable checksum validation when running database migration | +| `--strict_ordering` | Enforce strict ordering, pilgrim will run a validation of migration execution order before running the migrations | +| `--validate_migration_order` | (Special) Runs a validation of the migration directory to ensure there is no uncertainty in migration order of execution. | +| `--validate_latest` | (Special) Validate that the provided database contains all migrations in the migration directory | +| `--validate_checksums` | (Special) Validate that all current checksums of migration files match the ones stored in the migration table | + +##### Conflicts between `--down` and `--up` + +If both `--down` and `--up` are provided, `pilgrim` will return an error and will not migrate the database. +The `--script` name provided must adhere to the [migration naming convention and contain the folder it is located in](#migration-naming-convention--folder-structure). + +#### Environment Variables + +| Variable | Meaning | Example | +|-------------------|---------------------------------------------------------------------|-----------------------------------------------------------------------| +| PILGRIM_URL | The database url | PILGRIM_URL=driver://user:pass@host:port/database?arg1=val1&arg2=val2 | +| PILGRIM_DRIVER | The driver for the database | PILGRIM_DRIVER=postgres | +| PILGRIM_USERNAME | The username to use | PILGRIM_USERNAME=user | +| PILGRIM_PASSWORD | The password to use | PILGRIM_PASSWORD=pass | +| PILGRIM_HOST | The host/ip of the db server | PILGRIM_HOST=localhost | +| PILGRIM_PORT | The port of the db server | PILGRIM_PORT=9999 | +| PILGRIM_SEGMENTS | The segments ( typically the db name ) | PILGRIM_SEGMENTS=database; PILGRIM_SEGMENTS=db/seg1 | +| PILGRIM_ARGUMENTS | The arguments for the db server | PILGRIM_ARGUMENTS="arg1=val1&arg2=val2" | +| PILGRIM_DIRECTORY | The directory of the db migrations | PILGRIM_DIRECTORY=./migrations | +| PILGRIM_SCHEMA | The migration table schema. Must exist prior to running migrations. | PILGRIM_SCHEMA="public" | +| PILGRIM_TABLE | The migration table. | PILGRIM_TABLE="pilgrim_migrations" | + +#### Migration Naming Convention & Folder Structure + +`pilgrim` requires that migration files be named uniquely, and can be ordered. To ensure this, migrations +must be sorted by date, and then further within each date-named directory, the contained migration files must begin with a number. +The numbers need not be unique ( you can have multiple scripts starting with `1_`, for example ), but the name of the migration does. + +Example migration directory: + +``` +migrations +├─2024-09-28 +│ ├─1_CreateTableA.sql +│ ├─2_CreateViewA.sql +│ └─3_DropConstraintA.sql +├─2024-10-01 +│ ├─1_CreateTableB.sql +│ └─1_CreateTableC.sql +└─2024-10-12 + └─... +``` + +#### Migration Definition + +Creating a pilgrim migration involves creating a regular SQL file, with some mandatory comments contained within: + +```sql +-- PILGRIM::UP + +CREATE TABLE ... + +-- PILGRIM::DOWN + +DROP TABLE ... +``` + +Everything between `-- PILGRIM::UP` and `-- PILGRIM::DOWN` is run during the `--up` phase of migration. +Everything after `--PILGRIM::DOWN` to the end of the file is run during the `--down` phase of migration. + + +## Development + +This project is built using golang `1.23.1`. + +1. Checkout this repository +2. Run `go build -o target` + +This will build a `pilgrim` binary in the `target` folder. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5acff14 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module mvvasilev.dev/pilgrim + +go 1.23.1 diff --git a/main.go b/main.go new file mode 100644 index 0000000..a513851 --- /dev/null +++ b/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "log" + + "mvvasilev.dev/pilgrim/pilgrim" +) + +func main() { + pilgrimContext := pilgrim.NewPilgrimContext( + pilgrim.CliContext(), + pilgrim.NewEnvVarContext(), + ) + + log.Println(pilgrimContext) +} diff --git a/pilgrim/cli.go b/pilgrim/cli.go new file mode 100644 index 0000000..567feee --- /dev/null +++ b/pilgrim/cli.go @@ -0,0 +1,279 @@ +package pilgrim + +import ( + "flag" + "sync" +) + +const ( + Flag_Url string = "url" + Flag_Driver string = "driver" + Flag_Username string = "username" + Flag_Password string = "password" + Flag_Host string = "host" + Flag_Port string = "port" + Flag_Segments string = "segments" + Flag_Args string = "args" + Flag_Directory string = "dir" + Flag_Up string = "up" + Flag_Down string = "down" + Flag_Script string = "script" + Flag_Schema string = "schema" + Flag_Table string = "table" + Flag_DisableChecksumValidation string = "disable_checksum_validation" + Flag_StrictOrdering string = "strict_ordering" + Flag_ValidateMigrationOrder string = "validate_migration_order" + Flag_ValidateLatest string = "validate_latest" + Flag_ValidateChecksums string = "validate_checksums" +) + +type CliFlagRetriever interface { + Url() (value string, isProvided bool) + Driver() (value DbDriver, isProvided bool) + Username() (value string, isProvided bool) + Password() (value string, isProvided bool) + Host() (value string, isProvided bool) + Port() (value string, isProvided bool) + Segments() (value []string, isProvided bool) + Args() (value map[string]string, isProvided bool) + Directory() (value string, isProvided bool) + IsUp() (value bool, isProvided bool) + IsDown() (value bool, isProvided bool) + // Can be a script name, or "latest" + Script() (value string, isProvided bool) + MigrationTable() (value string, isProvided bool) + MigrationTableSchema() (value string, isProvided bool) + IsChecksumValidationEnabled() (value bool, isProvided bool) + IsStrictOrderingEnabled() (value bool, isProvided bool) + IsValidatingMigrationOrder() (value bool, isProvided bool) + IsValidatingLatest() (value bool, isProvided bool) + IsValidatingChecksums() (value bool, isProvided bool) + + ParseFlags() +} + +var ctxSingleton *cliContext +var once sync.Once + +type cliContext struct { + isProvided map[string]bool + + url, + driver, + username, + password, + host, + port, + segments, + args, + directory, + script, + migrationTable, + migrationTableSchema *string + + up, + down, + strictOrdering, + disableChecksumValidation, + validateChecksums, + validateMigrationOrder, + validateLatest *bool +} + +// Creates the cliContext singleton +// Using any of the flags here requires a call to `ParseFlags` +func CliContext() *cliContext { + once.Do(func() { + ctxSingleton = &cliContext{ + url: flag.String(Flag_Url, EmptyString, "://[[username]:[password]@]:[/database[?arg1=value[&arg2=value ...]]]"), + driver: flag.String(Flag_Driver, EmptyString, "The database driver ( mysql, postgres, sqlite, mariadb, oracle, mssql )"), + username: flag.String(Flag_Username, EmptyString, "The user"), + password: flag.String(Flag_Password, EmptyString, "Password for user"), + host: flag.String(Flag_Host, EmptyString, "The database host"), + port: flag.String(Flag_Port, EmptyString, "The database port"), + segments: flag.String(Flag_Segments, EmptyString, "The segments ( often the database name, in format \"segment1/segment2\" )"), + args: flag.String(Flag_Args, EmptyString, "Additional arguments to provide the database connection ( in format 'arg1=value&arg2=value' )"), + directory: flag.String(Flag_Directory, "./migrations", "The migration directory"), + script: flag.String(Flag_Script, "latest", "The script to upgrade/downgrade to."), + migrationTable: flag.String(Flag_Table, "pilgrim_migrations", "The database table to store migration information. WARNING: Changing this after running migrations once WILL NOT automagically move migration metadata from one table to another and will run all migrations from scratch."), + migrationTableSchema: flag.String(Flag_Schema, "", "The schema the database table belongs to. Default depends on driver. See WARNING of migration table."), + + up: flag.Bool(Flag_Up, false, "Upgrade the database."), + down: flag.Bool(Flag_Down, false, "Upgrade the database."), + strictOrdering: flag.Bool(Flag_StrictOrdering, false, "Enforce strict ordering, pilgrim will run a validation of migration execution order before running the migrations"), + disableChecksumValidation: flag.Bool(Flag_DisableChecksumValidation, false, "Disable checksum validation when running database migration"), + + validateChecksums: flag.Bool(Flag_ValidateChecksums, false, "(Special) Validate that all current checksums of migration files match the ones stored in the migration table"), + validateMigrationOrder: flag.Bool(Flag_ValidateMigrationOrder, false, "(Special) Runs a validation of the migration directory to ensure there is no uncertainty in migration order of execution."), + validateLatest: flag.Bool(Flag_ValidateLatest, false, "(Special) Validate that the provided database contains all migrations in the migration directory"), + + isProvided: make(map[string]bool), + } + }) + + return ctxSingleton +} + +func (cli *cliContext) ParseFlags() { + flag.Parse() + + flag.Visit(func(f *flag.Flag) { + cli.isProvided[f.Name] = true + }) +} + +func (cli *cliContext) Url() (value string, isProvided bool) { + return cli.fetchStringFlagValue(Flag_Url) +} + +func (cli *cliContext) Driver() (value DbDriver, isProvided bool) { + val, provided := cli.fetchStringFlagValue(Flag_Driver) + + return DbDriver(val), provided +} + +func (cli *cliContext) Username() (value string, isProvided bool) { + return cli.fetchStringFlagValue(Flag_Username) +} + +func (cli *cliContext) Password() (value string, isProvided bool) { + return cli.fetchStringFlagValue(Flag_Password) +} + +func (cli *cliContext) Host() (value string, isProvided bool) { + return cli.fetchStringFlagValue(Flag_Host) +} + +func (cli *cliContext) Port() (value string, isProvided bool) { + return cli.fetchStringFlagValue(Flag_Port) +} + +func (cli *cliContext) Segments() (value []string, isProvided bool) { + rawVal, provided := cli.fetchStringFlagValue(Flag_Segments) + + if rawVal == EmptyString { + return []string{}, false + } + + return ParseSegments(rawVal), provided +} + +func (cli *cliContext) Args() (value map[string]string, isProvided bool) { + rawVal, provided := cli.fetchStringFlagValue(Flag_Args) + + if rawVal == EmptyString { + return make(map[string]string), false + } + + return ParseArguments(rawVal), provided +} + +func (cli *cliContext) Directory() (value string, isProvided bool) { + return cli.fetchStringFlagValue(Flag_Directory) +} + +func (cli *cliContext) IsUp() (value bool, isProvided bool) { + return cli.fetchBoolFlagValue(Flag_Up) +} + +func (cli *cliContext) IsDown() (value bool, isProvided bool) { + return cli.fetchBoolFlagValue(Flag_Down) +} + +// Can be a script name, or "latest" +func (cli *cliContext) Script() (value string, isProvided bool) { + return cli.fetchStringFlagValue(Flag_Script) +} + +func (cli *cliContext) MigrationTable() (value string, isProvided bool) { + return cli.fetchStringFlagValue(Flag_Table) +} + +func (cli *cliContext) MigrationTableSchema() (value string, isProvided bool) { + return cli.fetchStringFlagValue(Flag_Schema) +} + +func (cli *cliContext) IsChecksumValidationEnabled() (value bool, isProvided bool) { + disabled, provided := cli.fetchBoolFlagValue(Flag_DisableChecksumValidation) + return !disabled, provided +} + +func (cli *cliContext) IsStrictOrderingEnabled() (value bool, isProvided bool) { + return cli.fetchBoolFlagValue(Flag_StrictOrdering) +} + +func (cli *cliContext) IsValidatingMigrationOrder() (value bool, isProvided bool) { + return cli.fetchBoolFlagValue(Flag_ValidateMigrationOrder) +} + +func (cli *cliContext) IsValidatingLatest() (value bool, isProvided bool) { + return cli.fetchBoolFlagValue(Flag_ValidateLatest) +} + +func (cli *cliContext) IsValidatingChecksums() (value bool, isProvided bool) { + return cli.fetchBoolFlagValue(Flag_ValidateChecksums) +} + +func (cli *cliContext) fetchStringFlagValue(f string) (value string, isProvided bool) { + var valPtr *string = nil + + switch f { + case Flag_Url: + valPtr = cli.url + case Flag_Driver: + valPtr = cli.driver + case Flag_Username: + valPtr = cli.username + case Flag_Password: + valPtr = cli.password + case Flag_Host: + valPtr = cli.host + case Flag_Port: + valPtr = cli.port + case Flag_Segments: + valPtr = cli.segments + case Flag_Args: + valPtr = cli.args + case Flag_Directory: + valPtr = cli.directory + case Flag_Script: + valPtr = cli.script + case Flag_Schema: + valPtr = cli.migrationTableSchema + case Flag_Table: + valPtr = cli.migrationTable + } + + if valPtr == nil { + return EmptyString, cli.isProvided[f] + } + + return *valPtr, cli.isProvided[f] +} + +func (cli *cliContext) fetchBoolFlagValue(f string) (value bool, isProvided bool) { + var valPtr *bool = nil + + switch f { + case Flag_Up: + valPtr = cli.up + case Flag_Down: + valPtr = cli.down + case Flag_StrictOrdering: + valPtr = cli.strictOrdering + case Flag_DisableChecksumValidation: + valPtr = cli.disableChecksumValidation + case Flag_ValidateChecksums: + valPtr = cli.validateChecksums + case Flag_ValidateMigrationOrder: + valPtr = cli.validateMigrationOrder + case Flag_ValidateLatest: + valPtr = cli.validateLatest + } + + if valPtr == nil { + return false, cli.isProvided[f] + } + + return *valPtr, cli.isProvided[f] +} diff --git a/pilgrim/context.go b/pilgrim/context.go new file mode 100644 index 0000000..3d88548 --- /dev/null +++ b/pilgrim/context.go @@ -0,0 +1,213 @@ +package pilgrim + +import ( + "log" +) + +const DefaultMigrationDirectory = "./migrations" +const DefaultMigrationTable = "pilgrim_migrations" + +type MigrationPhase bool + +const ( + MigrationPhase_Up MigrationPhase = true + MigrationPhase_Down = false +) + +type PilgrimConfigurationRetriever interface { + UrlParts() UrlParts + MigrationDirectory() string + MigrationPhase() MigrationPhase + MigrationScript() string + MigrationStrictOrdering() bool + MigrationIsChecksumValidationEnabled() bool + MigrationTable() string + MigrationSchema() string + + ValidateChecksums() bool + ValidateLatest() bool + ValidateMigrationOrder() bool +} + +type pilgrimContext struct { + urlParts UrlParts + migrationDirectory string + migrationPhase MigrationPhase + migrationScript string + migrationStrictOrdering bool + migrationIsChecksumValidationEnabled bool + migrationSchema string + migrationTable string + + validateMigrationOrder bool + validateLatest bool + validateChecksums bool +} + +func NewPilgrimContext(cli CliFlagRetriever, env EnvVarRetriever) *pilgrimContext { + + cliUrl, _ := cli.Url() + cliUrlParts := ParseDatabaseUrl(cliUrl) + envUrl, _ := env.Url() + envUrlParts := ParseDatabaseUrl(envUrl) + + urlParts := UrlParts{ + Driver: pickFirstAvailable( + cli.Driver, + func() (DbDriver, bool) { return DbDriver(cliUrlParts.Driver), cliUrlParts.Driver != EmptyString }, + env.Driver, + func() (DbDriver, bool) { return DbDriver(envUrlParts.Driver), envUrlParts.Driver != EmptyString }, + ), + Username: pickFirstAvailable( + cli.Username, + func() (string, bool) { return cliUrlParts.Username, cliUrlParts.Username != EmptyString }, + env.Username, + func() (string, bool) { return envUrlParts.Username, envUrlParts.Username != EmptyString }, + ), + Password: pickFirstAvailable( + cli.Password, + func() (string, bool) { return cliUrlParts.Password, cliUrlParts.Password != EmptyString }, + env.Password, + func() (string, bool) { return envUrlParts.Password, envUrlParts.Password != EmptyString }, + ), + Host: pickFirstAvailable( + cli.Host, + func() (string, bool) { return cliUrlParts.Host, cliUrlParts.Host != EmptyString }, + env.Host, + func() (string, bool) { return envUrlParts.Host, envUrlParts.Host != EmptyString }, + ), + Port: pickFirstAvailable( + cli.Port, + func() (string, bool) { return cliUrlParts.Port, cliUrlParts.Port != EmptyString }, + env.Port, + func() (string, bool) { return envUrlParts.Port, envUrlParts.Port != EmptyString }, + ), + Segments: pickFirstAvailable( + cli.Segments, + func() ([]string, bool) { return cliUrlParts.Segments, cliUrlParts.Segments != nil }, + env.Segments, + func() ([]string, bool) { return envUrlParts.Segments, envUrlParts.Segments != nil }, + ), + Arguments: pickFirstAvailable( + cli.Args, + func() (map[string]string, bool) { return cliUrlParts.Arguments, cliUrlParts.Arguments != nil }, + env.Args, + func() (map[string]string, bool) { return envUrlParts.Arguments, envUrlParts.Arguments != nil }, + ), + } + + isValid, err := urlParts.Validate() + + if !isValid { + log.Fatalln(err) + } + + migrationPhase, migrationScript, err := determineMigrationPhaseAndScript(cli) + + migrationDirectory := pickFirstAvailable(cli.Directory, env.Directory, func() (string, bool) { return DefaultMigrationDirectory, true }) + migrationSchema := pickFirstAvailable(cli.MigrationTableSchema, env.MigrationTableSchema, pickFirstDefault(EmptyString)) // Default depends on driver, set empty for now + migrationTable := pickFirstAvailable(cli.MigrationTable, env.MigrationTable, pickFirstDefault(DefaultMigrationTable)) + + migrationStrictOrdering, _ := cli.IsStrictOrderingEnabled() + migrationIsChecksumValidationEnabled, _ := cli.IsChecksumValidationEnabled() + + validateMigrationOrder, _ := cli.IsValidatingMigrationOrder() + validateLatest, _ := cli.IsValidatingLatest() + validateChecksums, _ := cli.IsValidatingLatest() + + return &pilgrimContext{ + urlParts, + migrationDirectory, + migrationPhase, + migrationScript, + migrationStrictOrdering, + migrationIsChecksumValidationEnabled, + migrationSchema, + migrationTable, + validateMigrationOrder, + validateLatest, + validateChecksums, + } +} + +func determineMigrationPhaseAndScript(cli CliFlagRetriever) (phase MigrationPhase, script string, err error) { + script, _ = cli.Script() + + isUp, isUpProvided := cli.IsUp() + _, isDownProvided := cli.IsDown() + + if (!isUpProvided && !isDownProvided) || (isUpProvided && isDownProvided) { + return MigrationPhase_Down, EmptyString, PilgrimInvalidError("Must provide either --up or --down, but not both.") + } + + if isUp { + phase = MigrationPhase_Up + } else { + phase = MigrationPhase_Down + } + + return +} + +func pickFirstDefault[T any](defaultVal T) func() (value T, provided bool) { + return func() (value T, provided bool) { + return defaultVal, true + } +} + +func pickFirstAvailable[T any](valueProviders ...func() (value T, provided bool)) (value T) { + for _, getValue := range valueProviders { + val, provided := getValue() + + if provided { + return val + } + } + + value, _ = valueProviders[len(valueProviders)-1]() + return +} + +func (c *pilgrimContext) UrlParts() UrlParts { + return c.urlParts +} + +func (c *pilgrimContext) MigrationDirectory() string { + return c.migrationDirectory +} + +func (c *pilgrimContext) MigrationPhase() MigrationPhase { + return MigrationPhase(c.migrationPhase) +} + +func (c *pilgrimContext) MigrationScript() string { + return c.migrationScript +} + +func (c *pilgrimContext) MigrationStrictOrdering() bool { + return c.migrationStrictOrdering +} + +func (c *pilgrimContext) MigrationIsChecksumValidationEnabled() bool { + return c.migrationIsChecksumValidationEnabled +} + +func (c *pilgrimContext) MigrationSchema() string { + return c.migrationSchema +} + +func (c *pilgrimContext) MigrationTable() string { + return c.migrationTable +} + +func (c *pilgrimContext) ValidateChecksums() bool { + return c.validateChecksums +} + +func (c *pilgrimContext) ValidateMigrationOrder() bool { + return c.validateMigrationOrder +} + +func (c *pilgrimContext) ValidateLatest() bool { + return c.validateLatest +} diff --git a/pilgrim/env.go b/pilgrim/env.go new file mode 100644 index 0000000..fe2edf5 --- /dev/null +++ b/pilgrim/env.go @@ -0,0 +1,95 @@ +package pilgrim + +import "os" + +const ( + EnvVarKey_Url string = "PILGRIM_URL" + EnvVarKey_Driver = "PILGRIM_DRIVER" + EnvVarKey_Username = "PILGRIM_USERNAME" + EnvVarKey_Password = "PILGRIM_PASSWORD" + EnvVarKey_Host = "PILGRIM_HOST" + EnvVarKey_Port = "PILGRIM_PORT" + EnvVarKey_Segments = "PILGRIM_SEGMENTS" + EnvVarKey_Arguments = "PILGRIM_ARGUMENTS" + EnvVarKey_Directory = "PILGRIM_DIRECTORY" + EnvVarKey_Schema = "PILGRIM_SCHEMA" + EnvVarKey_Table = "PILGRIM_TABLE" +) + +type EnvVarRetriever interface { + Url() (value string, isDefined bool) + Driver() (value DbDriver, isDefined bool) + Username() (value string, isDefined bool) + Password() (value string, isDefined bool) + Host() (value string, isDefined bool) + Port() (value string, isDefined bool) + Segments() (value []string, isDefined bool) + Args() (value map[string]string, isDefined bool) + Directory() (value string, isDefined bool) + MigrationTable() (value string, isDefined bool) + MigrationTableSchema() (value string, isDefined bool) +} + +type envVarContext struct { +} + +func NewEnvVarContext() *envVarContext { + return &envVarContext{} +} + +func (ec *envVarContext) Url() (value string, isDefined bool) { + return os.LookupEnv(EnvVarKey_Url) +} + +func (ec *envVarContext) Driver() (value DbDriver, isDefined bool) { + val, provided := os.LookupEnv(EnvVarKey_Driver) + return DbDriver(val), provided +} + +func (ec *envVarContext) Username() (value string, isDefined bool) { + return os.LookupEnv(EnvVarKey_Username) +} + +func (ec *envVarContext) Password() (value string, isDefined bool) { + return os.LookupEnv(EnvVarKey_Password) +} + +func (ec *envVarContext) Host() (value string, isDefined bool) { + return os.LookupEnv(EnvVarKey_Host) +} + +func (ec *envVarContext) Port() (value string, isDefined bool) { + return os.LookupEnv(EnvVarKey_Port) +} + +func (ec *envVarContext) Segments() (value []string, isDefined bool) { + rawVal, provided := os.LookupEnv(EnvVarKey_Segments) + + if !provided { + return []string{}, false + } + + return ParseSegments(rawVal), true +} + +func (ec *envVarContext) Args() (value map[string]string, isDefined bool) { + rawVal, provided := os.LookupEnv(EnvVarKey_Arguments) + + if !provided { + return make(map[string]string), false + } + + return ParseArguments(rawVal), true +} + +func (ec *envVarContext) Directory() (value string, isDefined bool) { + return os.LookupEnv(EnvVarKey_Directory) +} + +func (ec *envVarContext) MigrationTable() (value string, isDefined bool) { + return os.LookupEnv(EnvVarKey_Table) +} + +func (ec *envVarContext) MigrationTableSchema() (value string, isDefined bool) { + return os.LookupEnv(EnvVarKey_Schema) +} diff --git a/pilgrim/error.go b/pilgrim/error.go new file mode 100644 index 0000000..41fb18a --- /dev/null +++ b/pilgrim/error.go @@ -0,0 +1,15 @@ +package pilgrim + +type pilgrimValidationError struct { + validationError string +} + +func PilgrimInvalidError(err string) *pilgrimValidationError { + return &pilgrimValidationError{ + validationError: err, + } +} + +func (pve *pilgrimValidationError) Error() string { + return pve.validationError +} diff --git a/pilgrim/test/cli_test.go b/pilgrim/test/cli_test.go new file mode 100644 index 0000000..a9bb7c7 --- /dev/null +++ b/pilgrim/test/cli_test.go @@ -0,0 +1,226 @@ +package pilgrim_test + +import ( + "flag" + "fmt" + "maps" + "slices" + "testing" + + "mvvasilev.dev/pilgrim/pilgrim" +) + +func Test_CliContext_Url(t *testing.T) { + test_cliValue( + t, + func(cli pilgrim.CliFlagRetriever) (string, bool) { return cli.Url() }, + pilgrim.Flag_Url, + "driver://localhost:3306", + ) +} + +func Test_CliContext_Driver(t *testing.T) { + test_cliValue( + t, + func(cli pilgrim.CliFlagRetriever) (pilgrim.DbDriver, bool) { return cli.Driver() }, + pilgrim.Flag_Driver, + "driver", + ) +} + +func Test_CliContext_Username(t *testing.T) { + test_cliValue( + t, + func(cli pilgrim.CliFlagRetriever) (string, bool) { return cli.Username() }, + pilgrim.Flag_Username, + "username", + ) +} + +func Test_CliContext_Password(t *testing.T) { + test_cliValue( + t, + func(cli pilgrim.CliFlagRetriever) (string, bool) { return cli.Password() }, + pilgrim.Flag_Password, + "password", + ) +} + +func Test_CliContext_Host(t *testing.T) { + test_cliValue( + t, + func(cli pilgrim.CliFlagRetriever) (string, bool) { return cli.Host() }, + pilgrim.Flag_Host, + "localhost", + ) +} + +func Test_CliContext_Port(t *testing.T) { + test_cliValue( + t, + func(cli pilgrim.CliFlagRetriever) (string, bool) { return cli.Port() }, + pilgrim.Flag_Port, + "3306", + ) +} + +func Test_CliContext_Segments(t *testing.T) { + expectedValue := []string{ + "segment1", "segment2", "segment3", + } + + flag.Set(pilgrim.Flag_Segments, "segment1/segment2/segment3") + + value, _ := pilgrim.CliContext().Segments() + + Assert( + t, + slices.Equal(value, expectedValue), + "Cli value '", pilgrim.Flag_Segments, "' differs from expected.", "Value:", value, "Expected:", expectedValue, + ) +} + +func Test_CliContext_Args(t *testing.T) { + expectedValue := map[string]string{ + "arg1": "val1", + "arg2": "val2", + "arg3": "val3", + } + + flag.Set(pilgrim.Flag_Args, "arg1=val1&arg2=val2&arg3=val3") + + value, _ := pilgrim.CliContext().Args() + + Assert( + t, + maps.Equal(value, expectedValue), + "Cli value '", pilgrim.Flag_Args, "' differs from expected.", "Value:", value, "Expected:", expectedValue, + ) +} + +func Test_CliContext_Directory(t *testing.T) { + test_cliValue( + t, + func(cli pilgrim.CliFlagRetriever) (string, bool) { return cli.Directory() }, + pilgrim.Flag_Directory, + "./custom-migrations", + ) +} + +func Test_CliContext_Script(t *testing.T) { + test_cliValue( + t, + func(cli pilgrim.CliFlagRetriever) (string, bool) { return cli.Script() }, + pilgrim.Flag_Script, + "2024-10-13/1_SomeScript.sql", + ) +} + +func Test_CliContext_MigrationTable(t *testing.T) { + test_cliValue( + t, + func(cli pilgrim.CliFlagRetriever) (string, bool) { return cli.MigrationTable() }, + pilgrim.Flag_Table, + "CustomMigrationsTable", + ) +} + +func Test_CliContext_MigrationTableSchema(t *testing.T) { + test_cliValue( + t, + func(cli pilgrim.CliFlagRetriever) (string, bool) { + return pilgrim.CliContext().MigrationTableSchema() + }, + pilgrim.Flag_Schema, + "public", + ) +} + +func Test_CliContext_Up(t *testing.T) { + test_cliValue( + t, + func(cli pilgrim.CliFlagRetriever) (bool, bool) { return cli.IsUp() }, + pilgrim.Flag_Up, + true, + ) +} + +func Test_CliContext_Down(t *testing.T) { + test_cliValue( + t, + func(cli pilgrim.CliFlagRetriever) (bool, bool) { return cli.IsDown() }, + pilgrim.Flag_Down, + true, + ) +} +func Test_CliContext_StrictOrdering(t *testing.T) { + test_cliValue( + t, + func(cli pilgrim.CliFlagRetriever) (bool, bool) { + return pilgrim.CliContext().IsStrictOrderingEnabled() + }, + pilgrim.Flag_StrictOrdering, + true, + ) +} + +func Test_CliContext_DisableChecksumValidation(t *testing.T) { + expectedValue := false + + flag.Set(pilgrim.Flag_DisableChecksumValidation, "true") + + value, _ := pilgrim.CliContext().IsChecksumValidationEnabled() + + Assert( + t, + value == expectedValue, + "Cli value '", pilgrim.Flag_DisableChecksumValidation, "' differs from expected.", "Value:", value, "Expected:", expectedValue, + ) +} + +func Test_CliContext_ValidateChecksums(t *testing.T) { + test_cliValue( + t, + func(cli pilgrim.CliFlagRetriever) (bool, bool) { + return cli.IsValidatingChecksums() + }, + pilgrim.Flag_ValidateChecksums, + true, + ) +} + +func Test_CliContext_ValidateMigrationOrder(t *testing.T) { + test_cliValue( + t, + func(cli pilgrim.CliFlagRetriever) (bool, bool) { + return cli.IsValidatingMigrationOrder() + }, + pilgrim.Flag_ValidateMigrationOrder, + true, + ) +} + +func Test_CliContext_ValidateLatest(t *testing.T) { + test_cliValue( + t, + func(cli pilgrim.CliFlagRetriever) (bool, bool) { return cli.IsValidatingLatest() }, + pilgrim.Flag_ValidateLatest, + true, + ) +} + +func test_cliValue[T string | pilgrim.DbDriver | bool](t *testing.T, cliVal func(cli pilgrim.CliFlagRetriever) (value T, isProvided bool), flagName string, expectedVal T) { + cli := pilgrim.CliContext() + + flag.Set(flagName, fmt.Sprintf("%v", expectedVal)) + + cli.ParseFlags() + + value, _ := cliVal(cli) + + Assert( + t, + value == expectedVal, + "Cli value '", flagName, "' differs from expected.", "Value:", value, "Expected:", expectedVal, + ) +} diff --git a/pilgrim/test/context_test.go b/pilgrim/test/context_test.go new file mode 100644 index 0000000..5b514ee --- /dev/null +++ b/pilgrim/test/context_test.go @@ -0,0 +1,139 @@ +package pilgrim_test + +import ( + "testing" + + "mvvasilev.dev/pilgrim/pilgrim" + pilgrim_mock "mvvasilev.dev/pilgrim/pilgrim/test/mock" +) + +const CliUrlValue = "postgres://cli_host:5432/cli_segment1/cli_segment2?cli_arg1=cli_val1&cli_arg2=cli_val2" +const CliDriverValue = "postgres" + +func createPilgrimContextWithDependencies( + cliFlags map[string]string, + envVars map[string]string, +) ( + pilgrimContext pilgrim.PilgrimConfigurationRetriever, + cliContext pilgrim.CliFlagRetriever, + envContext pilgrim.EnvVarRetriever, +) { + cliStringFlagFuncs := map[string]func() (value string, isProvided bool){} + + for k, v := range cliFlags { + cliStringFlagFuncs[k] = func() (value string, isProvided bool) { + return v, true + } + } + + cliContext = pilgrim_mock.MockCliContext( + cliStringFlagFuncs, + make(map[string]func() (value bool, isProvided bool)), + func() (value []string, isProvided bool) { return []string{}, false }, + func() (value map[string]string, isProvided bool) { return map[string]string{}, false }, + ) + + envVarStringFuncs := map[string]func() (value string, isProvided bool){} + + for k, v := range envVars { + envVarStringFuncs[k] = func() (value string, isProvided bool) { + return v, true + } + } + + envContext = pilgrim_mock.MockEnvVarContext( + envVarStringFuncs, + make(map[string]func() (value bool, isProvided bool)), + func() (value []string, isProvided bool) { return []string{}, false }, + func() (value map[string]string, isProvided bool) { return map[string]string{}, false }, + ) + + pilgrimContext = pilgrim.NewPilgrimContext(cliContext, envContext) + + return +} + +func Test_PilgrimContext_CliDriverOverridesCliUrlDriver(t *testing.T) { + context, _, _ := createPilgrimContextWithDependencies( + map[string]string{ + pilgrim.Flag_Url: CliUrlValue, + pilgrim.Flag_Driver: CliDriverValue, + }, + map[string]string{}, + ) + + AssertEquals(t, context.UrlParts().Driver, CliDriverValue) +} + +func Test_PilgrimContext_EnvDriverOverridesEnvUrlDriver(t *testing.T) { + context, _, _ := createPilgrimContextWithDependencies( + map[string]string{}, + map[string]string{ + pilgrim.EnvVarKey_Url: "mariadb://env_host:3306/env_segment1/env_segment2?env_arg1=env_val1&env_arg2=env_val2", + pilgrim.EnvVarKey_Driver: "postgres", + }, + ) + + AssertEquals(t, context.UrlParts().Driver, "postgres") +} + +func Test_PilgrimContext_CliUrlDriverOverridesEnvDriver(t *testing.T) { + context, _, _ := createPilgrimContextWithDependencies( + map[string]string{ + pilgrim.Flag_Url: CliUrlValue, + }, + map[string]string{ + pilgrim.EnvVarKey_Driver: "mariadb", + }, + ) + + AssertEquals(t, context.UrlParts().Driver, CliDriverValue) +} + +func Test_PilgrimContext_CliDriverOverridesEnvDriver(t *testing.T) { + context, _, _ := createPilgrimContextWithDependencies( + map[string]string{ + pilgrim.Flag_Driver: CliDriverValue, + }, + map[string]string{ + pilgrim.EnvVarKey_Driver: "mariadb", + }, + ) + + AssertEquals(t, context.UrlParts().Driver, CliDriverValue) +} + +func Test_PilgrimContext_EnvDriverIsUsedIfNoCliDriverProvided(t *testing.T) { + context, _, _ := createPilgrimContextWithDependencies( + map[string]string{}, + map[string]string{ + pilgrim.EnvVarKey_Driver: "mariadb", + }, + ) + + AssertEquals(t, context.UrlParts().Driver, "mariadb") +} + +func Test_PilgrimContext_EnvUrlDriverIsUsedIfNoCliDriverProvided(t *testing.T) { + context, _, _ := createPilgrimContextWithDependencies( + map[string]string{}, + map[string]string{ + pilgrim.EnvVarKey_Url: "mariadb://env_host:3306/env_segment1/env_segment2?env_arg1=env_val1&env_arg2=env_val2", + }, + ) + + AssertEquals(t, context.UrlParts().Driver, "mariadb") +} + +func Test_PilgrimContext_EnvUrlDriverIsNotUsedIfNoCliDriverOverrideProvided(t *testing.T) { + context, _, _ := createPilgrimContextWithDependencies( + map[string]string{ + pilgrim.Flag_Driver: "postgres", + }, + map[string]string{ + pilgrim.EnvVarKey_Url: "mariadb://env_host:3306/env_segment1/env_segment2?env_arg1=env_val1&env_arg2=env_val2", + }, + ) + + AssertEquals(t, context.UrlParts().Driver, "postgres") +} diff --git a/pilgrim/test/env_test.go b/pilgrim/test/env_test.go new file mode 100644 index 0000000..255cedd --- /dev/null +++ b/pilgrim/test/env_test.go @@ -0,0 +1,149 @@ +package pilgrim_test + +import ( + "maps" + "os" + "slices" + "testing" + + "mvvasilev.dev/pilgrim/pilgrim" +) + +func test_envVarContext_StringValue( + t *testing.T, + envVarKey string, + getEnvVar func() (value string, isDefined bool), + expectedVal string, +) { + os.Setenv(envVarKey, expectedVal) + + val, isDefined := getEnvVar() + + Assert(t, isDefined, "Env var '", envVarKey, "' was not defined when it should have been") + Assert( + t, + val == expectedVal, + "Env var value '", envVarKey, "' differs from expected.", "Value:", val, "Expected:", expectedVal, + ) +} + +func Test_envVarContext_Url(t *testing.T) { + test_envVarContext_StringValue( + t, + pilgrim.EnvVarKey_Url, + func() (string, bool) { return pilgrim.NewEnvVarContext().Url() }, + "driver://user:pass@host:port/segment1/segment2?arg1=val1&arg2=val2", + ) +} + +func Test_envVarContext_Driver(t *testing.T) { + expectedVal := pilgrim.DbDriver_MariaDB + + os.Setenv(pilgrim.EnvVarKey_Driver, string(pilgrim.DbDriver_MariaDB)) + + val, isDefined := pilgrim.NewEnvVarContext().Driver() + + Assert(t, isDefined, "Env var '", pilgrim.EnvVarKey_Driver, "' was not defined when it should have been") + Assert( + t, + val == expectedVal, + "Env var value '", pilgrim.EnvVarKey_Driver, "' differs from expected.", "Value:", val, "Expected:", expectedVal, + ) +} + +func Test_envVarContext_Username(t *testing.T) { + test_envVarContext_StringValue( + t, + pilgrim.EnvVarKey_Username, + func() (string, bool) { return pilgrim.NewEnvVarContext().Username() }, + "username", + ) +} + +func Test_envVarContext_Password(t *testing.T) { + test_envVarContext_StringValue( + t, + pilgrim.EnvVarKey_Password, + func() (string, bool) { return pilgrim.NewEnvVarContext().Password() }, + "password", + ) +} + +func Test_envVarContext_Host(t *testing.T) { + test_envVarContext_StringValue( + t, + pilgrim.EnvVarKey_Host, + func() (string, bool) { return pilgrim.NewEnvVarContext().Host() }, + "localhost", + ) +} + +func Test_envVarContext_Port(t *testing.T) { + test_envVarContext_StringValue( + t, + pilgrim.EnvVarKey_Port, + func() (string, bool) { return pilgrim.NewEnvVarContext().Port() }, + "3306", + ) +} + +func Test_envVarContext_Directory(t *testing.T) { + test_envVarContext_StringValue( + t, + pilgrim.EnvVarKey_Directory, + func() (string, bool) { return pilgrim.NewEnvVarContext().Directory() }, + "./custom-directory/migrations", + ) +} + +func Test_envVarContext_MigrationTable(t *testing.T) { + test_envVarContext_StringValue( + t, + pilgrim.EnvVarKey_Table, + func() (string, bool) { return pilgrim.NewEnvVarContext().MigrationTable() }, + "pilgrim_migrations_custom_table", + ) +} + +func Test_envVarContext_MigrationTableSchema(t *testing.T) { + test_envVarContext_StringValue( + t, + pilgrim.EnvVarKey_Schema, + func() (string, bool) { return pilgrim.NewEnvVarContext().MigrationTableSchema() }, + "public", + ) +} + +func Test_envVarContext_Segments(t *testing.T) { + expectedVal := []string{"segment1", "segment2", "segment3"} + + os.Setenv(pilgrim.EnvVarKey_Segments, "segment1/segment2/segment3") + + val, isDefined := pilgrim.NewEnvVarContext().Segments() + + Assert(t, isDefined, "Env var '", pilgrim.EnvVarKey_Segments, "' was not defined when it should have been") + Assert( + t, + slices.Equal(expectedVal, val), + "Env var value '", pilgrim.EnvVarKey_Segments, "' differs from expected.", "Value:", val, "Expected:", expectedVal, + ) +} + +func Test_envVarContext_Args(t *testing.T) { + expectedVal := map[string]string{ + "arg1": "val1", + "arg2": "val2", + "arg3": "val3", + } + + os.Setenv(pilgrim.EnvVarKey_Arguments, "arg1=val1&arg2=val2&arg3=val3") + + val, isDefined := pilgrim.NewEnvVarContext().Args() + + Assert(t, isDefined, "Env var '", pilgrim.EnvVarKey_Arguments, "' was not defined when it should have been") + Assert( + t, + maps.Equal(expectedVal, val), + "Env var value '", pilgrim.EnvVarKey_Arguments, "' differs from expected.", "Value:", val, "Expected:", expectedVal, + ) +} diff --git a/pilgrim/test/mock/context_mock.go b/pilgrim/test/mock/context_mock.go new file mode 100644 index 0000000..b7b000e --- /dev/null +++ b/pilgrim/test/mock/context_mock.go @@ -0,0 +1,207 @@ +package pilgrim_mock + +import "mvvasilev.dev/pilgrim/pilgrim" + +type cliContextMock struct { + stringFlags map[string]func() (value string, isProvided bool) + boolFlags map[string]func() (value bool, isProvided bool) + segments func() (value []string, isProvided bool) + args func() (value map[string]string, isProvided bool) +} + +func MockCliContext( + stringFlags map[string]func() (value string, isProvided bool), + boolFlags map[string]func() (value bool, isProvided bool), + segments func() (value []string, isProvided bool), + args func() (value map[string]string, isProvided bool), +) *cliContextMock { + return &cliContextMock{ + stringFlags, boolFlags, segments, args, + } +} + +func (mock *cliContextMock) stringVal(val string) (value string, isProvided bool) { + retrieverFunc, available := mock.stringFlags[val] + + if !available { + return pilgrim.EmptyString, false + } + + return retrieverFunc() +} + +func (mock *cliContextMock) boolVal(val string) (value bool, isProvided bool) { + retrieverFunc, available := mock.boolFlags[val] + + if !available { + return false, false + } + + return retrieverFunc() +} + +func (mock *cliContextMock) Url() (value string, isProvided bool) { + return mock.stringVal(pilgrim.Flag_Url) +} + +func (mock *cliContextMock) Driver() (value pilgrim.DbDriver, isProvided bool) { + val, provided := mock.stringVal(pilgrim.Flag_Driver) + return pilgrim.DbDriver(val), provided +} + +func (mock *cliContextMock) Username() (value string, isProvided bool) { + return mock.stringVal(pilgrim.Flag_Username) +} + +func (mock *cliContextMock) Password() (value string, isProvided bool) { + return mock.stringVal(pilgrim.Flag_Password) +} + +func (mock *cliContextMock) Host() (value string, isProvided bool) { + return mock.stringVal(pilgrim.Flag_Host) +} + +func (mock *cliContextMock) Port() (value string, isProvided bool) { + return mock.stringVal(pilgrim.Flag_Port) +} + +func (mock *cliContextMock) Segments() (value []string, isProvided bool) { + return mock.segments() +} + +func (mock *cliContextMock) Args() (value map[string]string, isProvided bool) { + return mock.args() +} + +func (mock *cliContextMock) Directory() (value string, isProvided bool) { + return mock.stringVal(pilgrim.Flag_Directory) +} + +func (mock *cliContextMock) IsUp() (value bool, isProvided bool) { + return mock.boolVal(pilgrim.Flag_Up) +} + +func (mock *cliContextMock) IsDown() (value bool, isProvided bool) { + return mock.boolVal(pilgrim.Flag_Down) +} + +// Can be a script name, or "latest" +func (mock *cliContextMock) Script() (value string, isProvided bool) { + return mock.stringVal(pilgrim.Flag_Script) +} + +func (mock *cliContextMock) MigrationTable() (value string, isProvided bool) { + return mock.stringVal(pilgrim.Flag_Table) +} + +func (mock *cliContextMock) MigrationTableSchema() (value string, isProvided bool) { + return mock.stringVal(pilgrim.Flag_Schema) +} + +func (mock *cliContextMock) IsChecksumValidationEnabled() (value bool, isProvided bool) { + value, isProvided = mock.boolVal(pilgrim.Flag_DisableChecksumValidation) + return !value, isProvided +} + +func (mock *cliContextMock) IsStrictOrderingEnabled() (value bool, isProvided bool) { + return mock.boolVal(pilgrim.Flag_StrictOrdering) +} + +func (mock *cliContextMock) IsValidatingMigrationOrder() (value bool, isProvided bool) { + return mock.boolVal(pilgrim.Flag_ValidateMigrationOrder) +} + +func (mock *cliContextMock) IsValidatingLatest() (value bool, isProvided bool) { + return mock.boolVal(pilgrim.Flag_ValidateLatest) +} + +func (mock *cliContextMock) IsValidatingChecksums() (value bool, isProvided bool) { + return mock.boolVal(pilgrim.Flag_ValidateChecksums) +} + +func (mock *cliContextMock) ParseFlags() { + +} + +type envVarContextMock struct { + stringVars map[string]func() (value string, isProvided bool) + boolVars map[string]func() (value bool, isProvided bool) + segments func() (value []string, isProvided bool) + args func() (value map[string]string, isProvided bool) +} + +func MockEnvVarContext( + stringVars map[string]func() (value string, isProvided bool), + boolVars map[string]func() (value bool, isProvided bool), + segments func() (value []string, isProvided bool), + args func() (value map[string]string, isProvided bool), +) *envVarContextMock { + return &envVarContextMock{ + stringVars, boolVars, segments, args, + } +} + +func (mock *envVarContextMock) stringVal(val string) (value string, isProvided bool) { + retrieverFunc, available := mock.stringVars[val] + + if !available { + return pilgrim.EmptyString, false + } + + return retrieverFunc() +} + +func (mock *envVarContextMock) boolVal(val string) (value bool, isProvided bool) { + retrieverFunc, available := mock.boolVars[val] + + if !available { + return false, false + } + + return retrieverFunc() +} + +func (mock *envVarContextMock) Url() (value string, isDefined bool) { + return mock.stringVal(pilgrim.EnvVarKey_Url) +} + +func (mock *envVarContextMock) Driver() (value pilgrim.DbDriver, isDefined bool) { + val, provided := mock.stringVal(pilgrim.Flag_Driver) + return pilgrim.DbDriver(val), provided +} + +func (mock *envVarContextMock) Username() (value string, isDefined bool) { + return mock.stringVal(pilgrim.EnvVarKey_Username) +} + +func (mock *envVarContextMock) Password() (value string, isDefined bool) { + return mock.stringVal(pilgrim.EnvVarKey_Password) +} + +func (mock *envVarContextMock) Host() (value string, isDefined bool) { + return mock.stringVal(pilgrim.EnvVarKey_Host) +} + +func (mock *envVarContextMock) Port() (value string, isDefined bool) { + return mock.stringVal(pilgrim.EnvVarKey_Port) +} + +func (mock *envVarContextMock) Segments() (value []string, isDefined bool) { + return mock.segments() +} + +func (mock *envVarContextMock) Args() (value map[string]string, isDefined bool) { + return mock.args() +} + +func (mock *envVarContextMock) Directory() (value string, isDefined bool) { + return mock.stringVal(pilgrim.EnvVarKey_Directory) +} + +func (mock *envVarContextMock) MigrationTable() (value string, isDefined bool) { + return mock.stringVal(pilgrim.EnvVarKey_Table) +} + +func (mock *envVarContextMock) MigrationTableSchema() (value string, isDefined bool) { + return mock.stringVal(pilgrim.EnvVarKey_Schema) +} diff --git a/pilgrim/test/url_parts_test.go b/pilgrim/test/url_parts_test.go new file mode 100644 index 0000000..8185b5e --- /dev/null +++ b/pilgrim/test/url_parts_test.go @@ -0,0 +1,66 @@ +package pilgrim_test + +import ( + "reflect" + "testing" + + "mvvasilev.dev/pilgrim/pilgrim" +) + +func Test_ParseDatabaseUrl(t *testing.T) { + type args struct { + cliUrl string + } + + tests := []struct { + name string + args args + want pilgrim.UrlParts + }{ + { + name: "parses_url_correctly", + args: args{ + cliUrl: "driver://user:pass@localhost:9999/segment1/segment2?arg1=val1&arg2=val2", + }, + want: pilgrim.UrlParts{ + Driver: "driver", + Username: "user", + Password: "pass", + Host: "localhost", + Port: "9999", + Segments: []string{ + "segment1", + "segment2", + }, + Arguments: map[string]string{ + "arg1": "val1", + "arg2": "val2", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := pilgrim.ParseDatabaseUrl(tt.args.cliUrl); !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseDatabaseUrl() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_UrlParts_Validate_ValidUrl(t *testing.T) { + urlParts := pilgrim.ParseDatabaseUrl("mssql://user:pass@localhost:9999/segment1/segment2?arg1=val1&arg2=val2") + isValid, err := urlParts.Validate() + + Assert(t, isValid, "Valid DatabaseUrl returned as invalid", err) + Assert(t, err == nil, "Valid DatabaseUrl returned validation errors", err) +} + +func Test_UrlParts_Validate_InvalidUrl(t *testing.T) { + urlParts := pilgrim.ParseDatabaseUrl("invalid-driver://user:pass@localhost:9999/segment1/segment2?arg1=val1&arg2=val2") + isValid, err := urlParts.Validate() + + Assert(t, !isValid, "Invalid DatabaseUrl returned as valid", err) + Assert(t, err != nil, "Invalid DatabaseUrl did not return validation errors", err) +} diff --git a/pilgrim/test/utils_test.go b/pilgrim/test/utils_test.go new file mode 100644 index 0000000..db14d0a --- /dev/null +++ b/pilgrim/test/utils_test.go @@ -0,0 +1,24 @@ +package pilgrim_test + +import "testing" + +func Assert(t *testing.T, condition bool, errItems ...any) { + if !condition { + t.Fail() + t.Log("Test failure reason:", errItems) + } +} + +func AssertEquals[T comparable](t *testing.T, value T, expected T) { + if value != expected { + t.Fail() + t.Logf("Got %v, expected %v", value, expected) + } +} + +func AssertEqualsFunc[T any](t *testing.T, value T, expected T, equalsFunc func(v, e T) bool) { + if !equalsFunc(value, expected) { + t.Fail() + t.Logf("Got %v, expected %v", value, expected) + } +} diff --git a/pilgrim/url_parts.go b/pilgrim/url_parts.go new file mode 100644 index 0000000..eacfd13 --- /dev/null +++ b/pilgrim/url_parts.go @@ -0,0 +1,142 @@ +package pilgrim + +import ( + "fmt" + "log" + "regexp" + "slices" + "strings" +) + +const EmptyString = "" + +type DbDriver string + +const ( + DbDriver_MySQL DbDriver = "mysql" + DbDriver_MSSQL DbDriver = "mssql" + DbDriver_Postgres DbDriver = "postgres" + DbDriver_Oracle DbDriver = "oracle" + DbDriver_SQLite DbDriver = "sqlite" + DbDriver_MariaDB DbDriver = "mariadb" +) + +var AvailableDbDrivers []DbDriver = []DbDriver{ + DbDriver_MySQL, + DbDriver_MSSQL, + DbDriver_Postgres, + DbDriver_Oracle, + DbDriver_SQLite, + DbDriver_MariaDB, +} + +type UrlParts struct { + Host, + Port, + Username, + Password string + + Driver DbDriver + + Segments []string + Arguments map[string]string +} + +const DbUrlRegex = `^(?:([^:\/?#\s]+):\/{2})?(?:([^@\/?#\s]+)@)?([^\/?#\s]+)?(?:\/([^?#\s]*))?(?:[?]([^#\s]+))?\S*$` + +func ParseDatabaseUrl(url string) UrlParts { + urlRegex, err := regexp.Compile(DbUrlRegex) + + if err != nil { + log.Fatalf("Regex %s could not be compiled", DbUrlRegex) // The developer wrote the regex wrong. + } + + parsed := urlRegex.FindAllStringSubmatch(url, -1) + + if parsed == nil || parsed[0] == nil { + return UrlParts{} + } + + parsedUrl := parsed[0] + + driver := DbDriver(parsedUrl[1]) + username := "" + password := "" + host := "" + port := "" + var segments []string + var arguments map[string]string = make(map[string]string) + + // Parse username and password ( username:password ) + if parsedUrl[2] != EmptyString && strings.Contains(parsedUrl[2], ":") { + splitUserAndPass := strings.Split(parsedUrl[2], ":") + username = splitUserAndPass[0] + password = splitUserAndPass[1] + } + + // Parse host and port ( host:port ) + if parsedUrl[3] != EmptyString && strings.Contains(parsedUrl[3], ":") { + splitHostAndPort := strings.Split(parsedUrl[3], ":") + host = splitHostAndPort[0] + port = splitHostAndPort[1] + } + + // Parse segments ( segment1/segment2 ) + if parsedUrl[4] != EmptyString { + segments = ParseSegments(parsedUrl[4]) + } + + // Parse arguments ( arg1=value1&arg2=value2 ) + if parsedUrl[5] != EmptyString { + arguments = ParseArguments(parsedUrl[5]) + } + + return UrlParts{ + Driver: driver, + Username: username, + Password: password, + Host: host, + Port: port, + Segments: segments, + Arguments: arguments, + } +} + +func ParseSegments(segments string) []string { + if segments == EmptyString { + return []string{} + } + + return strings.Split(segments, "/") +} + +func ParseArguments(arguments string) map[string]string { + if arguments == EmptyString { + return make(map[string]string) + } + + var argsMap map[string]string = make(map[string]string) + + splitArguments := strings.Split(arguments, "&") + + for _, arg := range splitArguments { + if !strings.Contains(arg, "=") { + continue + } + + splitArgAndValue := strings.Split(arg, "=") + + argsMap[splitArgAndValue[0]] = splitArgAndValue[1] + } + + return argsMap +} + +// Returns true if valid, with nil error, or false if invalid, with the validation error +func (urlParts *UrlParts) Validate() (bool, error) { + if !slices.Contains(AvailableDbDrivers, DbDriver(urlParts.Driver)) { + return false, PilgrimInvalidError(fmt.Sprintf("URLParts invalid: Provided driver %v is not currently supported ( must be one of %v )", urlParts.Driver, AvailableDbDrivers)) + } + + return true, nil +}