Initial commit: Add some basic CLI and Environment Var code

This commit is contained in:
Miroslav Vasilev 2024-10-25 23:57:19 +03:00
parent 162863b4dd
commit 6a664613db
16 changed files with 1705 additions and 2 deletions

1
.gitignore vendored
View file

@ -8,6 +8,7 @@
*.dll
*.so
*.dylib
target/**
# Test binary, built with `go test -c`
*.test

16
.vscode/launch.json vendored Normal file
View file

@ -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}"
}
]
}

116
README.md
View file

@ -1,3 +1,115 @@
# pilgrim
# Pilgrim: An SQL Migration Tool Worth The Pilgrimage
A database migration tool written in golang
## 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.

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module mvvasilev.dev/pilgrim
go 1.23.1

16
main.go Normal file
View file

@ -0,0 +1,16 @@
package main
import (
"log"
"mvvasilev.dev/pilgrim/pilgrim"
)
func main() {
pilgrimContext := pilgrim.NewPilgrimContext(
pilgrim.CliContext(),
pilgrim.NewEnvVarContext(),
)
log.Println(pilgrimContext)
}

279
pilgrim/cli.go Normal file
View file

@ -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, "<driver>://[[username]:[password]@]<host>:<port>[/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]
}

213
pilgrim/context.go Normal file
View file

@ -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
}

95
pilgrim/env.go Normal file
View file

@ -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)
}

15
pilgrim/error.go Normal file
View file

@ -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
}

226
pilgrim/test/cli_test.go Normal file
View file

@ -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,
)
}

View file

@ -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")
}

149
pilgrim/test/env_test.go Normal file
View file

@ -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,
)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}
}

142
pilgrim/url_parts.go Normal file
View file

@ -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
}