├── testdata ├── test.duckdb.wal ├── test.duckdb └── test.sqlite3 ├── .gitignore ├── go.mod ├── .github └── workflows │ └── test.yml ├── _example └── example.go ├── .golangci.yml ├── passfile ├── example_test.go ├── passfile_test.go └── passfile.go ├── example_test.go ├── LICENSE ├── scheme.go ├── dburl.go ├── README.md ├── dsn.go └── dburl_test.go /testdata/test.duckdb.wal: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | example/example 2 | coverage.out 3 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/xo/dburl 2 | 3 | go 1.22 4 | -------------------------------------------------------------------------------- /testdata/test.duckdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xo/dburl/HEAD/testdata/test.duckdb -------------------------------------------------------------------------------- /testdata/test.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xo/dburl/HEAD/testdata/test.sqlite3 -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test 3 | jobs: 4 | test: 5 | name: Test 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Install Go 9 | uses: actions/setup-go@v5 10 | with: 11 | go-version: stable 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | - name: Test 15 | run: CGO_ENABLED=0 go test -v ./... 16 | -------------------------------------------------------------------------------- /_example/example.go: -------------------------------------------------------------------------------- 1 | // _example/example.go 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "log" 7 | 8 | _ "github.com/microsoft/go-mssqldb" 9 | "github.com/xo/dburl" 10 | ) 11 | 12 | func main() { 13 | db, err := dburl.Open("sqlserver://user:pass@localhost/dbname") 14 | if err != nil { 15 | log.Fatal(err) 16 | } 17 | var name string 18 | if err := db.QueryRow(`SELECT name FROM mytable WHERE id=10`).Scan(&name); err != nil { 19 | log.Fatal(err) 20 | } 21 | fmt.Println("name:", name) 22 | } 23 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: all 4 | disable: 5 | - cyclop 6 | - depguard 7 | - err113 8 | - errcheck 9 | - exhaustruct 10 | - funlen 11 | - gochecknoglobals 12 | - gochecknoinits 13 | - gocognit 14 | - goconst 15 | - lll 16 | - maintidx 17 | - mnd 18 | - nestif 19 | - nilnil 20 | - nlreturn 21 | - paralleltest 22 | - prealloc 23 | - sqlclosecheck 24 | - testableexamples 25 | - testpackage 26 | - varnamelen 27 | - wrapcheck 28 | - wsl 29 | settings: 30 | gosec: 31 | excludes: 32 | - G101 33 | - G304 34 | - G390 35 | gocritic: 36 | disabled-checks: 37 | - exitAfterDefer 38 | -------------------------------------------------------------------------------- /passfile/example_test.go: -------------------------------------------------------------------------------- 1 | package passfile_test 2 | 3 | import ( 4 | "log" 5 | "os/user" 6 | 7 | "github.com/xo/dburl" 8 | "github.com/xo/dburl/passfile" 9 | ) 10 | 11 | func Example_entries() { 12 | u, err := user.Current() 13 | if err != nil { 14 | log.Fatal(err) 15 | } 16 | // read ~/.usqlpass or $ENV{USQLPASS} 17 | entries, err := passfile.Entries(u.HomeDir, "usqlpass") 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | for i, entry := range entries { 22 | log.Printf("%d: %v", i, entry) 23 | } 24 | } 25 | 26 | func Example_match() { 27 | v, err := user.Current() 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | u, err := dburl.Parse("pg://") 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | // read ~/.usqlpass or $ENV{USQLPASS} 36 | user, err := passfile.Match(u, v.HomeDir, "usqlpass") 37 | if err == nil { 38 | u.User = user 39 | } 40 | log.Println("url:", u.String()) 41 | } 42 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package dburl_test 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | 7 | "github.com/xo/dburl" 8 | ) 9 | 10 | func Example() { 11 | db, err := dburl.Open("my://user:pass@host:1234/dbname") 12 | if err != nil { 13 | log.Fatal(err) 14 | } 15 | defer db.Close() 16 | res, err := db.Query("SELECT ...") 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | for res.Next() { 21 | /* ... */ 22 | } 23 | if err := res.Err(); err != nil { 24 | log.Fatal(err) 25 | } 26 | } 27 | 28 | func Example_parse() { 29 | u, err := dburl.Parse("pg://user:pass@host:1234/dbname") 30 | if err != nil { 31 | log.Fatal(err) 32 | } 33 | db, err := sql.Open(u.Driver, u.DSN) 34 | if err != nil { 35 | log.Fatal(err) 36 | } 37 | defer db.Close() 38 | res, err := db.Query("SELECT ...") 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | for res.Next() { 43 | /* ... */ 44 | } 45 | if err := res.Err(); err != nil { 46 | log.Fatal(err) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2025 Kenneth Shaw 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /passfile/passfile_test.go: -------------------------------------------------------------------------------- 1 | package passfile 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestParse(t *testing.T) { 10 | entries, err := Parse(strings.NewReader(passfile)) 11 | if err != nil { 12 | t.Fatalf("expected no error, got: %v", err) 13 | } 14 | if len(entries) != 10 { 15 | t.Fatalf("entries should have exactly 10 entries, got: %d", len(entries)) 16 | } 17 | exp := []Entry{ 18 | {"postgres", "*", "*", "*", "postgres", "P4ssw0rd"}, 19 | {"cql", "*", "*", "*", "cassandra", "cassandra"}, 20 | {"godror", "*", "*", "*", "system", "P4ssw0rd"}, 21 | {"ignite", "*", "*", "*", "ignite", "ignite"}, 22 | {"mymysql", "*", "*", "*", "root", "P4ssw0rd"}, 23 | {"mysql", "*", "*", "*", "root", "P4ssw0rd"}, 24 | {"oracle", "*", "*", "*", "system", "P4ssw0rd"}, 25 | {"pgx", "*", "*", "*", "postgres", "P4ssw0rd"}, 26 | {"sqlserver", "*", "*", "*", "sa", "Adm1nP@ssw0rd"}, 27 | {"vertica", "*", "*", "*", "dbadmin", "P4ssw0rd"}, 28 | } 29 | if !reflect.DeepEqual(entries, exp) { 30 | t.Errorf("entries does not equal expected:\nexp:%#v\n---\ngot:%#v", exp, entries) 31 | } 32 | } 33 | 34 | const passfile = `# sample ~/.usqlpass file 35 | # 36 | # format is: 37 | # protocol:host:port:dbname:user:pass 38 | postgres:*:*:*:postgres:P4ssw0rd 39 | 40 | cql:*:*:*:cassandra:cassandra 41 | godror:*:*:*:system:P4ssw0rd 42 | ignite:*:*:*:ignite:ignite 43 | mymysql:*:*:*:root:P4ssw0rd 44 | mysql:*:*:*:root:P4ssw0rd 45 | oracle:*:*:*:system:P4ssw0rd 46 | pgx:*:*:*:postgres:P4ssw0rd 47 | sqlserver:*:*:*:sa:Adm1nP@ssw0rd 48 | vertica:*:*:*:dbadmin:P4ssw0rd 49 | ` 50 | -------------------------------------------------------------------------------- /passfile/passfile.go: -------------------------------------------------------------------------------- 1 | // Package passfile provides a mechanism for reading database credentials from 2 | // passfiles. 3 | package passfile 4 | 5 | import ( 6 | "bufio" 7 | "database/sql" 8 | "fmt" 9 | "io" 10 | "net/url" 11 | "os" 12 | "path/filepath" 13 | "regexp" 14 | "runtime" 15 | "slices" 16 | "strings" 17 | 18 | "github.com/xo/dburl" 19 | ) 20 | 21 | // Entry is a passfile entry. 22 | // 23 | // Corresponds to a non-empty line in a passfile. 24 | type Entry struct { 25 | Protocol, Host, Port, DBName, Username, Password string 26 | } 27 | 28 | // NewEntry creates a new passfile entry. 29 | func NewEntry(v []string) Entry { 30 | // make sure there's always at least 6 elements 31 | v = append(v, "", "", "", "", "", "") 32 | return Entry{ 33 | Protocol: v[0], 34 | Host: v[1], 35 | Port: v[2], 36 | DBName: v[3], 37 | Username: v[4], 38 | Password: v[5], 39 | } 40 | } 41 | 42 | // Parse parses passfile entries from the reader. 43 | func Parse(r io.Reader) ([]Entry, error) { 44 | var entries []Entry 45 | i, s := 0, bufio.NewScanner(r) 46 | for s.Scan() { 47 | i++ 48 | // grab next line 49 | line := strings.TrimSpace(commentRE.ReplaceAllString(s.Text(), "")) 50 | if line == "" { 51 | continue 52 | } 53 | // split and check length 54 | v := strings.Split(line, ":") 55 | if len(v) != 6 { 56 | return nil, &InvalidEntryError{i} 57 | } 58 | // make sure no blank entries exist 59 | for j := range v { 60 | if v[j] == "" { 61 | return nil, &EmptyFieldError{i, j} 62 | } 63 | } 64 | entries = append(entries, NewEntry(v)) 65 | } 66 | return entries, nil 67 | } 68 | 69 | // commentRE matches comment entries in a passfile. 70 | var commentRE = regexp.MustCompile(`#.*`) 71 | 72 | // ParseFile parses passfile entries contained in file. 73 | func ParseFile(file string) ([]Entry, error) { 74 | fi, err := os.Stat(file) 75 | switch { 76 | case err != nil && os.IsNotExist(err): 77 | return nil, nil 78 | case err != nil: 79 | return nil, &FileError{file, err} 80 | case fi.IsDir(): 81 | // ensure not a directory 82 | return nil, &FileError{file, ErrMustNotBeDirectory} 83 | case runtime.GOOS != "windows" && fi.Mode()&0x3f != 0: 84 | // ensure not group/world readable/writable/executable 85 | return nil, &FileError{file, ErrHasGroupOrWorldAccess} 86 | } 87 | // open 88 | f, err := os.OpenFile(file, os.O_RDONLY, 0) 89 | if err != nil { 90 | return nil, &FileError{file, err} 91 | } 92 | // parse 93 | entries, err := Parse(f) 94 | if err != nil { 95 | defer f.Close() 96 | return nil, &FileError{file, err} 97 | } 98 | if err := f.Close(); err != nil { 99 | return nil, &FileError{file, err} 100 | } 101 | return entries, nil 102 | } 103 | 104 | // Equals returns true when v matches the entry. 105 | func (entry Entry) Equals(v Entry, protocols ...string) bool { 106 | return (entry.Protocol == "*" || slices.Contains(protocols, entry.Protocol)) && 107 | (entry.Host == "*" || entry.Host == v.Host) && 108 | (entry.Port == "*" || entry.Port == v.Port) 109 | } 110 | 111 | // MatchEntries returns a Userinfo when the normalized v is found in entries. 112 | func MatchEntries(u *dburl.URL, entries []Entry, protocols ...string) (*url.Userinfo, error) { 113 | // check if v already has password defined ... 114 | var username string 115 | if u.User != nil { 116 | username = u.User.Username() 117 | if _, ok := u.User.Password(); ok { 118 | return nil, nil 119 | } 120 | } 121 | // find matching entry 122 | n := strings.SplitN(u.Normalize(":", "", 3), ":", 6) 123 | if len(n) < 3 { 124 | return nil, ErrUnableToNormalizeURL 125 | } 126 | m := NewEntry(n) 127 | for _, entry := range entries { 128 | if entry.Equals(m, protocols...) { 129 | u := entry.Username 130 | if entry.Username == "*" { 131 | u = username 132 | } 133 | return url.UserPassword(u, entry.Password), nil 134 | } 135 | } 136 | return nil, nil 137 | } 138 | 139 | // MatchFile returns a Userinfo from a passfile entry matching database URL v 140 | // read from the specified file. 141 | func MatchFile(u *dburl.URL, file string, protocols ...string) (*url.Userinfo, error) { 142 | entries, err := ParseFile(file) 143 | if err != nil { 144 | return nil, &FileError{file, err} 145 | } 146 | if entries == nil { 147 | return nil, nil 148 | } 149 | user, err := MatchEntries(u, entries, protocols...) 150 | if err != nil { 151 | return nil, &FileError{file, err} 152 | } 153 | return user, nil 154 | } 155 | 156 | // Match returns a Userinfo from a passfile entry matching database URL read 157 | // from the file in $HOME/. or $ENV{NAME}. 158 | // 159 | // Equivalent to MatchFile(u, Path(homeDir, name), dburl.Protocols(u.Driver)...). 160 | func Match(u *dburl.URL, homeDir, name string) (*url.Userinfo, error) { 161 | return MatchFile(u, Path(homeDir, name), dburl.Protocols(u.Driver)...) 162 | } 163 | 164 | // MatchProtocols returns a Userinfo from a passfile entry matching database 165 | // URL read from the file in $HOME/. or $ENV{NAME} using the specified 166 | // protocols. 167 | // 168 | // Equivalent to MatchFile(u, Path(homeDir, name), protocols...). 169 | func MatchProtocols(u *dburl.URL, homeDir, name string, protocols ...string) (*url.Userinfo, error) { 170 | return MatchFile(u, Path(homeDir, name), protocols...) 171 | } 172 | 173 | // Entries returns the entries for the specified passfile name. 174 | // 175 | // Equivalent to ParseFile(Path(homeDir, name)). 176 | func Entries(homeDir, name string) ([]Entry, error) { 177 | return ParseFile(Path(homeDir, name)) 178 | } 179 | 180 | // Path returns the expanded path to the password file for name. 181 | // 182 | // Uses $HOME/., overridden by environment variable $ENV{NAME} (for 183 | // example, ~/.usqlpass and $ENV{USQLPASS}). 184 | func Path(homeDir, name string) string { 185 | file := "~/." + strings.ToLower(name) 186 | if s := os.Getenv(strings.ToUpper(name)); s != "" { 187 | file = s 188 | } 189 | return Expand(homeDir, file) 190 | } 191 | 192 | // Expand expands the beginning tilde (~) in a file name to the provided home 193 | // directory. 194 | func Expand(homeDir string, file string) string { 195 | switch { 196 | case file == "~": 197 | return homeDir 198 | case strings.HasPrefix(file, "~/"): 199 | return filepath.Join(homeDir, strings.TrimPrefix(file, "~/")) 200 | } 201 | return file 202 | } 203 | 204 | // OpenURL opens a database connection for the provided URL, reading the named 205 | // passfile in the home directory. 206 | func OpenURL(u *dburl.URL, homeDir, name string) (*sql.DB, error) { 207 | if u.User != nil { 208 | return sql.Open(u.Driver, u.DSN) 209 | } 210 | user, err := Match(u, homeDir, name) 211 | if err != nil { 212 | return sql.Open(u.Driver, u.DSN) 213 | } 214 | u.User = user 215 | v, _ := dburl.Parse(u.String()) 216 | *u = *v 217 | return sql.Open(v.Driver, v.DSN) 218 | } 219 | 220 | // Open opens a database connection for a URL, reading the named passfile in 221 | // the home directory. 222 | func Open(urlstr, homeDir, name string) (*sql.DB, error) { 223 | u, err := dburl.Parse(urlstr) 224 | if err != nil { 225 | return nil, err 226 | } 227 | return OpenURL(u, homeDir, name) 228 | } 229 | 230 | // Error is a error. 231 | type Error string 232 | 233 | // Error satisfies the error interface. 234 | func (err Error) Error() string { 235 | return string(err) 236 | } 237 | 238 | const ( 239 | // ErrUnableToNormalizeURL is the unable to normalize URL error. 240 | ErrUnableToNormalizeURL Error = "unable to normalize URL" 241 | // ErrMustNotBeDirectory is the must not be directory error. 242 | ErrMustNotBeDirectory Error = "must not be directory" 243 | // ErrHasGroupOrWorldAccess is the has group or world access error. 244 | ErrHasGroupOrWorldAccess Error = "has group or world access" 245 | ) 246 | 247 | // FileError is a file error. 248 | type FileError struct { 249 | File string 250 | Err error 251 | } 252 | 253 | // Error satisfies the error interface. 254 | func (err *FileError) Error() string { 255 | return fmt.Sprintf("passfile %q: %v", err.File, err.Err) 256 | } 257 | 258 | // Unwrap satisfies the unwrap interface. 259 | func (err *FileError) Unwrap() error { 260 | return err.Err 261 | } 262 | 263 | // InvalidEntryError is the invalid entry error. 264 | type InvalidEntryError struct { 265 | Line int 266 | } 267 | 268 | // Error satisfies the error interface. 269 | func (err *InvalidEntryError) Error() string { 270 | return fmt.Sprintf("invalid entry at line %d", err.Line) 271 | } 272 | 273 | // EmptyFieldError is the empty field error. 274 | type EmptyFieldError struct { 275 | Line int 276 | Field int 277 | } 278 | 279 | // Error satisfies the error interface. 280 | func (err *EmptyFieldError) Error() string { 281 | return fmt.Sprintf("line %d has empty field %d", err.Line, err.Field) 282 | } 283 | -------------------------------------------------------------------------------- /scheme.go: -------------------------------------------------------------------------------- 1 | package dburl 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "regexp" 7 | "slices" 8 | "sort" 9 | ) 10 | 11 | // Transport is the allowed transport protocol types in a database [URL] scheme. 12 | type Transport uint 13 | 14 | // Transport types. 15 | const ( 16 | TransportNone Transport = 0 17 | TransportTCP Transport = 1 18 | TransportUDP Transport = 2 19 | TransportUnix Transport = 4 20 | TransportAny Transport = 8 21 | ) 22 | 23 | // Scheme wraps information used for registering a database URL scheme for use 24 | // with [Parse]/[Open]. 25 | type Scheme struct { 26 | // Driver is the name of the SQL driver that is set as the Scheme in 27 | // Parse'd URLs and is the driver name expected by the standard sql.Open 28 | // calls. 29 | // 30 | // Note: a 2 letter alias will always be registered for the Driver as the 31 | // first 2 characters of the Driver, unless one of the Aliases includes an 32 | // alias that is 2 characters. 33 | Driver string 34 | // Generator is the func responsible for generating a DSN based on parsed 35 | // URL information. 36 | // 37 | // Note: this func should not modify the passed URL. 38 | Generator func(*URL) (string, string, error) 39 | // Transport are allowed protocol transport types for the scheme. 40 | Transport Transport 41 | // Opaque toggles Parse to not re-process URLs with an "opaque" component. 42 | Opaque bool 43 | // Aliases are any additional aliases for the scheme. 44 | Aliases []string 45 | // Override is the Go SQL driver to use instead of Driver. 46 | // 47 | // Used for "wire compatible" driver schemes. 48 | Override string 49 | } 50 | 51 | // BaseSchemes returns the supported base schemes. 52 | func BaseSchemes() []Scheme { 53 | return []Scheme{ 54 | { 55 | "file", 56 | GenOpaque, 0, true, 57 | []string{"file"}, 58 | "", 59 | }, 60 | // core databases 61 | { 62 | "mysql", 63 | GenMysql, TransportTCP | TransportUDP | TransportUnix, 64 | false, 65 | []string{"mariadb", "maria", "percona", "aurora"}, 66 | "", 67 | }, 68 | { 69 | "oracle", 70 | GenFromURL("oracle://localhost:1521"), 0, false, 71 | []string{"ora", "oci", "oci8", "odpi", "odpi-c"}, 72 | "", 73 | }, 74 | { 75 | "postgres", 76 | GenPostgres, TransportUnix, false, 77 | []string{"pg", "postgresql", "pgsql"}, 78 | "", 79 | }, 80 | { 81 | "sqlite3", 82 | GenOpaque, 0, true, 83 | []string{"sqlite"}, 84 | "", 85 | }, 86 | { 87 | "sqlserver", 88 | GenSqlserver, 0, false, 89 | []string{"ms", "mssql", "azuresql"}, 90 | "", 91 | }, 92 | // wire compatibles 93 | { 94 | "cockroachdb", 95 | GenFromURL("postgres://localhost:26257/?sslmode=disable"), 0, false, 96 | []string{"cr", "cockroach", "crdb", "cdb"}, 97 | "postgres", 98 | }, 99 | { 100 | "memsql", GenMysql, 0, false, nil, "mysql", 101 | }, 102 | { 103 | "redshift", 104 | GenFromURL("postgres://localhost:5439/"), 0, false, 105 | []string{"rs"}, 106 | "postgres", 107 | }, 108 | { 109 | "tidb", 110 | GenMysql, 0, false, nil, "mysql", 111 | }, 112 | { 113 | "vitess", 114 | GenMysql, 0, false, 115 | []string{"vt"}, 116 | "mysql", 117 | }, 118 | // alternate implementations 119 | { 120 | "godror", 121 | GenGodror, 0, false, 122 | []string{"gr"}, 123 | "", 124 | }, 125 | { 126 | "moderncsqlite", 127 | GenOpaque, 0, true, 128 | []string{"mq", "modernsqlite"}, 129 | "", 130 | }, 131 | { 132 | "mymysql", 133 | GenMymysql, TransportTCP | TransportUDP | TransportUnix, false, 134 | []string{"zm", "mymy"}, 135 | "", 136 | }, 137 | { 138 | "pgx", 139 | GenFromURL("postgres://localhost:5432/"), TransportUnix, false, 140 | []string{"px"}, 141 | "", 142 | }, 143 | // other databases 144 | { 145 | "adodb", 146 | GenAdodb, 0, false, 147 | []string{"ado"}, 148 | "", 149 | }, 150 | { 151 | "awsathena", 152 | GenScheme("s3"), 0, false, 153 | []string{"s3", "aws", "athena"}, 154 | "", 155 | }, 156 | { 157 | "avatica", 158 | GenFromURL("http://localhost:8765/"), 0, false, 159 | []string{"phoenix"}, 160 | "", 161 | }, 162 | { 163 | "bigquery", 164 | GenScheme("bigquery"), 0, false, 165 | []string{"bq"}, 166 | "", 167 | }, 168 | { 169 | "clickhouse", 170 | GenClickhouse, TransportAny, false, 171 | []string{"ch"}, 172 | "", 173 | }, 174 | { 175 | "cosmos", 176 | GenCosmos, 0, false, 177 | []string{"cm", "gocosmos"}, 178 | "", 179 | }, 180 | { 181 | "cql", 182 | GenCassandra, 0, false, 183 | []string{"ca", "cassandra", "datastax", "scy", "scylla"}, 184 | "", 185 | }, 186 | { 187 | "csvq", 188 | GenOpaque, 0, true, 189 | []string{"csv", "tsv", "json"}, 190 | "", 191 | }, 192 | { 193 | "databend", 194 | GenDatabend, 0, false, 195 | []string{"dd", "bend"}, 196 | "", 197 | }, 198 | { 199 | "databricks", 200 | GenDatabricks, 0, false, 201 | []string{"br", "brick", "bricks", "databrick"}, 202 | "", 203 | }, 204 | { 205 | "duckdb", 206 | GenDuckDB, 0, true, 207 | []string{"dk", "ddb", "duck"}, 208 | "", 209 | }, 210 | { 211 | "godynamo", 212 | GenDynamo, 0, false, 213 | []string{"dy", "dyn", "dynamo", "dynamodb"}, 214 | "", 215 | }, 216 | { 217 | "exasol", 218 | GenExasol, 0, false, 219 | []string{"ex", "exa"}, 220 | "", 221 | }, 222 | { 223 | "firebirdsql", 224 | GenFirebird, 0, false, 225 | []string{"fb", "firebird"}, 226 | "", 227 | }, 228 | { 229 | "flightsql", 230 | GenScheme("flightsql"), 0, false, 231 | []string{"fl", "flight"}, 232 | "", 233 | }, 234 | { 235 | "chai", 236 | GenOpaque, 0, true, 237 | []string{"ci", "chaisql", "genji"}, 238 | "", 239 | }, 240 | { 241 | "h2", 242 | GenFromURL("h2://localhost:9092/"), 0, false, nil, "", 243 | }, 244 | { 245 | "hdb", 246 | GenScheme("hdb"), 0, false, 247 | []string{"sa", "saphana", "sap", "hana"}, 248 | "", 249 | }, 250 | { 251 | "hive", 252 | GenFromURL("truncate://localhost:10000/"), 0, false, 253 | []string{"hive2"}, 254 | "", 255 | }, 256 | { 257 | "ignite", 258 | GenIgnite, 0, false, 259 | []string{"ig", "gridgain"}, 260 | "", 261 | }, 262 | { 263 | "impala", 264 | GenScheme("impala"), 0, false, nil, "", 265 | }, 266 | { 267 | "maxcompute", 268 | GenFromURL("truncate://localhost/"), 0, false, 269 | []string{"mc"}, 270 | "", 271 | }, 272 | { 273 | "n1ql", 274 | GenFromURL("http://localhost:8093/"), 0, false, 275 | []string{"couchbase"}, 276 | "", 277 | }, 278 | { 279 | "nzgo", 280 | GenPostgres, TransportUnix, false, 281 | []string{"nz", "netezza"}, 282 | "", 283 | }, 284 | { 285 | "odbc", 286 | GenOdbc, TransportAny, false, nil, "", 287 | }, 288 | { 289 | "oleodbc", 290 | GenOleodbc, TransportAny, false, 291 | []string{"oo", "ole"}, 292 | "adodb", 293 | }, 294 | { 295 | "ots", 296 | GenTableStore, TransportAny, false, 297 | []string{"tablestore"}, 298 | "", 299 | }, 300 | { 301 | "presto", 302 | GenPresto, 0, false, 303 | []string{"prestodb", "prestos", "prs", "prestodbs"}, 304 | "", 305 | }, 306 | { 307 | "ql", 308 | GenOpaque, 0, true, 309 | []string{"ql", "cznic", "cznicql"}, 310 | "", 311 | }, 312 | { 313 | "ramsql", 314 | GenFromURL("truncate://ramsql"), 0, false, 315 | []string{"rm", "ram"}, 316 | "", 317 | }, 318 | { 319 | "snowflake", 320 | GenSnowflake, 0, false, 321 | []string{"sf"}, 322 | "", 323 | }, 324 | { 325 | "spanner", 326 | GenSpanner, 0, false, 327 | []string{"sp"}, 328 | "", 329 | }, 330 | { 331 | "tds", 332 | GenFromURL("http://localhost:5000/"), 0, false, 333 | []string{"ax", "ase", "sapase"}, 334 | "", 335 | }, 336 | { 337 | "trino", 338 | GenPresto, 0, false, 339 | []string{"trino", "trinos", "trs"}, 340 | "", 341 | }, 342 | { 343 | "vertica", 344 | GenFromURL("vertica://localhost:5433/"), 0, false, nil, "", 345 | }, 346 | { 347 | "voltdb", 348 | GenVoltdb, 0, false, 349 | []string{"volt", "vdb"}, 350 | "", 351 | }, 352 | { 353 | "ydb", 354 | GenYDB, 0, false, 355 | []string{"yd", "yds", "ydbs"}, 356 | "", 357 | }, 358 | } 359 | } 360 | 361 | func init() { 362 | // register schemes 363 | schemes := BaseSchemes() 364 | schemeMap = make(map[string]*Scheme, len(schemes)) 365 | for _, scheme := range schemes { 366 | Register(scheme) 367 | } 368 | RegisterFileType("duckdb", isDuckdbHeader, `(?i)\.duckdb$`) 369 | RegisterFileType("sqlite3", isSqlite3Header, `(?i)\.(db|sqlite|sqlite3)$`) 370 | } 371 | 372 | // schemeMap is the map of registered schemes. 373 | var schemeMap map[string]*Scheme 374 | 375 | // registerAlias registers a alias for an already registered Scheme. 376 | func registerAlias(name, alias string, doSort bool) { 377 | scheme, ok := schemeMap[name] 378 | if !ok { 379 | panic(fmt.Sprintf("scheme %s not registered", name)) 380 | } 381 | if doSort && slices.Contains(scheme.Aliases, alias) { 382 | panic(fmt.Sprintf("scheme %s already has alias %s", name, alias)) 383 | } 384 | if _, ok := schemeMap[alias]; ok { 385 | panic(fmt.Sprintf("scheme %s already registered", alias)) 386 | } 387 | scheme.Aliases = append(scheme.Aliases, alias) 388 | if doSort { 389 | sort.Slice(scheme.Aliases, func(i, j int) bool { 390 | if len(scheme.Aliases[i]) <= len(scheme.Aliases[j]) { 391 | return true 392 | } 393 | if len(scheme.Aliases[j]) < len(scheme.Aliases[i]) { 394 | return false 395 | } 396 | return scheme.Aliases[i] < scheme.Aliases[j] 397 | }) 398 | } 399 | schemeMap[alias] = scheme 400 | } 401 | 402 | // Register registers a [Scheme]. 403 | func Register(scheme Scheme) { 404 | if scheme.Generator == nil { 405 | panic("must specify Generator when registering Scheme") 406 | } 407 | if scheme.Opaque && scheme.Transport&TransportUnix != 0 { 408 | panic("scheme must support only Opaque or Unix protocols, not both") 409 | } 410 | // check if registered 411 | if _, ok := schemeMap[scheme.Driver]; ok { 412 | panic(fmt.Sprintf("scheme %s already registered", scheme.Driver)) 413 | } 414 | sz := &Scheme{ 415 | Driver: scheme.Driver, 416 | Generator: scheme.Generator, 417 | Transport: scheme.Transport, 418 | Opaque: scheme.Opaque, 419 | Override: scheme.Override, 420 | } 421 | schemeMap[scheme.Driver] = sz 422 | // add aliases 423 | var hasShort bool 424 | for _, alias := range scheme.Aliases { 425 | if len(alias) == 2 { 426 | hasShort = true 427 | } 428 | if scheme.Driver != alias { 429 | registerAlias(scheme.Driver, alias, false) 430 | } 431 | } 432 | if !hasShort && len(scheme.Driver) > 2 { 433 | registerAlias(scheme.Driver, scheme.Driver[:2], false) 434 | } 435 | // ensure always at least one alias, and that if Driver is 2 characters, 436 | // that it gets added as well 437 | if len(sz.Aliases) == 0 || len(scheme.Driver) == 2 { 438 | sz.Aliases = append(sz.Aliases, scheme.Driver) 439 | } 440 | // sort 441 | sort.Slice(sz.Aliases, func(i, j int) bool { 442 | if len(sz.Aliases[i]) <= len(sz.Aliases[j]) { 443 | return true 444 | } 445 | if len(sz.Aliases[j]) < len(sz.Aliases[i]) { 446 | return false 447 | } 448 | return sz.Aliases[i] < sz.Aliases[j] 449 | }) 450 | } 451 | 452 | // Unregister unregisters a scheme and all associated aliases, returning the 453 | // removed [Scheme]. 454 | func Unregister(name string) *Scheme { 455 | if scheme, ok := schemeMap[name]; ok { 456 | for _, alias := range scheme.Aliases { 457 | delete(schemeMap, alias) 458 | } 459 | delete(schemeMap, name) 460 | return scheme 461 | } 462 | return nil 463 | } 464 | 465 | // RegisterAlias registers an additional alias for a registered scheme. 466 | func RegisterAlias(name, alias string) { 467 | registerAlias(name, alias, true) 468 | } 469 | 470 | // fileTypes are registered header recognition funcs. 471 | var fileTypes []fileType 472 | 473 | // RegisterFileType registers a file header recognition func, and extension regexp. 474 | func RegisterFileType(driver string, f func([]byte) bool, ext string) { 475 | extRE, err := regexp.Compile(ext) 476 | if err != nil { 477 | panic(fmt.Sprintf("invalid extension regexp %q: %v", ext, err)) 478 | } 479 | fileTypes = append(fileTypes, fileType{ 480 | driver: driver, 481 | f: f, 482 | ext: extRE, 483 | }) 484 | } 485 | 486 | // fileType wraps file type information. 487 | type fileType struct { 488 | driver string 489 | f func([]byte) bool 490 | ext *regexp.Regexp 491 | } 492 | 493 | // FileTypes returns the registered file types. 494 | func FileTypes() []string { 495 | var v []string 496 | for _, typ := range fileTypes { 497 | v = append(v, typ.driver) 498 | } 499 | return v 500 | } 501 | 502 | // Protocols returns list of all valid protocol aliases for a registered 503 | // [Scheme] name. 504 | func Protocols(name string) []string { 505 | if scheme, ok := schemeMap[name]; ok { 506 | return append([]string{scheme.Driver}, scheme.Aliases...) 507 | } 508 | return nil 509 | } 510 | 511 | // SchemeDriverAndAliases returns the registered driver and aliases for a 512 | // database scheme. 513 | func SchemeDriverAndAliases(name string) (string, []string) { 514 | if scheme, ok := schemeMap[name]; ok { 515 | driver := scheme.Driver 516 | if scheme.Override != "" { 517 | driver = scheme.Override 518 | } 519 | var aliases []string 520 | for _, alias := range scheme.Aliases { 521 | if alias == driver { 522 | continue 523 | } 524 | aliases = append(aliases, alias) 525 | } 526 | sort.Slice(aliases, func(i, j int) bool { 527 | if len(aliases[i]) <= len(aliases[j]) { 528 | return true 529 | } 530 | if len(aliases[j]) < len(aliases[i]) { 531 | return false 532 | } 533 | return aliases[i] < aliases[j] 534 | }) 535 | return driver, aliases 536 | } 537 | return "", nil 538 | } 539 | 540 | // ShortAlias returns the short alias for the scheme name. 541 | func ShortAlias(name string) string { 542 | if scheme, ok := schemeMap[name]; ok { 543 | return scheme.Aliases[0] 544 | } 545 | return "" 546 | } 547 | 548 | // isSqlite3Header returns true when the passed header is empty or starts with 549 | // the SQLite3 header. 550 | // 551 | // See: https://www.sqlite.org/fileformat.html 552 | func isSqlite3Header(buf []byte) bool { 553 | return bytes.HasPrefix(buf, sqlite3Header) 554 | } 555 | 556 | // sqlite3Header is the sqlite3 header. 557 | var sqlite3Header = []byte("SQLite format 3\000") 558 | 559 | // isDuckdbHeader returns true when the passed header is a DuckDB header. 560 | // 561 | // See: https://duckdb.org/internals/storage 562 | func isDuckdbHeader(buf []byte) bool { 563 | return duckdbRE.Match(buf) 564 | } 565 | 566 | // duckdbRE is the duckdb storage header regexp. 567 | var duckdbRE = regexp.MustCompile(`^.{8}DUCK.{8}`) 568 | -------------------------------------------------------------------------------- /dburl.go: -------------------------------------------------------------------------------- 1 | // Package dburl provides a standard, [net/url.URL] style mechanism for parsing 2 | // and opening SQL database connection strings for Go. Provides standardized 3 | // way to parse and open [URL]'s for popular databases PostgreSQL, MySQL, SQLite3, 4 | // Oracle Database, Microsoft SQL Server, in addition to most other SQL 5 | // databases with a publicly available Go driver. 6 | // 7 | // See the [package documentation README section] for more details. 8 | // 9 | // [package documentation README section]: https://pkg.go.dev/github.com/xo/dburl#section-readme 10 | package dburl 11 | 12 | import ( 13 | "database/sql" 14 | "fmt" 15 | "io/fs" 16 | "net/url" 17 | "os" 18 | "path" 19 | "path/filepath" 20 | "runtime" 21 | "strings" 22 | ) 23 | 24 | // ResolveSchemeType is a configuration setting to open paths on disk using 25 | // [SchemeType], [Stat], and [OpenFile]. Set this to false in an `init()` func 26 | // in order to disable this behavior. 27 | var ResolveSchemeType = true 28 | 29 | // Open takes a URL string, also known as a DSN, in the form of 30 | // "protocol+transport://user:pass@host/dbname?option1=a&option2=b" and opens a 31 | // standard [sql.DB] connection. 32 | // 33 | // See [Parse] for information on formatting URL strings to work properly with Open. 34 | func Open(urlstr string) (*sql.DB, error) { 35 | u, err := Parse(urlstr) 36 | if err != nil { 37 | return nil, err 38 | } 39 | driver := u.Driver 40 | if u.GoDriver != "" { 41 | driver = u.GoDriver 42 | } 43 | return sql.Open(driver, u.DSN) 44 | } 45 | 46 | // OpenMap takes a map of URL components and opens a standard [sql.DB] connection. 47 | // 48 | // See [BuildURL] for information on the recognized map components. 49 | func OpenMap(components map[string]any) (*sql.DB, error) { 50 | urlstr, err := BuildURL(components) 51 | if err != nil { 52 | return nil, err 53 | } 54 | return Open(urlstr) 55 | } 56 | 57 | // URL wraps the standard [net/url.URL] type, adding OriginalScheme, Transport, 58 | // Driver, Unaliased, and DSN strings. 59 | type URL struct { 60 | // URL is the base [net/url.URL]. 61 | url.URL 62 | // OriginalScheme is the original parsed scheme (ie, "sq", "mysql+unix", "sap", etc). 63 | OriginalScheme string 64 | // Transport is the specified transport protocol (ie, "tcp", "udp", 65 | // "unix", ...), if provided. 66 | Transport string 67 | // Driver is the non-aliased SQL driver name that should be used in a call 68 | // to [sql.Open]. 69 | Driver string 70 | // GoDriver is the Go SQL driver name to use when opening a connection to 71 | // the database. Used by Microsoft SQL Server's azuresql:// URLs, as the 72 | // wire-compatible alias style uses a different syntax style. 73 | GoDriver string 74 | // UnaliasedDriver is the unaliased driver name. 75 | UnaliasedDriver string 76 | // DSN is the built connection "data source name" that can be used in a 77 | // call to [sql.Open]. 78 | DSN string 79 | // hostPortDB will be set by Gen*() funcs after determining the host, port, 80 | // database. 81 | // 82 | // When empty, indicates that these values are not special, and can be 83 | // retrieved as the host, port, and path[1:] as usual. 84 | hostPortDB []string 85 | } 86 | 87 | // Parse parses a URL string, similar to the standard [net/url.Parse]. 88 | // 89 | // Handles parsing OriginalScheme, Transport, Driver, Unaliased, and DSN 90 | // fields. 91 | // 92 | // Note: if the URL has a Opaque component (ie, URLs not specified as 93 | // "scheme://" but "scheme:"), and the database scheme does not support opaque 94 | // components, Parse will attempt to re-process the URL as "scheme://". 95 | func Parse(urlstr string) (*URL, error) { 96 | // parse url 97 | v, err := url.Parse(urlstr) 98 | switch { 99 | case err != nil: 100 | return nil, err 101 | case v.Scheme == "": 102 | if ResolveSchemeType { 103 | if typ, err := SchemeType(urlstr); err == nil { 104 | return Parse(typ + ":" + urlstr) 105 | } 106 | } 107 | return nil, ErrInvalidDatabaseScheme 108 | } 109 | // create url 110 | u := &URL{ 111 | URL: *v, 112 | OriginalScheme: urlstr[:len(v.Scheme)], 113 | Transport: "tcp", 114 | } 115 | // check for +transport in scheme 116 | var checkTransport bool 117 | if i := strings.IndexRune(u.Scheme, '+'); i != -1 { 118 | u.Transport = urlstr[i+1 : len(v.Scheme)] 119 | u.Scheme = u.Scheme[:i] 120 | checkTransport = true 121 | } 122 | // get dsn generator 123 | scheme, ok := schemeMap[u.Scheme] 124 | switch { 125 | case !ok: 126 | return nil, ErrUnknownDatabaseScheme 127 | case scheme.Driver == "file": 128 | // determine scheme for file 129 | s := u.opaqueOrPath() 130 | switch { 131 | case u.Transport != "tcp", 132 | strings.Contains(u.OriginalScheme, "+"): 133 | return nil, ErrInvalidTransportProtocol 134 | case s == "": 135 | return nil, ErrMissingPath 136 | case ResolveSchemeType: 137 | if typ, err := SchemeType(s); err == nil { 138 | return Parse(typ + "://" + u.buildOpaque()) 139 | } 140 | } 141 | return nil, ErrUnknownFileExtension 142 | case !scheme.Opaque && u.Opaque != "": 143 | // if scheme does not understand opaque URLs, retry parsing after 144 | // building fully qualified URL 145 | return Parse(u.OriginalScheme + "://" + u.buildOpaque()) 146 | case scheme.Opaque && u.Opaque == "": 147 | // force Opaque 148 | u.Opaque, u.Host, u.Path, u.RawPath = u.Host+u.Path, "", "", "" 149 | case u.Host == ".", u.Host == "" && strings.TrimPrefix(u.Path, "/") != "": 150 | // force unix proto 151 | u.Transport = "unix" 152 | } 153 | // check transport 154 | if checkTransport || u.Transport != "tcp" { 155 | switch { 156 | case scheme.Transport == TransportNone: 157 | return nil, ErrInvalidTransportProtocol 158 | case scheme.Transport&TransportAny != 0 && u.Transport != "", 159 | scheme.Transport&TransportTCP != 0 && u.Transport == "tcp", 160 | scheme.Transport&TransportUDP != 0 && u.Transport == "udp", 161 | scheme.Transport&TransportUnix != 0 && u.Transport == "unix": 162 | default: 163 | return nil, ErrInvalidTransportProtocol 164 | } 165 | } 166 | // set driver 167 | u.Driver, u.UnaliasedDriver = scheme.Driver, scheme.Driver 168 | if scheme.Override != "" { 169 | u.Driver = scheme.Override 170 | } 171 | // generate dsn 172 | if u.DSN, u.GoDriver, err = scheme.Generator(u); err != nil { 173 | return nil, err 174 | } 175 | return u, nil 176 | } 177 | 178 | // FromMap creates a [URL] using the mapped components. 179 | // 180 | // Recognized components are: 181 | // 182 | // protocol, proto, scheme 183 | // transport 184 | // username, user 185 | // password, pass 186 | // hostname, host 187 | // port 188 | // path, file, opaque 189 | // database, dbname, db 190 | // instance 191 | // parameters, params, options, opts, query, q 192 | // 193 | // See [BuildURL] for more information. 194 | func FromMap(components map[string]any) (*URL, error) { 195 | urlstr, err := BuildURL(components) 196 | if err != nil { 197 | return nil, err 198 | } 199 | return Parse(urlstr) 200 | } 201 | 202 | // String satisfies the [fmt.Stringer] interface. 203 | func (u *URL) String() string { 204 | p := &url.URL{ 205 | Scheme: u.OriginalScheme, 206 | Opaque: u.Opaque, 207 | User: u.User, 208 | Host: u.Host, 209 | Path: u.Path, 210 | RawPath: u.RawPath, 211 | RawQuery: u.RawQuery, 212 | Fragment: u.Fragment, 213 | } 214 | return p.String() 215 | } 216 | 217 | // Short provides a short description of the user, host, and database. 218 | func (u *URL) Short() string { 219 | if u.Scheme == "" { 220 | return "" 221 | } 222 | s := schemeMap[u.Scheme].Aliases[0] 223 | if u.Scheme == "odbc" || u.Scheme == "oleodbc" { 224 | n := u.Transport 225 | if v, ok := schemeMap[n]; ok { 226 | n = v.Aliases[0] 227 | } 228 | s += "+" + n 229 | } else if u.Transport != "tcp" { 230 | s += "+" + u.Transport 231 | } 232 | s += ":" 233 | if u.User != nil { 234 | if n := u.User.Username(); n != "" { 235 | s += n + "@" 236 | } 237 | } 238 | if u.Host != "" { 239 | s += u.Host 240 | } 241 | if u.Path != "" && u.Path != "/" { 242 | s += u.Path 243 | } 244 | if u.Opaque != "" { 245 | s += u.Opaque 246 | } 247 | return s 248 | } 249 | 250 | // Normalize returns the driver, host, port, database, and user name of a URL, 251 | // joined with sep, populating blank fields with empty. 252 | func (u *URL) Normalize(sep, empty string, cut int) string { 253 | s := []string{u.UnaliasedDriver, "", "", "", ""} 254 | if u.Transport != "tcp" && u.Transport != "unix" { 255 | s[0] += "+" + u.Transport 256 | } 257 | // set host port dbname fields 258 | if u.hostPortDB == nil { 259 | if u.Opaque != "" { 260 | u.hostPortDB = []string{u.Opaque, "", ""} 261 | } else { 262 | u.hostPortDB = []string{u.Hostname(), u.Port(), strings.TrimPrefix(u.Path, "/")} 263 | } 264 | } 265 | copy(s[1:], u.hostPortDB) 266 | // set user 267 | if u.User != nil { 268 | s[4] = u.User.Username() 269 | } 270 | // replace blank entries ... 271 | for i := 0; i < len(s); i++ { 272 | if s[i] == "" { 273 | s[i] = empty 274 | } 275 | } 276 | if cut > 0 { 277 | // cut to only populated fields 278 | i := len(s) - 1 279 | for ; i > cut; i-- { 280 | if s[i] != "" { 281 | break 282 | } 283 | } 284 | s = s[:i] 285 | } 286 | return strings.Join(s, sep) 287 | } 288 | 289 | // buildOpaque builds a opaque path. 290 | func (u *URL) buildOpaque() string { 291 | var up string 292 | if u.User != nil { 293 | up = u.User.String() + "@" 294 | } 295 | var q string 296 | if u.RawQuery != "" { 297 | q = "?" + u.RawQuery 298 | } 299 | var f string 300 | if u.Fragment != "" { 301 | f = "#" + u.Fragment 302 | } 303 | return up + u.opaqueOrPath() + q + f 304 | } 305 | 306 | // opaqueOrPath returns the opaque or path value. 307 | func (u *URL) opaqueOrPath() string { 308 | if u.Opaque != "" { 309 | return u.Opaque 310 | } 311 | return u.Path 312 | } 313 | 314 | // SchemeType returns the scheme type for a path. 315 | func SchemeType(name string) (string, error) { 316 | // try to resolve the path on unix systems 317 | if runtime.GOOS != "windows" { 318 | if typ, ok := resolveType(name); ok { 319 | return typ, nil 320 | } 321 | } 322 | if f, err := OpenFile(name); err == nil { 323 | defer f.Close() 324 | // file exists, match header 325 | buf := make([]byte, 64) 326 | if n, _ := f.Read(buf); n == 0 { 327 | return "sqlite3", nil 328 | } 329 | for _, typ := range fileTypes { 330 | if typ.f(buf) { 331 | return typ.driver, nil 332 | } 333 | } 334 | return "", ErrUnknownFileHeader 335 | } 336 | // doesn't exist, match file extension 337 | ext := filepath.Ext(name) 338 | for _, typ := range fileTypes { 339 | if typ.ext.MatchString(ext) { 340 | return typ.driver, nil 341 | } 342 | } 343 | return "", ErrUnknownFileExtension 344 | } 345 | 346 | // Error is an error. 347 | type Error string 348 | 349 | // Error satisfies the error interface. 350 | func (err Error) Error() string { 351 | return string(err) 352 | } 353 | 354 | // Error values. 355 | const ( 356 | // ErrInvalidDatabaseScheme is the invalid database scheme error. 357 | ErrInvalidDatabaseScheme Error = "invalid database scheme" 358 | // ErrUnknownDatabaseScheme is the unknown database type error. 359 | ErrUnknownDatabaseScheme Error = "unknown database scheme" 360 | // ErrUnknownFileHeader is the unknown file header error. 361 | ErrUnknownFileHeader Error = "unknown file header" 362 | // ErrUnknownFileExtension is the unknown file extension error. 363 | ErrUnknownFileExtension Error = "unknown file extension" 364 | // ErrInvalidTransportProtocol is the invalid transport protocol error. 365 | ErrInvalidTransportProtocol Error = "invalid transport protocol" 366 | // ErrRelativePathNotSupported is the relative paths not supported error. 367 | ErrRelativePathNotSupported Error = "relative path not supported" 368 | // ErrMissingHost is the missing host error. 369 | ErrMissingHost Error = "missing host" 370 | // ErrMissingPath is the missing path error. 371 | ErrMissingPath Error = "missing path" 372 | // ErrMissingUser is the missing user error. 373 | ErrMissingUser Error = "missing user" 374 | // ErrInvalidQuery is the invalid query error. 375 | ErrInvalidQuery Error = "invalid query" 376 | ) 377 | 378 | // Stat is the default stat func. 379 | // 380 | // Used internally to stat files, and used when generating the DSNs for 381 | // postgres://, mysql://, file:// schemes, and opaque [URL]'s. 382 | var Stat = func(name string) (fs.FileInfo, error) { 383 | return fs.Stat(os.DirFS(filepath.Dir(name)), filepath.Base(name)) 384 | } 385 | 386 | // OpenFile is the default open file func. 387 | // 388 | // Used internally to read file headers. 389 | var OpenFile = func(name string) (fs.File, error) { 390 | return os.OpenFile(name, os.O_RDONLY, 0) 391 | } 392 | 393 | // BuildURL creates a dsn using the mapped components. 394 | // 395 | // Recognized components are: 396 | // 397 | // protocol, proto, scheme 398 | // transport 399 | // username, user 400 | // password, pass 401 | // hostname, host 402 | // port 403 | // path, file, opaque 404 | // database, dbname, db 405 | // instance 406 | // parameters, params, options, opts, query, q 407 | // 408 | // See [BuildURL] for more information. 409 | func BuildURL(components map[string]any) (string, error) { 410 | if components == nil { 411 | return "", ErrInvalidDatabaseScheme 412 | } 413 | var urlstr string 414 | if proto, ok := getComponent(components, "protocol", "proto", "scheme"); ok { 415 | if transport, ok := getComponent(components, "transport"); ok { 416 | proto += "+" + transport 417 | } 418 | urlstr = proto + ":" 419 | } 420 | if host, ok := getComponent(components, "hostname", "host"); ok { 421 | hostinfo := url.QueryEscape(host) 422 | if port, ok := getComponent(components, "port"); ok { 423 | hostinfo += ":" + port 424 | } 425 | var userinfo string 426 | if user, ok := getComponent(components, "username", "user"); ok { 427 | userinfo += url.QueryEscape(user) 428 | if pass, ok := getComponent(components, "password", "pass"); ok { 429 | userinfo += ":" + url.QueryEscape(pass) 430 | } 431 | hostinfo = userinfo + "@" + hostinfo 432 | } 433 | urlstr += "//" + hostinfo 434 | } 435 | if pathstr, ok := getComponent(components, "path", "file", "opaque"); ok { 436 | if urlstr == "" { 437 | urlstr += "file:" 438 | } 439 | urlstr += pathstr 440 | } else { 441 | var v []string 442 | if instance, ok := getComponent(components, "instance"); ok { 443 | v = append(v, url.PathEscape(instance)) 444 | } 445 | if dbname, ok := getComponent(components, "database", "dbname", "db"); ok { 446 | v = append(v, url.PathEscape(dbname)) 447 | } 448 | if len(v) != 0 { 449 | if s := path.Join(v...); s != "" { 450 | urlstr += "/" + s 451 | } 452 | } 453 | } 454 | if v, ok := getFirst(components, "parameters", "params", "options", "opts", "query", "q"); ok { 455 | switch z := v.(type) { 456 | case string: 457 | if z != "" { 458 | urlstr += "?" + z 459 | } 460 | case map[string]any: 461 | q := url.Values{} 462 | for k, v := range z { 463 | q.Set(k, fmt.Sprintf("%v", v)) 464 | } 465 | if s := q.Encode(); s != "" { 466 | urlstr += "?" + s 467 | } 468 | default: 469 | return "", ErrInvalidQuery 470 | } 471 | } 472 | return urlstr, nil 473 | } 474 | 475 | // resolveType tries to resolve a path to a Unix domain socket or directory. 476 | func resolveType(s string) (string, bool) { 477 | if i := strings.LastIndex(s, "?"); i != -1 { 478 | if _, err := Stat(s[:i]); err == nil { 479 | s = s[:i] 480 | } 481 | } 482 | dir := s 483 | for dir != "" && dir != "/" && dir != "." { 484 | // chop off :4444 port 485 | i, j := strings.LastIndex(dir, ":"), strings.LastIndex(dir, "/") 486 | if i != -1 && i > j { 487 | dir = dir[:i] 488 | } 489 | switch fi, err := Stat(dir); { 490 | case err == nil && fi.IsDir(): 491 | return "postgres", true 492 | case err == nil && fi.Mode()&fs.ModeSocket != 0: 493 | return "mysql", true 494 | case err == nil: 495 | return "", false 496 | } 497 | if j != -1 { 498 | dir = dir[:j] 499 | } else { 500 | dir = "" 501 | } 502 | } 503 | return "", false 504 | } 505 | 506 | // resolveSocket tries to resolve a path to a Unix domain socket based on the 507 | // form "/path/to/socket/dbname" returning either the original path and the 508 | // empty string, or the components "/path/to/socket" and "dbname", when 509 | // /path/to/socket/dbname is reported by Stat as a socket. 510 | func resolveSocket(s string) (string, string) { 511 | dir, dbname := s, "" 512 | for dir != "" && dir != "/" && dir != "." { 513 | if mode(dir)&fs.ModeSocket != 0 { 514 | return dir, dbname 515 | } 516 | dir, dbname = path.Dir(dir), path.Base(dir) 517 | } 518 | return s, "" 519 | } 520 | 521 | // resolveDir resolves a directory with a :port list. 522 | func resolveDir(s string) (string, string, string) { 523 | dir := s 524 | for dir != "" && dir != "/" && dir != "." { 525 | port := "" 526 | i, j := strings.LastIndex(dir, ":"), strings.LastIndex(dir, "/") 527 | if i != -1 && i > j { 528 | port, dir = dir[i+1:], dir[:i] 529 | } 530 | if mode(dir)&fs.ModeDir != 0 { 531 | dbname := strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(s, dir), ":"+port), "/") 532 | return dir, port, dbname 533 | } 534 | if j != -1 { 535 | dir = dir[:j] 536 | } else { 537 | dir = "" 538 | } 539 | } 540 | return s, "", "" 541 | } 542 | 543 | // mode returns the mode of the path. 544 | func mode(s string) os.FileMode { 545 | if fi, err := Stat(s); err == nil { 546 | return fi.Mode() 547 | } 548 | return 0 549 | } 550 | 551 | // getComponent returns the first defined component in the map. 552 | func getComponent(m map[string]any, v ...string) (string, bool) { 553 | if z, ok := getFirst(m, v...); ok { 554 | str := fmt.Sprintf("%v", z) 555 | return str, str != "" 556 | } 557 | return "", false 558 | } 559 | 560 | // getFirst returns the first value in the map. 561 | func getFirst(m map[string]any, v ...string) (any, bool) { 562 | for _, s := range v { 563 | if z, ok := m[s]; ok { 564 | return z, ok 565 | } 566 | } 567 | return nil, false 568 | } 569 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About dburl 2 | 3 | Package `dburl` provides a standard, URL style mechanism for parsing and 4 | opening SQL database connection strings for [Go][go-project]. Provides 5 | standardized way to [parse][goref-parse] and [open][goref-open] URLs for 6 | popular databases PostgreSQL, MySQL, SQLite3, Oracle Database, Microsoft SQL 7 | Server, in addition to most other SQL databases with a publicly available Go 8 | driver. 9 | 10 | [Overview][] | [Quickstart][] | [Examples][] | [Schemes][] | [Installing][] | [Using][] | [About][] 11 | 12 | [Overview]: #database-connection-url-overview "Database Connection URL Overview" 13 | [Quickstart]: #quickstart "Quickstart" 14 | [Examples]: #example-urls "Example URLs" 15 | [Schemes]: #database-schemes-aliases-and-drivers "Database Schemes, Aliases, and Drivers" 16 | [Installing]: #installing "Installing" 17 | [Using]: #using "Using" 18 | [About]: #about "About" 19 | 20 | [![Unit Tests][dburl-ci-status]][dburl-ci] 21 | [![Go Reference][goref-dburl-status]][goref-dburl] 22 | [![Discord Discussion][discord-status]][discord] 23 | 24 | [dburl-ci]: https://github.com/xo/dburl/actions/workflows/test.yml 25 | [dburl-ci-status]: https://github.com/xo/dburl/actions/workflows/test.yml/badge.svg 26 | [goref-dburl]: https://pkg.go.dev/github.com/xo/dburl 27 | [goref-dburl-status]: https://pkg.go.dev/badge/github.com/xo/dburl.svg 28 | [discord]: https://discord.gg/yJKEzc7prt "Discord Discussion" 29 | [discord-status]: https://img.shields.io/discord/829150509658013727.svg?label=Discord&logo=Discord&colorB=7289da&style=flat-square "Discord Discussion" 30 | 31 | ## Database Connection URL Overview 32 | 33 | Supported database connection URLs are of the form: 34 | 35 | ```text 36 | protocol+transport://user:pass@host/dbname?opt1=a&opt2=b 37 | protocol:/path/to/file 38 | ``` 39 | 40 | Where: 41 | 42 | | Component | Description | 43 | | ------------------- | ------------------------------------------------------------------------------------ | 44 | | protocol | driver name or alias (see below) | 45 | | transport | "tcp", "udp", "unix" or driver name (odbc/oleodbc) | 46 | | user | username | 47 | | pass | password | 48 | | host | host | 49 | | dbname\* | database, instance, or service name/ID to connect to | 50 | | ?opt1=... | additional database driver options (see respective SQL driver for available options) | 51 | 52 | \* for Microsoft SQL Server, `/dbname` can be 53 | `/instance/dbname`, where `/instance` is optional. For Oracle Database, 54 | `/dbname` is of the form `/service/dbname` where `/service` is the service name 55 | or SID, and `/dbname` is optional. Please see below for examples. 56 | 57 | ## Quickstart 58 | 59 | Database connection URLs in the above format can be parsed with the 60 | [`dburl.Parse` func][goref-parse] as such: 61 | 62 | ```go 63 | import ( 64 | "github.com/xo/dburl" 65 | ) 66 | 67 | u, err := dburl.Parse("postgresql://user:pass@localhost/mydatabase/?sslmode=disable") 68 | if err != nil { /* ... */ } 69 | ``` 70 | 71 | Additionally, a simple helper, [`dburl.Open`][goref-open], is provided that 72 | will parse, open, and return a [standard `sql.DB` database][goref-sql-db] 73 | connection: 74 | 75 | ```go 76 | import ( 77 | "github.com/xo/dburl" 78 | ) 79 | 80 | db, err := dburl.Open("sqlite:mydatabase.sqlite3?loc=auto") 81 | if err != nil { /* ... */ } 82 | ``` 83 | 84 | ## Example URLs 85 | 86 | The following are example database connection URLs that can be handled by 87 | [`dburl.Parse`][goref-parse] and [`dburl.Open`][goref-open]: 88 | 89 | ```text 90 | postgres://user:pass@localhost/dbname 91 | pg://user:pass@localhost/dbname?sslmode=disable 92 | mysql://user:pass@localhost/dbname 93 | mysql:/var/run/mysqld/mysqld.sock 94 | sqlserver://user:pass@remote-host.com/dbname 95 | mssql://user:pass@remote-host.com/instance/dbname 96 | ms://user:pass@remote-host.com:port/instance/dbname?keepAlive=10 97 | oracle://user:pass@somehost.com/sid 98 | sap://user:pass@localhost/dbname 99 | sqlite:/path/to/file.db 100 | file:myfile.sqlite3?loc=auto 101 | odbc+postgres://user:pass@localhost:port/dbname?option1= 102 | ``` 103 | 104 | ## Database Schemes, Aliases, and Drivers 105 | 106 | The following table lists the supported `dburl` protocol schemes (ie, driver), 107 | additional aliases, and the related Go driver: 108 | 109 | 110 | 111 | | Database | Scheme / Tag | Scheme Aliases | Driver Package / Notes | 112 | | -------------------- | --------------- | ----------------------------------------------- | --------------------------------------------------------------------------- | 113 | | PostgreSQL | `postgres` | `pg`, `pgsql`, `postgresql` | [github.com/lib/pq][d-postgres] | 114 | | MySQL | `mysql` | `my`, `maria`, `aurora`, `mariadb`, `percona` | [github.com/go-sql-driver/mysql][d-mysql] | 115 | | Microsoft SQL Server | `sqlserver` | `ms`, `mssql`, `azuresql` | [github.com/microsoft/go-mssqldb][d-sqlserver] | 116 | | Oracle Database | `oracle` | `or`, `ora`, `oci`, `oci8`, `odpi`, `odpi-c` | [github.com/sijms/go-ora/v2][d-oracle] | 117 | | SQLite3 | `sqlite3` | `sq`, `sqlite`, `file` | [github.com/mattn/go-sqlite3][d-sqlite3] [†][f-cgo] | 118 | | ClickHouse | `clickhouse` | `ch` | [github.com/ClickHouse/clickhouse-go/v2][d-clickhouse] | 119 | | CSVQ | `csvq` | `cs`, `csv`, `tsv`, `json` | [github.com/mithrandie/csvq-driver][d-csvq] | 120 | | | | | | 121 | | Alibaba MaxCompute | `maxcompute` | `mc` | [sqlflow.org/gomaxcompute][d-maxcompute] | 122 | | Alibaba Tablestore | `ots` | `ot`, `tablestore` | [github.com/aliyun/aliyun-tablestore-go-sql-driver][d-ots] | 123 | | Apache Avatica | `avatica` | `av`, `phoenix` | [github.com/apache/calcite-avatica-go/v5][d-avatica] | 124 | | Apache H2 | `h2` | | [github.com/jmrobles/h2go][d-h2] | 125 | | Apache Hive | `hive` | `hi`, `hive2` | [sqlflow.org/gohive][d-hive] | 126 | | Apache Ignite | `ignite` | `ig`, `gridgain` | [github.com/amsokol/ignite-go-client/sql][d-ignite] | 127 | | Apache Impala | `impala` | `im` | [github.com/sclgo/impala-go][d-impala] | 128 | | AWS Athena | `athena` | `s3`, `aws`, `awsathena` | [github.com/uber/athenadriver/go][d-athena] | 129 | | Azure CosmosDB | `cosmos` | `cm`, `gocosmos` | [github.com/btnguyen2k/gocosmos][d-cosmos] | 130 | | Cassandra | `cassandra` | `ca`, `scy`, `scylla`, `datastax`, `cql` | [github.com/MichaelS11/go-cql-driver][d-cassandra] | 131 | | ChaiSQL | `chai` | `ci`, `genji`, `chaisql` | [github.com/chaisql/chai][d-chai] | 132 | | Couchbase | `couchbase` | `n1`, `n1ql` | [github.com/couchbase/go_n1ql][d-couchbase] | 133 | | Cznic QL | `ql` | `cznic`, `cznicql` | [modernc.org/ql][d-ql] | 134 | | Databend | `databend` | `dd`, `bend` | [github.com/datafuselabs/databend-go][d-databend] | 135 | | Databricks | `databricks` | `br`, `brick`, `bricks`, `databrick` | [github.com/databricks/databricks-sql-go][d-databricks] | 136 | | DuckDB | `duckdb` | `dk`, `ddb`, `duck`, `file` | [github.com/duckdb/duckdb-go/v2][d-duckdb] [†][f-cgo] | 137 | | DynamoDb | `dynamodb` | `dy`, `dyn`, `dynamo`, `dynamodb` | [github.com/btnguyen2k/godynamo][d-dynamodb] | 138 | | Exasol | `exasol` | `ex`, `exa` | [github.com/exasol/exasol-driver-go][d-exasol] | 139 | | Firebird | `firebird` | `fb`, `firebirdsql` | [github.com/nakagami/firebirdsql][d-firebird] | 140 | | FlightSQL | `flightsql` | `fl`, `flight` | [github.com/apache/arrow/go/v17/arrow/flight/flightsql/driver][d-flightsql] | 141 | | Google BigQuery | `bigquery` | `bq` | [gorm.io/driver/bigquery/driver][d-bigquery] | 142 | | Google Spanner | `spanner` | `sp` | [github.com/googleapis/go-sql-spanner][d-spanner] | 143 | | Microsoft ADODB | `adodb` | `ad`, `ado` | [github.com/mattn/go-adodb][d-adodb] | 144 | | ModernC SQLite3 | `moderncsqlite` | `mq`, `modernsqlite` | [modernc.org/sqlite][d-moderncsqlite] | 145 | | MySQL MyMySQL | `mymysql` | `zm`, `mymy` | [github.com/ziutek/mymysql/godrv][d-mymysql] | 146 | | Netezza | `netezza` | `nz`, `nzgo` | [github.com/IBM/nzgo/v12][d-netezza] | 147 | | PostgreSQL PGX | `pgx` | `px` | [github.com/jackc/pgx/v5/stdlib][d-pgx] | 148 | | Presto | `presto` | `pr`, `prs`, `prestos`, `prestodb`, `prestodbs` | [github.com/prestodb/presto-go-client/presto][d-presto] | 149 | | RamSQL | `ramsql` | `rm`, `ram` | [github.com/proullon/ramsql/driver][d-ramsql] | 150 | | SAP ASE | `sapase` | `ax`, `ase`, `tds` | [github.com/thda/tds][d-sapase] | 151 | | SAP HANA | `saphana` | `sa`, `sap`, `hana`, `hdb` | [github.com/SAP/go-hdb/driver][d-saphana] | 152 | | Snowflake | `snowflake` | `sf` | [github.com/snowflakedb/gosnowflake][d-snowflake] | 153 | | Trino | `trino` | `tr`, `trs`, `trinos` | [github.com/trinodb/trino-go-client/trino][d-trino] | 154 | | Vertica | `vertica` | `ve` | [github.com/vertica/vertica-sql-go][d-vertica] | 155 | | VoltDB | `voltdb` | `vo`, `vdb`, `volt` | [github.com/VoltDB/voltdb-client-go/voltdbclient][d-voltdb] | 156 | | YDB | `ydb` | `yd`, `yds`, `ydbs` | [github.com/ydb-platform/ydb-go-sdk/v3][d-ydb] | 157 | | | | | | 158 | | GO DRiver for ORacle | `godror` | `gr` | [github.com/godror/godror][d-godror] [†][f-cgo] | 159 | | ODBC | `odbc` | `od` | [github.com/alexbrainman/odbc][d-odbc] [†][f-cgo] | 160 | | | | | | 161 | | Amazon Redshift | `postgres` | `rs`, `redshift` | [github.com/lib/pq][d-postgres] [‡][f-wire] | 162 | | CockroachDB | `postgres` | `cr`, `cdb`, `crdb`, `cockroach`, `cockroachdb` | [github.com/lib/pq][d-postgres] [‡][f-wire] | 163 | | OLE ODBC | `adodb` | `oo`, `ole`, `oleodbc` | [github.com/mattn/go-adodb][d-adodb] [‡][f-wire] | 164 | | SingleStore MemSQL | `mysql` | `me`, `memsql` | [github.com/go-sql-driver/mysql][d-mysql] [‡][f-wire] | 165 | | TiDB | `mysql` | `ti`, `tidb` | [github.com/go-sql-driver/mysql][d-mysql] [‡][f-wire] | 166 | | Vitess Database | `mysql` | `vt`, `vitess` | [github.com/go-sql-driver/mysql][d-mysql] [‡][f-wire] | 167 | | | | | | 168 | | | | | | 169 | 170 | [d-adodb]: https://github.com/mattn/go-adodb 171 | [d-athena]: https://github.com/uber/athenadriver 172 | [d-avatica]: https://github.com/apache/calcite-avatica-go 173 | [d-bigquery]: https://github.com/go-gorm/bigquery 174 | [d-cassandra]: https://github.com/MichaelS11/go-cql-driver 175 | [d-chai]: https://github.com/chaisql/chai 176 | [d-clickhouse]: https://github.com/ClickHouse/clickhouse-go 177 | [d-cosmos]: https://github.com/btnguyen2k/gocosmos 178 | [d-couchbase]: https://github.com/couchbase/go_n1ql 179 | [d-csvq]: https://github.com/mithrandie/csvq-driver 180 | [d-databend]: https://github.com/datafuselabs/databend-go 181 | [d-databricks]: https://github.com/databricks/databricks-sql-go 182 | [d-duckdb]: https://github.com/duckdb/duckdb-go 183 | [d-dynamodb]: https://github.com/btnguyen2k/godynamo 184 | [d-exasol]: https://github.com/exasol/exasol-driver-go 185 | [d-firebird]: https://github.com/nakagami/firebirdsql 186 | [d-flightsql]: https://github.com/apache/arrow/tree/main/go/arrow/flight/flightsql/driver 187 | [d-godror]: https://github.com/godror/godror 188 | [d-h2]: https://github.com/jmrobles/h2go 189 | [d-hive]: https://github.com/sql-machine-learning/gohive 190 | [d-ignite]: https://github.com/amsokol/ignite-go-client 191 | [d-impala]: https://github.com/sclgo/impala-go 192 | [d-maxcompute]: https://github.com/sql-machine-learning/gomaxcompute 193 | [d-moderncsqlite]: https://gitlab.com/cznic/sqlite 194 | [d-mymysql]: https://github.com/ziutek/mymysql 195 | [d-mysql]: https://github.com/go-sql-driver/mysql 196 | [d-netezza]: https://github.com/IBM/nzgo 197 | [d-odbc]: https://github.com/alexbrainman/odbc 198 | [d-oracle]: https://github.com/sijms/go-ora 199 | [d-ots]: https://github.com/aliyun/aliyun-tablestore-go-sql-driver 200 | [d-pgx]: https://github.com/jackc/pgx 201 | [d-postgres]: https://github.com/lib/pq 202 | [d-presto]: https://github.com/prestodb/presto-go-client 203 | [d-ql]: https://gitlab.com/cznic/ql 204 | [d-ramsql]: https://github.com/proullon/ramsql 205 | [d-sapase]: https://github.com/thda/tds 206 | [d-saphana]: https://github.com/SAP/go-hdb 207 | [d-snowflake]: https://github.com/snowflakedb/gosnowflake 208 | [d-spanner]: https://github.com/googleapis/go-sql-spanner 209 | [d-sqlite3]: https://github.com/mattn/go-sqlite3 210 | [d-sqlserver]: https://github.com/microsoft/go-mssqldb 211 | [d-trino]: https://github.com/trinodb/trino-go-client 212 | [d-vertica]: https://github.com/vertica/vertica-sql-go 213 | [d-voltdb]: https://github.com/VoltDB/voltdb-client-go 214 | [d-ydb]: https://github.com/ydb-platform/ydb-go-sdk 215 | 216 | 217 | 218 | [f-cgo]: #f-cgo "Requires CGO" 219 | [f-wire]: #f-wire "Wire compatible" 220 | 221 |

222 | 223 | Requires CGO
224 | Wire compatible (see respective driver) 225 |
226 |

227 | 228 | Any protocol scheme `alias://` can be used in place of `protocol://`, and will 229 | work identically with [`dburl.Parse`][goref-parse] and [`dburl.Open`][goref-open]. 230 | 231 | ## Installing 232 | 233 | Install in the usual Go fashion: 234 | 235 | ```sh 236 | $ go get github.com/xo/dburl@latest 237 | ``` 238 | 239 | ## Using 240 | 241 | `dburl` does not import any of Go's SQL drivers, as it only provides a way to 242 | [parse][goref-parse] and [open][goref-open] database URL stylized connection 243 | strings. As such, it is necessary to explicitly `import` the relevant SQL driver: 244 | 245 | ```go 246 | import ( 247 | // import Microsoft SQL Server driver 248 | _ "github.com/microsoft/go-mssqldb" 249 | ) 250 | ``` 251 | 252 | See the [database schemes table][Schemes] above for a list of the 253 | expected Go driver `import`'s. 254 | 255 | Additional examples and API details can be found in [the `dburl` package 256 | documentation][goref-dburl]. 257 | 258 | ### URL Parsing Rules 259 | 260 | [`dburl.Parse`][goref-parse] and [`dburl.Open`][goref-open] rely primarily on 261 | Go's standard [`net/url.URL`][goref-net-url] type, and as such, parsing or 262 | opening database connection URLs with `dburl` are subject to the same rules, 263 | conventions, and semantics as [Go's `net/url.Parse` func][goref-net-url-parse]. 264 | 265 | ## Example 266 | 267 | A [full example](_example/example.go) for reference: 268 | 269 | ```go 270 | // _example/example.go 271 | package main 272 | 273 | import ( 274 | "fmt" 275 | "log" 276 | 277 | _ "github.com/microsoft/go-mssqldb" 278 | "github.com/xo/dburl" 279 | ) 280 | 281 | func main() { 282 | db, err := dburl.Open("sqlserver://user:pass@localhost/dbname") 283 | if err != nil { 284 | log.Fatal(err) 285 | } 286 | var name string 287 | if err := db.QueryRow(`SELECT name FROM mytable WHERE id=10`).Scan(&name); err != nil { 288 | log.Fatal(err) 289 | } 290 | fmt.Println("name:", name) 291 | } 292 | ``` 293 | 294 | ## Scheme Resolution 295 | 296 | By default on non-Windows systems, `dburl` will resolve paths on disk, and URLs 297 | with `file:` schemes to an appropriate database driver: 298 | 299 | 1. Directories will resolve as `postgres:` URLs 300 | 2. Unix sockets will resolve as `mysql:` URLs 301 | 3. Regular files will have their headers checked to determine if they are 302 | either `sqlite3:` or `duckdb:` files 303 | 4. Non-existent files will test their file extension against well-known 304 | `sqlite3:` and `duckdb:` file extensions and open with the appropriate 305 | scheme 306 | 307 | If this behavior is undesired, it can be disabled by providing different 308 | implementations for [`dburl.Stat`][goref-variables] and [`dburl.OpenFile`][goref-variables], 309 | or alternately by setting [`dburl.ResolveSchemeType`][goref-variables] to false: 310 | 311 | ```go 312 | import "github.com/xo/dburl" 313 | 314 | func init() { 315 | dburl.ResolveSchemeType = false 316 | } 317 | ``` 318 | 319 | ## About 320 | 321 | `dburl` was built primarily to support these projects: 322 | 323 | - [usql][usql] - a universal command-line interface for SQL databases 324 | - [xo][xo] - a command-line tool to generate code for SQL databases 325 | 326 | [go-project]: https://go.dev/project 327 | [goref-open]: https://pkg.go.dev/github.com/xo/dburl#Open 328 | [goref-variables]: https://pkg.go.dev/github.com/xo/dburl#pkg-variables 329 | [goref-parse]: https://pkg.go.dev/github.com/xo/dburl#Parse 330 | [goref-sql-db]: https://pkg.go.dev/database/sql#DB 331 | [goref-net-url]: https://pkg.go.dev/net/url#URL 332 | [goref-net-url-parse]: https://pkg.go.dev/net/url#URL.Parse 333 | [usql]: https://github.com/xo/usql 334 | [xo]: https://github.com/xo/xo 335 | -------------------------------------------------------------------------------- /dsn.go: -------------------------------------------------------------------------------- 1 | package dburl 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "path" 7 | "sort" 8 | "strings" 9 | ) 10 | 11 | // OdbcIgnoreQueryPrefixes are the query prefixes to ignore when generating the 12 | // odbc DSN. Used by [GenOdbc]. 13 | var OdbcIgnoreQueryPrefixes []string 14 | 15 | // GenScheme returns a generator that will generate a scheme based on the 16 | // passed scheme DSN. 17 | func GenScheme(scheme string) func(*URL) (string, string, error) { 18 | return func(u *URL) (string, string, error) { 19 | z := &url.URL{ 20 | Scheme: scheme, 21 | Opaque: u.Opaque, 22 | User: u.User, 23 | Host: u.Host, 24 | Path: u.Path, 25 | RawPath: u.RawPath, 26 | RawQuery: u.RawQuery, 27 | Fragment: u.Fragment, 28 | } 29 | if z.Host == "" { 30 | z.Host = "localhost" 31 | } 32 | return z.String(), "", nil 33 | } 34 | } 35 | 36 | // GenFromURL returns a func that generates a DSN based on parameters of the 37 | // passed URL. 38 | func GenFromURL(urlstr string) func(*URL) (string, string, error) { 39 | z, err := url.Parse(urlstr) 40 | if err != nil { 41 | panic(err) 42 | } 43 | return func(u *URL) (string, string, error) { 44 | opaque := z.Opaque 45 | if u.Opaque != "" { 46 | opaque = u.Opaque 47 | } 48 | user := z.User 49 | if u.User != nil { 50 | user = u.User 51 | } 52 | host, port := z.Hostname(), z.Port() 53 | if h := u.Hostname(); h != "" { 54 | host = h 55 | } 56 | if p := u.Port(); p != "" { 57 | port = p 58 | } 59 | if port != "" { 60 | host += ":" + port 61 | } 62 | pstr := z.Path 63 | if u.Path != "" { 64 | pstr = u.Path 65 | } 66 | rawPath := z.RawPath 67 | if u.RawPath != "" { 68 | rawPath = u.RawPath 69 | } 70 | q := z.Query() 71 | for k, v := range u.Query() { 72 | q.Set(k, strings.Join(v, " ")) 73 | } 74 | fragment := z.Fragment 75 | if u.Fragment != "" { 76 | fragment = u.Fragment 77 | } 78 | y := &url.URL{ 79 | Scheme: z.Scheme, 80 | Opaque: opaque, 81 | User: user, 82 | Host: host, 83 | Path: pstr, 84 | RawPath: rawPath, 85 | RawQuery: q.Encode(), 86 | Fragment: fragment, 87 | } 88 | return strings.TrimPrefix(y.String(), "truncate://"), "", nil 89 | } 90 | } 91 | 92 | // GenOpaque generates a opaque file path DSN from the passed URL. 93 | func GenOpaque(u *URL) (string, string, error) { 94 | if u.Opaque == "" { 95 | return "", "", ErrMissingPath 96 | } 97 | return u.Opaque + genQueryOptions(u.Query()), "", nil 98 | } 99 | 100 | // GenAdodb generates a adodb DSN from the passed URL. 101 | func GenAdodb(u *URL) (string, string, error) { 102 | // grab data source 103 | host, port := u.Hostname(), u.Port() 104 | dsname, dbname := strings.TrimPrefix(u.Path, "/"), "" 105 | if dsname == "" { 106 | dsname = "." 107 | } 108 | // check if data source is not a path on disk 109 | if mode(dsname) == 0 { 110 | if i := strings.IndexAny(dsname, `\/`); i != -1 { 111 | dbname = dsname[i+1:] 112 | dsname = dsname[:i] 113 | } 114 | } 115 | // build q 116 | q := u.Query() 117 | q.Set("Provider", host) 118 | q.Set("Port", port) 119 | q.Set("Data Source", dsname) 120 | q.Set("Database", dbname) 121 | if u.User != nil { 122 | q.Set("User ID", u.User.Username()) 123 | pass, _ := u.User.Password() 124 | q.Set("Password", pass) 125 | } 126 | if u.hostPortDB == nil { 127 | n := dsname 128 | if dbname != "" { 129 | n += "/" + dbname 130 | } 131 | u.hostPortDB = []string{host, port, n} 132 | } 133 | return genOptionsOdbc(q, true, nil, OdbcIgnoreQueryPrefixes), "", nil 134 | } 135 | 136 | // GenCassandra generates a cassandra DSN from the passed URL. 137 | func GenCassandra(u *URL) (string, string, error) { 138 | host, port, dbname := "localhost", "9042", strings.TrimPrefix(u.Path, "/") 139 | if h := u.Hostname(); h != "" { 140 | host = h 141 | } 142 | if p := u.Port(); p != "" { 143 | port = p 144 | } 145 | q := u.Query() 146 | // add user/pass 147 | if u.User != nil { 148 | q.Set("username", u.User.Username()) 149 | if pass, _ := u.User.Password(); pass != "" { 150 | q.Set("password", pass) 151 | } 152 | } 153 | // add dbname 154 | if dbname != "" { 155 | q.Set("keyspace", dbname) 156 | } 157 | return host + ":" + port + genQueryOptions(q), "", nil 158 | } 159 | 160 | // GenClickhouse generates a clickhouse DSN from the passed URL. 161 | func GenClickhouse(u *URL) (string, string, error) { 162 | switch strings.ToLower(u.Transport) { 163 | case "", "tcp": 164 | return clickhouseTCP(u) 165 | case "http": 166 | return clickhouseHTTP(u) 167 | case "https": 168 | return clickhouseHTTPS(u) 169 | } 170 | return "", "", ErrInvalidTransportProtocol 171 | } 172 | 173 | // clickhouse generators. 174 | var ( 175 | clickhouseTCP = GenFromURL("clickhouse://localhost:9000/") 176 | clickhouseHTTP = GenFromURL("http://localhost/") 177 | clickhouseHTTPS = GenFromURL("https://localhost/") 178 | ) 179 | 180 | // GenCosmos generates a cosmos DSN from the passed URL. 181 | func GenCosmos(u *URL) (string, string, error) { 182 | host, port, dbname := u.Hostname(), u.Port(), strings.TrimPrefix(u.Path, "/") 183 | if port != "" { 184 | port = ":" + port 185 | } 186 | q := u.Query() 187 | q.Set("AccountEndpoint", "https://"+host+port) 188 | // add user/pass 189 | if u.User == nil { 190 | return "", "", ErrMissingUser 191 | } 192 | q.Set("AccountKey", u.User.Username()) 193 | if dbname != "" { 194 | q.Set("Db", dbname) 195 | } 196 | return genOptionsOdbc(q, true, nil, nil), "gocosmos", nil 197 | } 198 | 199 | // GenDatabend generates a databend DSN from the passed URL. 200 | func GenDatabend(u *URL) (string, string, error) { 201 | if u.Hostname() == "" { 202 | return "", "", ErrMissingHost 203 | } 204 | return u.String(), "", nil 205 | } 206 | 207 | // GenDynamo generates a dynamo DSN from the passed URL. 208 | func GenDynamo(u *URL) (string, string, error) { 209 | var v []string 210 | if host := u.Hostname(); host != "" { 211 | v = append(v, "Region="+host) 212 | } 213 | if u.User != nil { 214 | v = append(v, "AkId="+u.User.Username()) 215 | if pass, ok := u.User.Password(); ok { 216 | v = append(v, "Secret_Key="+pass) 217 | } 218 | } 219 | return strings.Join(v, ";") + genOptions(u.Query(), ";", "=", ";", ",", true, []string{"Region", "Secret_Key", "AkId"}, nil), "", nil 220 | } 221 | 222 | // GenDatabricks generates a databricks DSN from the passed URL. 223 | func GenDatabricks(u *URL) (string, string, error) { 224 | if u.User == nil { 225 | return "", "", ErrMissingUser 226 | } 227 | user := u.User.Username() 228 | pass, ok := u.User.Password() 229 | if !ok || pass == "" { 230 | return "", "", ErrMissingUser 231 | } 232 | host, port := u.Hostname(), u.Port() 233 | if host == "" { 234 | return "", "", ErrMissingHost 235 | } 236 | if port == "" { 237 | port = "443" 238 | } 239 | s := fmt.Sprintf("token:%s@%s.databricks.com:%s/sql/1.0/endpoints/%s", user, pass, port, host) 240 | return s + genOptions(u.Query(), "?", "=", "&", ",", true, nil, nil), "", nil 241 | } 242 | 243 | // GenExasol generates a exasol DSN from the passed URL. 244 | func GenExasol(u *URL) (string, string, error) { 245 | host, port, dbname := u.Hostname(), u.Port(), strings.TrimPrefix(u.Path, "/") 246 | if host == "" { 247 | host = "localhost" 248 | } 249 | if port == "" { 250 | port = "8563" 251 | } 252 | q := u.Query() 253 | if dbname != "" { 254 | q.Set("schema", dbname) 255 | } 256 | if u.User != nil { 257 | q.Set("user", u.User.Username()) 258 | pass, _ := u.User.Password() 259 | q.Set("password", pass) 260 | } 261 | return fmt.Sprintf("exa:%s:%s%s", host, port, genOptions(q, ";", "=", ";", ",", true, nil, nil)), "", nil 262 | } 263 | 264 | // GenFirebird generates a firebird DSN from the passed URL. 265 | func GenFirebird(u *URL) (string, string, error) { 266 | z := &url.URL{ 267 | User: u.User, 268 | Host: u.Host, 269 | Path: u.Path, 270 | RawPath: u.RawPath, 271 | RawQuery: u.RawQuery, 272 | Fragment: u.Fragment, 273 | } 274 | return strings.TrimPrefix(z.String(), "//"), "", nil 275 | } 276 | 277 | // GenGodror generates a godror DSN from the passed URL. 278 | func GenGodror(u *URL) (string, string, error) { 279 | // Easy Connect Naming method enables clients to connect to a database server 280 | // without any configuration. Clients use a connect string for a simple TCP/IP 281 | // address, which includes a host name and optional port and service name: 282 | // CONNECT username[/password]@[//]host[:port][/service_name][:server][/instance_name] 283 | host, port, service := u.Hostname(), u.Port(), strings.TrimPrefix(u.Path, "/") 284 | // grab instance name from service name 285 | var instance string 286 | if i := strings.LastIndex(service, "/"); i != -1 { 287 | instance, service = service[i+1:], service[:i] 288 | } 289 | // build dsn 290 | dsn := host 291 | if port != "" { 292 | dsn += ":" + port 293 | } 294 | if u.User != nil { 295 | if n := u.User.Username(); n != "" { 296 | if p, ok := u.User.Password(); ok { 297 | n += "/" + p 298 | } 299 | dsn = n + "@//" + dsn 300 | } 301 | } 302 | if service != "" { 303 | dsn += "/" + service 304 | } 305 | if instance != "" { 306 | dsn += "/" + instance 307 | } 308 | return dsn, "", nil 309 | } 310 | 311 | // GenIgnite generates an ignite DSN from the passed URL. 312 | func GenIgnite(u *URL) (string, string, error) { 313 | host, port, dbname := "localhost", "10800", strings.TrimPrefix(u.Path, "/") 314 | if h := u.Hostname(); h != "" { 315 | host = h 316 | } 317 | if p := u.Port(); p != "" { 318 | port = p 319 | } 320 | q := u.Query() 321 | // add user/pass 322 | if u.User != nil { 323 | q.Set("username", u.User.Username()) 324 | if pass, _ := u.User.Password(); pass != "" { 325 | q.Set("password", pass) 326 | } 327 | } 328 | // add dbname 329 | if dbname != "" { 330 | dbname = "/" + dbname 331 | } 332 | return "tcp://" + host + ":" + port + dbname + genQueryOptions(q), "", nil 333 | } 334 | 335 | // GenMymysql generates a mymysql DSN from the passed URL. 336 | func GenMymysql(u *URL) (string, string, error) { 337 | host, port, dbname := u.Hostname(), u.Port(), strings.TrimPrefix(u.Path, "/") 338 | // resolve path 339 | if u.Transport == "unix" { 340 | if host == "" { 341 | dbname = "/" + dbname 342 | } 343 | host, dbname = resolveSocket(path.Join(host, dbname)) 344 | port = "" 345 | } 346 | // save host, port, dbname 347 | if u.hostPortDB == nil { 348 | u.hostPortDB = []string{host, port, dbname} 349 | } 350 | // if host or proto is not empty 351 | if u.Transport != "unix" { 352 | if host == "" { 353 | host = "localhost" 354 | } 355 | if port == "" { 356 | port = "3306" 357 | } 358 | } 359 | if port != "" { 360 | port = ":" + port 361 | } 362 | // build dsn 363 | dsn := u.Transport + ":" + host + port 364 | dsn += genOptions( 365 | convertOptions(u.Query(), "true", ""), 366 | ",", "=", ",", " ", false, 367 | nil, nil, 368 | ) 369 | dsn += "*" + dbname 370 | if u.User != nil { 371 | pass, _ := u.User.Password() 372 | dsn += "/" + u.User.Username() + "/" + pass 373 | } else if strings.HasSuffix(dsn, "*") { 374 | dsn += "//" 375 | } 376 | return dsn, "", nil 377 | } 378 | 379 | // GenMysql generates a mysql DSN from the passed URL. 380 | func GenMysql(u *URL) (string, string, error) { 381 | host, port, dbname := u.Hostname(), u.Port(), strings.TrimPrefix(u.Path, "/") 382 | // build dsn 383 | var dsn string 384 | if u.User != nil { 385 | if n := u.User.Username(); n != "" { 386 | if p, ok := u.User.Password(); ok { 387 | n += ":" + p 388 | } 389 | dsn += n + "@" 390 | } 391 | } 392 | // resolve path 393 | if u.Transport == "unix" { 394 | if host == "" { 395 | dbname = "/" + dbname 396 | } 397 | host, dbname = resolveSocket(path.Join(host, dbname)) 398 | port = "" 399 | } 400 | // save host, port, dbname 401 | if u.hostPortDB == nil { 402 | u.hostPortDB = []string{host, port, dbname} 403 | } 404 | // if host or proto is not empty 405 | if u.Transport != "unix" { 406 | if host == "" { 407 | host = "localhost" 408 | } 409 | if port == "" { 410 | port = "3306" 411 | } 412 | } 413 | if port != "" { 414 | port = ":" + port 415 | } 416 | // add proto and database 417 | dsn += u.Transport + "(" + host + port + ")" + "/" + dbname 418 | return dsn + genQueryOptions(u.Query()), "", nil 419 | } 420 | 421 | // GenOdbc generates a odbc DSN from the passed URL. 422 | func GenOdbc(u *URL) (string, string, error) { 423 | // save host, port, dbname 424 | host, port, dbname := u.Hostname(), u.Port(), strings.TrimPrefix(u.Path, "/") 425 | if u.hostPortDB == nil { 426 | u.hostPortDB = []string{host, port, dbname} 427 | } 428 | // build q 429 | q := u.Query() 430 | q.Set("Driver", "{"+strings.ReplaceAll(u.Transport, "+", " ")+"}") 431 | q.Set("Server", host) 432 | if port == "" { 433 | proto := strings.ToLower(u.Transport) 434 | switch { 435 | case strings.Contains(proto, "mysql"): 436 | q.Set("Port", "3306") 437 | case strings.Contains(proto, "postgres"): 438 | q.Set("Port", "5432") 439 | case strings.Contains(proto, "db2") || strings.Contains(proto, "ibm"): 440 | q.Set("ServiceName", "50000") 441 | default: 442 | q.Set("Port", "1433") 443 | } 444 | } else { 445 | q.Set("Port", port) 446 | } 447 | q.Set("Database", dbname) 448 | // add user/pass 449 | if u.User != nil { 450 | q.Set("UID", u.User.Username()) 451 | p, _ := u.User.Password() 452 | q.Set("PWD", p) 453 | } 454 | return genOptionsOdbc(q, true, nil, OdbcIgnoreQueryPrefixes), "", nil 455 | } 456 | 457 | // GenOleodbc generates a oleodbc DSN from the passed URL. 458 | func GenOleodbc(u *URL) (string, string, error) { 459 | props, _, err := GenOdbc(u) 460 | if err != nil { 461 | return "", "", err 462 | } 463 | return `Provider=MSDASQL.1;Extended Properties="` + props + `"`, "", nil 464 | } 465 | 466 | // GenPostgres generates a postgres DSN from the passed URL. 467 | func GenPostgres(u *URL) (string, string, error) { 468 | host, port, dbname := u.Hostname(), u.Port(), strings.TrimPrefix(u.Path, "/") 469 | if host == "." { 470 | return "", "", ErrRelativePathNotSupported 471 | } 472 | // resolve path 473 | if u.Transport == "unix" { 474 | if host == "" { 475 | dbname = "/" + dbname 476 | } 477 | host, port, dbname = resolveDir(path.Join(host, dbname)) 478 | } 479 | // build q 480 | q := u.Query() 481 | q.Set("host", host) 482 | q.Set("port", port) 483 | q.Set("dbname", dbname) 484 | // add user/pass 485 | if u.User != nil { 486 | q.Set("user", u.User.Username()) 487 | pass, _ := u.User.Password() 488 | q.Set("password", pass) 489 | } 490 | // save host, port, dbname 491 | if u.hostPortDB == nil { 492 | u.hostPortDB = []string{host, port, dbname} 493 | } 494 | return genOptions(q, "", "=", " ", ",", true, nil, nil), "", nil 495 | } 496 | 497 | // GenPresto generates a presto DSN from the passed URL. 498 | func GenPresto(u *URL) (string, string, error) { 499 | z := &url.URL{ 500 | Scheme: "http", 501 | Opaque: u.Opaque, 502 | User: u.User, 503 | Host: u.Host, 504 | RawQuery: u.RawQuery, 505 | Fragment: u.Fragment, 506 | } 507 | // change to https 508 | if strings.HasSuffix(u.OriginalScheme, "s") { 509 | z.Scheme = "https" 510 | } 511 | // force user 512 | if z.User == nil { 513 | z.User = url.User("user") 514 | } 515 | // force host 516 | if z.Host == "" { 517 | z.Host = "localhost" 518 | } 519 | // force port 520 | if z.Port() == "" { 521 | switch z.Scheme { 522 | case "http": 523 | z.Host += ":8080" 524 | case "https": 525 | z.Host += ":8443" 526 | } 527 | } 528 | // add parameters 529 | q := z.Query() 530 | dbname, schema := strings.TrimPrefix(u.Path, "/"), "" 531 | if dbname == "" { 532 | dbname = "default" 533 | } else if i := strings.Index(dbname, "/"); i != -1 { 534 | schema, dbname = dbname[i+1:], dbname[:i] 535 | } 536 | q.Set("catalog", dbname) 537 | if schema != "" { 538 | q.Set("schema", schema) 539 | } 540 | z.RawQuery = q.Encode() 541 | return z.String(), "", nil 542 | } 543 | 544 | // GenSnowflake generates a snowflake DSN from the passed URL. 545 | func GenSnowflake(u *URL) (string, string, error) { 546 | host, port, dbname := u.Hostname(), u.Port(), strings.TrimPrefix(u.Path, "/") 547 | if host == "" { 548 | return "", "", ErrMissingHost 549 | } 550 | if port != "" { 551 | port = ":" + port 552 | } 553 | // add user/pass 554 | if u.User == nil { 555 | return "", "", ErrMissingUser 556 | } 557 | user := u.User.Username() 558 | if pass, _ := u.User.Password(); pass != "" { 559 | user += ":" + pass 560 | } 561 | return user + "@" + host + port + "/" + dbname + genQueryOptions(u.Query()), "", nil 562 | } 563 | 564 | // GenSpanner generates a spanner DSN from the passed URL. 565 | func GenSpanner(u *URL) (string, string, error) { 566 | project := u.Hostname() 567 | if project == "" { 568 | return "", "", ErrMissingHost 569 | } 570 | instance, dbname, ok := strings.Cut(strings.TrimPrefix(u.Path, "/"), "/") 571 | if !ok || instance == "" || dbname == "" { 572 | return "", "", ErrMissingPath 573 | } 574 | return fmt.Sprintf(`projects/%s/instances/%s/databases/%s`, project, instance, dbname), "", nil 575 | } 576 | 577 | // GenSqlserver generates a sqlserver DSN from the passed URL. 578 | func GenSqlserver(u *URL) (string, string, error) { 579 | z := &url.URL{ 580 | Scheme: "sqlserver", 581 | Opaque: u.Opaque, 582 | User: u.User, 583 | Host: u.Host, 584 | Path: u.Path, 585 | RawQuery: u.RawQuery, 586 | Fragment: u.Fragment, 587 | } 588 | if z.Host == "" { 589 | z.Host = "localhost" 590 | } 591 | driver := "sqlserver" 592 | if strings.Contains(strings.ToLower(u.Scheme), "azuresql") || 593 | u.Query().Get("fedauth") != "" { 594 | driver = "azuresql" 595 | } 596 | v := strings.Split(strings.TrimPrefix(z.Path, "/"), "/") 597 | if n, q := len(v), z.Query(); !q.Has("database") && n != 0 && len(v[0]) != 0 { 598 | q.Set("database", v[n-1]) 599 | z.Path, z.RawQuery = "/"+strings.Join(v[:n-1], "/"), q.Encode() 600 | } 601 | return z.String(), driver, nil 602 | } 603 | 604 | // GenTableStore generates a tablestore DSN from the passed URL. 605 | func GenTableStore(u *URL) (string, string, error) { 606 | var transport string 607 | splits := strings.Split(u.OriginalScheme, "+") 608 | switch { 609 | case len(splits) == 0: 610 | return "", "", ErrInvalidDatabaseScheme 611 | case len(splits) == 1, splits[1] == "https": 612 | transport = "https" 613 | case splits[1] == "http": 614 | transport = "http" 615 | default: 616 | return "", "", ErrInvalidTransportProtocol 617 | } 618 | z := &url.URL{ 619 | Scheme: transport, 620 | Opaque: u.Opaque, 621 | User: u.User, 622 | Host: u.Host, 623 | Path: u.Path, 624 | RawPath: u.RawPath, 625 | RawQuery: u.RawQuery, 626 | Fragment: u.Fragment, 627 | } 628 | return z.String(), "", nil 629 | } 630 | 631 | // GenVoltdb generates a voltdb DSN from the passed URL. 632 | func GenVoltdb(u *URL) (string, string, error) { 633 | host, port := "localhost", "21212" 634 | if h := u.Hostname(); h != "" { 635 | host = h 636 | } 637 | if p := u.Port(); p != "" { 638 | port = p 639 | } 640 | return host + ":" + port, "", nil 641 | } 642 | 643 | // GenYDB generates a ydb dsn from the passed URL. 644 | func GenYDB(u *URL) (string, string, error) { 645 | scheme, host, port := "grpc", "localhost", "2136" 646 | if strings.HasSuffix(strings.ToLower(u.OriginalScheme), "s") { 647 | scheme, port = "grpcs", "2135" 648 | } 649 | if h := u.Hostname(); h != "" { 650 | host = h 651 | } 652 | if p := u.Port(); p != "" { 653 | port = p 654 | } 655 | var userpass string 656 | if u.User != nil { 657 | userpass = u.User.String() + "@" 658 | } 659 | s := scheme + "://" + userpass + host + ":" + port + "/" + strings.TrimPrefix(u.Path, "/") 660 | return s + genOptions(u.Query(), "?", "=", "&", ",", true, nil, nil), "", nil 661 | } 662 | 663 | // GenDuckDB generates a duckdb dsn from the passed URL. 664 | func GenDuckDB(u *URL) (string, string, error) { 665 | // Same as GenOpaque but accepts empty path which refers to in-memory DB 666 | return u.Opaque + genQueryOptions(u.Query()), "", nil 667 | } 668 | 669 | // convertOptions converts an option value based on name, value pairs. 670 | func convertOptions(q url.Values, pairs ...string) url.Values { 671 | n := make(url.Values) 672 | for k, v := range q { 673 | x := make([]string, len(v)) 674 | for i, z := range v { 675 | for j := 0; j < len(pairs); j += 2 { 676 | if pairs[j] == z { 677 | z = pairs[j+1] 678 | } 679 | } 680 | x[i] = z 681 | } 682 | n[k] = x 683 | } 684 | return n 685 | } 686 | 687 | // genQueryOptions generates standard query options. 688 | func genQueryOptions(q url.Values) string { 689 | if s := q.Encode(); s != "" { 690 | return "?" + s 691 | } 692 | return "" 693 | } 694 | 695 | // genOptionsOdbc is a util wrapper around genOptions that uses the fixed 696 | // settings for ODBC style connection strings. 697 | func genOptionsOdbc(q url.Values, skipWhenEmpty bool, ignore, ignorePrefixes []string) string { 698 | return genOptions(q, "", "=", ";", ",", skipWhenEmpty, ignore, ignorePrefixes) 699 | } 700 | 701 | // genOptions takes URL values and generates options, joining together with 702 | // joiner, and separated by sep, with any multi URL values joined by valSep, 703 | // ignoring any values with keys in ignore. 704 | // 705 | // For example, to build a "ODBC" style connection string, can be used like the 706 | // following: 707 | // 708 | // genOptions(u.Query(), "", "=", ";", ",", false) 709 | // 710 | //nolint:unparam 711 | func genOptions(q url.Values, joiner, assign, sep, valSep string, skipWhenEmpty bool, ignore, ignorePrefixes []string) string { 712 | if len(q) == 0 { 713 | return "" 714 | } 715 | // make ignore map 716 | ig := make(map[string]bool, len(ignore)) 717 | for _, v := range ignore { 718 | ig[strings.ToLower(v)] = true 719 | } 720 | // sort keys 721 | s := make([]string, len(q)) 722 | var i int 723 | for k := range q { 724 | s[i] = k 725 | i++ 726 | } 727 | sort.Strings(s) 728 | var opts []string 729 | for _, k := range s { 730 | if s := strings.ToLower(k); !ig[s] && !hasPrefix(s, ignorePrefixes) { 731 | val := strings.Join(q[k], valSep) 732 | if !skipWhenEmpty || val != "" { 733 | if val != "" { 734 | val = assign + val 735 | } 736 | opts = append(opts, k+val) 737 | } 738 | } 739 | } 740 | if len(opts) != 0 { 741 | return joiner + strings.Join(opts, sep) 742 | } 743 | return "" 744 | } 745 | 746 | // hasPrefix returns true when s begins with any listed prefix. 747 | func hasPrefix(s string, prefixes []string) bool { 748 | for _, prefix := range prefixes { 749 | if strings.HasPrefix(s, prefix) { 750 | return true 751 | } 752 | } 753 | return false 754 | } 755 | -------------------------------------------------------------------------------- /dburl_test.go: -------------------------------------------------------------------------------- 1 | package dburl 2 | 3 | import ( 4 | "errors" 5 | "io/fs" 6 | "os" 7 | "strconv" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestBadParse(t *testing.T) { 13 | tests := []struct { 14 | s string 15 | exp error 16 | }{ 17 | {``, ErrInvalidDatabaseScheme}, 18 | {` `, ErrInvalidDatabaseScheme}, 19 | {`pgsqlx://`, ErrUnknownDatabaseScheme}, 20 | {`m`, ErrInvalidDatabaseScheme}, 21 | {`pg+udp://user:pass@localhost/dbname`, ErrInvalidTransportProtocol}, 22 | {`sqlite+unix://`, ErrInvalidTransportProtocol}, 23 | {`sqlite+tcp://`, ErrInvalidTransportProtocol}, 24 | {`file+tcp://`, ErrInvalidTransportProtocol}, 25 | {`file://`, ErrMissingPath}, 26 | {`ql://`, ErrMissingPath}, 27 | {`mssql+tcp://user:pass@host/dbname`, ErrInvalidTransportProtocol}, 28 | {`mssql+foobar://`, ErrInvalidTransportProtocol}, 29 | {`mssql+unix:/var/run/mssql.sock`, ErrInvalidTransportProtocol}, 30 | {`mssql+udp:localhost:155`, ErrInvalidTransportProtocol}, 31 | {`adodb+foo+bar://provider/database`, ErrInvalidTransportProtocol}, 32 | {`memsql:/var/run/mysqld/mysqld.sock`, ErrInvalidTransportProtocol}, 33 | {`tidb:/var/run/mysqld/mysqld.sock`, ErrInvalidTransportProtocol}, 34 | {`vitess:/var/run/mysqld/mysqld.sock`, ErrInvalidTransportProtocol}, 35 | {`memsql+unix:///var/run/mysqld/mysqld.sock`, ErrInvalidTransportProtocol}, 36 | {`tidb+unix:///var/run/mysqld/mysqld.sock`, ErrInvalidTransportProtocol}, 37 | {`vitess+unix:///var/run/mysqld/mysqld.sock`, ErrInvalidTransportProtocol}, 38 | {`cockroach:/var/run/postgresql`, ErrInvalidTransportProtocol}, 39 | {`cockroach+unix:/var/run/postgresql`, ErrInvalidTransportProtocol}, 40 | {`cockroach:./path`, ErrInvalidTransportProtocol}, 41 | {`cockroach+unix:./path`, ErrInvalidTransportProtocol}, 42 | {`redshift:/var/run/postgresql`, ErrInvalidTransportProtocol}, 43 | {`redshift+unix:/var/run/postgresql`, ErrInvalidTransportProtocol}, 44 | {`redshift:./path`, ErrInvalidTransportProtocol}, 45 | {`redshift+unix:./path`, ErrInvalidTransportProtocol}, 46 | {`pg:./path/to/socket`, ErrRelativePathNotSupported}, // relative paths are not possible for postgres sockets 47 | {`pg+unix:./path/to/socket`, ErrRelativePathNotSupported}, 48 | {`snowflake://`, ErrMissingHost}, 49 | {`sf://`, ErrMissingHost}, 50 | {`snowflake://account`, ErrMissingUser}, 51 | {`sf://account`, ErrMissingUser}, 52 | {`mq+unix://`, ErrInvalidTransportProtocol}, 53 | {`mq+tcp://`, ErrInvalidTransportProtocol}, 54 | {`ots+tcp://`, ErrInvalidTransportProtocol}, 55 | {`tablestore+tcp://`, ErrInvalidTransportProtocol}, 56 | {`bend://`, ErrMissingHost}, 57 | {`databend://`, ErrMissingHost}, 58 | {`unknown_file.ext3`, ErrInvalidDatabaseScheme}, 59 | } 60 | for i, test := range tests { 61 | t.Run(strconv.Itoa(i), func(t *testing.T) { 62 | testBadParse(t, test.s, test.exp) 63 | }) 64 | } 65 | } 66 | 67 | func testBadParse(t *testing.T, s string, exp error) { 68 | t.Helper() 69 | _, err := Parse(s) 70 | switch { 71 | case err == nil: 72 | t.Errorf("%q expected error nil error, got: %v", s, err) 73 | case !errors.Is(err, exp): 74 | t.Errorf("%q expected error %v, got: %v", s, exp, err) 75 | } 76 | } 77 | 78 | func TestParse(t *testing.T) { 79 | OdbcIgnoreQueryPrefixes = []string{"usql_"} 80 | tests := []struct { 81 | s string 82 | d string 83 | exp string 84 | path string 85 | }{ 86 | { 87 | `pg:`, 88 | `postgres`, 89 | ``, 90 | ``, 91 | }, 92 | { 93 | `pg://`, 94 | `postgres`, 95 | ``, 96 | ``, 97 | }, 98 | { 99 | `pg:user:pass@localhost/booktest`, 100 | `postgres`, 101 | `dbname=booktest host=localhost password=pass user=user`, 102 | ``, 103 | }, 104 | { 105 | `pg:/var/run/postgresql`, 106 | `postgres`, 107 | `host=/var/run/postgresql`, 108 | `/var/run/postgresql`, 109 | }, 110 | { 111 | `pg:/var/run/postgresql:6666/mydb`, 112 | `postgres`, 113 | `dbname=mydb host=/var/run/postgresql port=6666`, 114 | `/var/run/postgresql`, 115 | }, 116 | { 117 | `/var/run/postgresql:6666/mydb`, 118 | `postgres`, 119 | `dbname=mydb host=/var/run/postgresql port=6666`, 120 | `/var/run/postgresql`, 121 | }, 122 | { 123 | `pg:/var/run/postgresql/mydb`, 124 | `postgres`, 125 | `dbname=mydb host=/var/run/postgresql`, 126 | `/var/run/postgresql`, 127 | }, 128 | { 129 | `/var/run/postgresql/mydb`, 130 | `postgres`, 131 | `dbname=mydb host=/var/run/postgresql`, 132 | `/var/run/postgresql`, 133 | }, 134 | { 135 | `pg:/var/run/postgresql:7777`, 136 | `postgres`, 137 | `host=/var/run/postgresql port=7777`, 138 | `/var/run/postgresql`, 139 | }, 140 | { 141 | `pg+unix:/var/run/postgresql:4444/booktest`, 142 | `postgres`, 143 | `dbname=booktest host=/var/run/postgresql port=4444`, 144 | `/var/run/postgresql`, 145 | }, 146 | { 147 | `/var/run/postgresql:7777`, 148 | `postgres`, 149 | `host=/var/run/postgresql port=7777`, 150 | `/var/run/postgresql`, 151 | }, 152 | { 153 | `pg:user:pass@/var/run/postgresql/mydb`, 154 | `postgres`, 155 | `dbname=mydb host=/var/run/postgresql password=pass user=user`, 156 | `/var/run/postgresql`, 157 | }, 158 | { 159 | `pg:user:pass@/really/bad/path`, 160 | `postgres`, 161 | `host=/really/bad/path password=pass user=user`, 162 | ``, 163 | }, 164 | { 165 | `my:`, 166 | `mysql`, 167 | `tcp(localhost:3306)/`, 168 | ``, 169 | }, 170 | { 171 | `my://`, 172 | `mysql`, 173 | `tcp(localhost:3306)/`, 174 | ``, 175 | }, 176 | { 177 | `my:booktest:booktest@localhost/booktest`, 178 | `mysql`, 179 | `booktest:booktest@tcp(localhost:3306)/booktest`, 180 | ``, 181 | }, 182 | { 183 | `my:/var/run/mysqld/mysqld.sock/mydb?timeout=90`, 184 | `mysql`, 185 | `unix(/var/run/mysqld/mysqld.sock)/mydb?timeout=90`, 186 | `/var/run/mysqld/mysqld.sock`, 187 | }, 188 | { 189 | `/var/run/mysqld/mysqld.sock/mydb?timeout=90`, 190 | `mysql`, 191 | `unix(/var/run/mysqld/mysqld.sock)/mydb?timeout=90`, 192 | `/var/run/mysqld/mysqld.sock`, 193 | }, 194 | { 195 | `my:///var/run/mysqld/mysqld.sock/mydb?timeout=90`, 196 | `mysql`, 197 | `unix(/var/run/mysqld/mysqld.sock)/mydb?timeout=90`, 198 | `/var/run/mysqld/mysqld.sock`, 199 | }, 200 | { 201 | `my+unix:user:pass@mysqld.sock?timeout=90`, 202 | `mysql`, 203 | `user:pass@unix(mysqld.sock)/?timeout=90`, 204 | ``, 205 | }, 206 | { 207 | `my:./path/to/socket`, 208 | `mysql`, 209 | `unix(path/to/socket)/`, 210 | ``, 211 | }, 212 | { 213 | `my+unix:./path/to/socket`, 214 | `mysql`, 215 | `unix(path/to/socket)/`, 216 | ``, 217 | }, 218 | { 219 | `mymy:`, 220 | `mymysql`, 221 | `tcp:localhost:3306*//`, 222 | ``, 223 | }, 224 | { 225 | `mymy://`, 226 | `mymysql`, 227 | `tcp:localhost:3306*//`, 228 | ``, 229 | }, 230 | { 231 | `mymy:user:pass@localhost/booktest`, 232 | `mymysql`, 233 | `tcp:localhost:3306*booktest/user/pass`, 234 | ``, 235 | }, 236 | { 237 | `mymy:/var/run/mysqld/mysqld.sock/mydb?timeout=90&test=true`, 238 | `mymysql`, 239 | `unix:/var/run/mysqld/mysqld.sock,test,timeout=90*mydb`, 240 | `/var/run/mysqld/mysqld.sock`, 241 | }, 242 | { 243 | `mymy:///var/run/mysqld/mysqld.sock/mydb?timeout=90`, 244 | `mymysql`, 245 | `unix:/var/run/mysqld/mysqld.sock,timeout=90*mydb`, 246 | `/var/run/mysqld/mysqld.sock`, 247 | }, 248 | { 249 | `mymy+unix:user:pass@mysqld.sock?timeout=90`, 250 | `mymysql`, 251 | `unix:mysqld.sock,timeout=90*/user/pass`, 252 | ``, 253 | }, 254 | { 255 | `mymy:./path/to/socket`, 256 | `mymysql`, 257 | `unix:path/to/socket*//`, 258 | ``, 259 | }, 260 | { 261 | `mymy+unix:./path/to/socket`, 262 | `mymysql`, 263 | `unix:path/to/socket*//`, 264 | ``, 265 | }, 266 | { 267 | `mssql://`, 268 | `sqlserver`, 269 | `sqlserver://localhost`, 270 | ``, 271 | }, 272 | { 273 | `mssql://user:pass@localhost/dbname`, 274 | `sqlserver`, 275 | `sqlserver://user:pass@localhost/?database=dbname`, 276 | ``, 277 | }, 278 | { 279 | `mssql://user@localhost/service/dbname`, 280 | `sqlserver`, 281 | `sqlserver://user@localhost/service?database=dbname`, 282 | ``, 283 | }, 284 | { 285 | `mssql://user:!234%23$@localhost:1580/dbname`, 286 | `sqlserver`, 287 | `sqlserver://user:%21234%23$@localhost:1580/?database=dbname`, 288 | ``, 289 | }, 290 | { 291 | `mssql://user:!234%23$@localhost:1580/service/dbname?fedauth=true`, 292 | `azuresql`, 293 | `sqlserver://user:%21234%23$@localhost:1580/service?database=dbname&fedauth=true`, 294 | ``, 295 | }, 296 | { 297 | `azuresql://user:pass@localhost:100/dbname`, 298 | `azuresql`, 299 | `sqlserver://user:pass@localhost:100/?database=dbname`, 300 | ``, 301 | }, 302 | { 303 | `sqlserver://xxx.database.windows.net?database=xxx&fedauth=ActiveDirectoryMSI`, 304 | `azuresql`, 305 | `sqlserver://xxx.database.windows.net?database=xxx&fedauth=ActiveDirectoryMSI`, 306 | ``, 307 | }, 308 | { 309 | `azuresql://xxx.database.windows.net/dbname?fedauth=ActiveDirectoryMSI`, 310 | `azuresql`, 311 | `sqlserver://xxx.database.windows.net/?database=dbname&fedauth=ActiveDirectoryMSI`, 312 | ``, 313 | }, 314 | { 315 | `adodb://Microsoft.ACE.OLEDB.12.0?Extended+Properties=%22Text%3BHDR%3DNO%3BFMT%3DDelimited%22`, 316 | `adodb`, 317 | `Data Source=.;Extended Properties="Text;HDR=NO;FMT=Delimited";Provider=Microsoft.ACE.OLEDB.12.0`, 318 | ``, 319 | }, 320 | { 321 | `adodb://user:pass@Provider.Name:1542/Oracle8i/dbname`, 322 | `adodb`, 323 | `Data Source=Oracle8i;Database=dbname;Password=pass;Port=1542;Provider=Provider.Name;User ID=user`, 324 | ``, 325 | }, 326 | { 327 | `adodb://user:pass@Provider.Name:1542/Oracle8i/dbname?not_ignored=1&usql_ignore=1`, 328 | `adodb`, 329 | `Data Source=Oracle8i;Database=dbname;Password=pass;Port=1542;Provider=Provider.Name;User ID=user;not_ignored=1`, 330 | ``, 331 | }, 332 | { 333 | `oo+Postgres+Unicode://user:pass@host:5432/dbname`, 334 | `adodb`, 335 | `Provider=MSDASQL.1;Extended Properties="Database=dbname;Driver={Postgres Unicode};PWD=pass;Port=5432;Server=host;UID=user"`, 336 | ``, 337 | }, 338 | { 339 | `oo+Postgres+Unicode://user:pass@host:5432/dbname?not_ignored=1&usql_ignore=1`, 340 | `adodb`, 341 | `Provider=MSDASQL.1;Extended Properties="Database=dbname;Driver={Postgres Unicode};PWD=pass;Port=5432;Server=host;UID=user;not_ignored=1"`, 342 | ``, 343 | }, 344 | { 345 | `odbc+Postgres+Unicode://user:pass@host:5432/dbname?not_ignored=1`, 346 | `odbc`, 347 | `Database=dbname;Driver={Postgres Unicode};PWD=pass;Port=5432;Server=host;UID=user;not_ignored=1`, 348 | ``, 349 | }, 350 | { 351 | `odbc+Postgres+Unicode://user:pass@host:5432/dbname?usql_ignore=1¬_ignored=1`, 352 | `odbc`, 353 | `Database=dbname;Driver={Postgres Unicode};PWD=pass;Port=5432;Server=host;UID=user;not_ignored=1`, 354 | ``, 355 | }, 356 | { 357 | `sqlite:///path/to/file.sqlite3`, 358 | `sqlite3`, 359 | `/path/to/file.sqlite3`, 360 | ``, 361 | }, 362 | { 363 | `sq://path/to/file.sqlite3`, 364 | `sqlite3`, 365 | `path/to/file.sqlite3`, 366 | ``, 367 | }, 368 | { 369 | `sq:path/to/file.sqlite3`, 370 | `sqlite3`, 371 | `path/to/file.sqlite3`, 372 | ``, 373 | }, 374 | { 375 | `sq:./path/to/file.sqlite3`, 376 | `sqlite3`, 377 | `./path/to/file.sqlite3`, 378 | ``, 379 | }, 380 | { 381 | `sq://./path/to/file.sqlite3?loc=auto`, 382 | `sqlite3`, 383 | `./path/to/file.sqlite3?loc=auto`, 384 | ``, 385 | }, 386 | { 387 | `sq::memory:?loc=auto`, 388 | `sqlite3`, 389 | `:memory:?loc=auto`, 390 | ``, 391 | }, 392 | { 393 | `sq://:memory:?loc=auto`, 394 | `sqlite3`, 395 | `:memory:?loc=auto`, 396 | ``, 397 | }, 398 | { 399 | `or://user:pass@localhost:3000/sidname`, 400 | `oracle`, 401 | `oracle://user:pass@localhost:3000/sidname`, 402 | ``, 403 | }, 404 | { 405 | `or://localhost`, 406 | `oracle`, 407 | `oracle://localhost:1521`, 408 | ``, 409 | }, 410 | { 411 | `oracle://user:pass@localhost`, 412 | `oracle`, 413 | `oracle://user:pass@localhost:1521`, 414 | ``, 415 | }, 416 | { 417 | `oracle://user:pass@localhost/service_name/instance_name`, 418 | `oracle`, 419 | `oracle://user:pass@localhost:1521/service_name/instance_name`, 420 | ``, 421 | }, 422 | { 423 | `oracle://user:pass@localhost:2000/xe.oracle.docker`, 424 | `oracle`, 425 | `oracle://user:pass@localhost:2000/xe.oracle.docker`, 426 | ``, 427 | }, 428 | { 429 | `or://username:password@host/ORCL`, 430 | `oracle`, 431 | `oracle://username:password@host:1521/ORCL`, 432 | ``, 433 | }, 434 | { 435 | `odpi://username:password@sales-server:1521/sales.us.acme.com`, 436 | `oracle`, 437 | `oracle://username:password@sales-server:1521/sales.us.acme.com`, 438 | ``, 439 | }, 440 | { 441 | `oracle://username:password@sales-server.us.acme.com/sales.us.oracle.com`, 442 | `oracle`, 443 | `oracle://username:password@sales-server.us.acme.com:1521/sales.us.oracle.com`, 444 | ``, 445 | }, 446 | { 447 | `presto://host:8001/`, 448 | `presto`, 449 | `http://user@host:8001?catalog=default`, 450 | ``, 451 | }, 452 | { 453 | `presto://host/catalogname/schemaname`, 454 | `presto`, 455 | `http://user@host:8080?catalog=catalogname&schema=schemaname`, 456 | ``, 457 | }, 458 | { 459 | `prs://admin@host/catalogname`, 460 | `presto`, 461 | `https://admin@host:8443?catalog=catalogname`, 462 | ``, 463 | }, 464 | { 465 | `prestodbs://admin:pass@host:9998/catalogname`, 466 | `presto`, 467 | `https://admin:pass@host:9998?catalog=catalogname`, 468 | ``, 469 | }, 470 | { 471 | `ca://host`, 472 | `cql`, 473 | `host:9042`, 474 | ``, 475 | }, 476 | { 477 | `cassandra://host:9999`, 478 | `cql`, 479 | `host:9999`, 480 | ``, 481 | }, 482 | { 483 | `scy://user@host:9999`, 484 | `cql`, 485 | `host:9999?username=user`, 486 | ``, 487 | }, 488 | { 489 | `scylla://user@host:9999?timeout=1000`, 490 | `cql`, 491 | `host:9999?timeout=1000&username=user`, 492 | ``, 493 | }, 494 | { 495 | `datastax://user:pass@localhost:9999/?timeout=1000`, 496 | `cql`, 497 | `localhost:9999?password=pass&timeout=1000&username=user`, 498 | ``, 499 | }, 500 | { 501 | `ca://user:pass@localhost:9999/dbname?timeout=1000`, 502 | `cql`, 503 | `localhost:9999?keyspace=dbname&password=pass&timeout=1000&username=user`, 504 | ``, 505 | }, 506 | { 507 | `ig://host`, 508 | `ignite`, 509 | `tcp://host:10800`, 510 | ``, 511 | }, 512 | { 513 | `ignite://host:9999`, 514 | `ignite`, 515 | `tcp://host:9999`, 516 | ``, 517 | }, 518 | { 519 | `gridgain://user@host:9999`, 520 | `ignite`, 521 | `tcp://host:9999?username=user`, 522 | ``, 523 | }, 524 | { 525 | `ig://user@host:9999?timeout=1000`, 526 | `ignite`, 527 | `tcp://host:9999?timeout=1000&username=user`, 528 | ``, 529 | }, 530 | { 531 | `ig://user:pass@localhost:9999/?timeout=1000`, 532 | `ignite`, 533 | `tcp://localhost:9999?password=pass&timeout=1000&username=user`, 534 | ``, 535 | }, 536 | { 537 | `ig://user:pass@localhost:9999/dbname?timeout=1000`, 538 | `ignite`, 539 | `tcp://localhost:9999/dbname?password=pass&timeout=1000&username=user`, 540 | ``, 541 | }, 542 | { 543 | `sf://user@host:9999/dbname/schema?timeout=1000`, 544 | `snowflake`, 545 | `user@host:9999/dbname/schema?timeout=1000`, 546 | ``, 547 | }, 548 | { 549 | `sf://user:pass@localhost:9999/dbname/schema?timeout=1000`, 550 | `snowflake`, 551 | `user:pass@localhost:9999/dbname/schema?timeout=1000`, 552 | ``, 553 | }, 554 | { 555 | `rs://user:pass@amazon.com/dbname`, 556 | `postgres`, 557 | `postgres://user:pass@amazon.com:5439/dbname`, 558 | ``, 559 | }, 560 | { 561 | `ve://`, 562 | `vertica`, 563 | `vertica://localhost:5433/`, 564 | ``, 565 | }, 566 | { 567 | `ve://user:pass@vertica-host/dbvertica?tlsmode=server-strict`, 568 | `vertica`, 569 | `vertica://user:pass@vertica-host:5433/dbvertica?tlsmode=server-strict`, 570 | ``, 571 | }, 572 | { 573 | `vertica://vertica:P4ssw0rd@localhost/vertica`, 574 | `vertica`, 575 | `vertica://vertica:P4ssw0rd@localhost:5433/vertica`, 576 | ``, 577 | }, 578 | { 579 | `ve://vertica:P4ssw0rd@localhost:5433/vertica`, 580 | `vertica`, 581 | `vertica://vertica:P4ssw0rd@localhost:5433/vertica`, 582 | ``, 583 | }, 584 | { 585 | `moderncsqlite:///path/to/file.sqlite3`, 586 | `moderncsqlite`, 587 | `/path/to/file.sqlite3`, 588 | ``, 589 | }, 590 | { 591 | `modernsqlite:///path/to/file.sqlite3`, 592 | `moderncsqlite`, 593 | `/path/to/file.sqlite3`, 594 | ``, 595 | }, 596 | { 597 | `mq://path/to/file.sqlite3`, 598 | `moderncsqlite`, 599 | `path/to/file.sqlite3`, 600 | ``, 601 | }, 602 | { 603 | `mq:path/to/file.sqlite3`, 604 | `moderncsqlite`, 605 | `path/to/file.sqlite3`, 606 | ``, 607 | }, 608 | { 609 | `mq:./path/to/file.sqlite3`, 610 | `moderncsqlite`, 611 | `./path/to/file.sqlite3`, 612 | ``, 613 | }, 614 | { 615 | `mq://./path/to/file.sqlite3?loc=auto`, 616 | `moderncsqlite`, 617 | `./path/to/file.sqlite3?loc=auto`, 618 | ``, 619 | }, 620 | { 621 | `mq::memory:?loc=auto`, 622 | `moderncsqlite`, 623 | `:memory:?loc=auto`, 624 | ``, 625 | }, 626 | { 627 | `mq://:memory:?loc=auto`, 628 | `moderncsqlite`, 629 | `:memory:?loc=auto`, 630 | ``, 631 | }, 632 | { 633 | `gr://user:pass@localhost:3000/sidname`, 634 | `godror`, 635 | `user/pass@//localhost:3000/sidname`, 636 | ``, 637 | }, 638 | { 639 | `gr://localhost`, 640 | `godror`, 641 | `localhost`, 642 | ``, 643 | }, 644 | { 645 | `godror://user:pass@localhost`, 646 | `godror`, 647 | `user/pass@//localhost`, 648 | ``, 649 | }, 650 | { 651 | `godror://user:pass@localhost/service_name/instance_name`, 652 | `godror`, 653 | `user/pass@//localhost/service_name/instance_name`, 654 | ``, 655 | }, 656 | { 657 | `godror://user:pass@localhost:2000/xe.oracle.docker`, 658 | `godror`, 659 | `user/pass@//localhost:2000/xe.oracle.docker`, 660 | ``, 661 | }, 662 | { 663 | `gr://username:password@host/ORCL`, 664 | `godror`, 665 | `username/password@//host/ORCL`, 666 | ``, 667 | }, 668 | { 669 | `gr://username:password@sales-server:1521/sales.us.acme.com`, 670 | `godror`, 671 | `username/password@//sales-server:1521/sales.us.acme.com`, 672 | ``, 673 | }, 674 | { 675 | `godror://username:password@sales-server.us.acme.com/sales.us.oracle.com`, 676 | `godror`, 677 | `username/password@//sales-server.us.acme.com/sales.us.oracle.com`, 678 | ``, 679 | }, 680 | { 681 | `trino://host:8001/`, 682 | `trino`, 683 | `http://user@host:8001?catalog=default`, 684 | ``, 685 | }, 686 | { 687 | `trino://host/catalogname/schemaname`, 688 | `trino`, 689 | `http://user@host:8080?catalog=catalogname&schema=schemaname`, 690 | ``, 691 | }, 692 | { 693 | `trs://admin@host/catalogname`, 694 | `trino`, 695 | `https://admin@host:8443?catalog=catalogname`, 696 | ``, 697 | }, 698 | { 699 | `pgx://`, 700 | `pgx`, 701 | `postgres://localhost:5432/`, 702 | ``, 703 | }, 704 | { 705 | `ca://`, 706 | `cql`, 707 | `localhost:9042`, 708 | ``, 709 | }, 710 | { 711 | `exa://`, 712 | `exasol`, 713 | `exa:localhost:8563`, 714 | ``, 715 | }, 716 | { 717 | `exa://user:pass@host:1883/dbname?autocommit=1`, 718 | `exasol`, 719 | `exa:host:1883;autocommit=1;password=pass;schema=dbname;user=user`, 720 | ``, 721 | }, 722 | { 723 | `ots://user:pass@localhost/instance_name`, 724 | `ots`, 725 | `https://user:pass@localhost/instance_name`, 726 | ``, 727 | }, 728 | { 729 | `ots+https://user:pass@localhost/instance_name`, 730 | `ots`, 731 | `https://user:pass@localhost/instance_name`, 732 | ``, 733 | }, 734 | { 735 | `ots+http://user:pass@localhost/instance_name`, 736 | `ots`, 737 | `http://user:pass@localhost/instance_name`, 738 | ``, 739 | }, 740 | { 741 | `tablestore://user:pass@localhost/instance_name`, 742 | `ots`, 743 | `https://user:pass@localhost/instance_name`, 744 | ``, 745 | }, 746 | { 747 | `tablestore+https://user:pass@localhost/instance_name`, 748 | `ots`, 749 | `https://user:pass@localhost/instance_name`, 750 | ``, 751 | }, 752 | { 753 | `tablestore+http://user:pass@localhost/instance_name`, 754 | `ots`, 755 | `http://user:pass@localhost/instance_name`, 756 | ``, 757 | }, 758 | { 759 | `bend://user:pass@localhost/instance_name?sslmode=disabled&warehouse=wh`, 760 | `databend`, 761 | `bend://user:pass@localhost/instance_name?sslmode=disabled&warehouse=wh`, 762 | ``, 763 | }, 764 | { 765 | `databend://user:pass@localhost/instance_name?tenant=tn&warehouse=wh`, 766 | `databend`, 767 | `databend://user:pass@localhost/instance_name?tenant=tn&warehouse=wh`, 768 | ``, 769 | }, 770 | { 771 | `flightsql://user:pass@localhost?timeout=3s&token=foobar&tls=enabled`, 772 | `flightsql`, 773 | `flightsql://user:pass@localhost?timeout=3s&token=foobar&tls=enabled`, 774 | ``, 775 | }, 776 | { 777 | `duckdb:/path/to/foo.db?access_mode=read_only&threads=4`, 778 | `duckdb`, 779 | `/path/to/foo.db?access_mode=read_only&threads=4`, 780 | ``, 781 | }, 782 | { 783 | `dk:///path/to/foo.db?access_mode=read_only&threads=4`, 784 | `duckdb`, 785 | `/path/to/foo.db?access_mode=read_only&threads=4`, 786 | ``, 787 | }, 788 | { 789 | `duckdb://`, 790 | `duckdb`, 791 | ``, 792 | ``, 793 | }, 794 | { 795 | `duckdb://:memory:`, 796 | `duckdb`, 797 | `:memory:`, 798 | ``, 799 | }, 800 | { 801 | `duckdb:?threads=4`, 802 | `duckdb`, 803 | `?threads=4`, 804 | ``, 805 | }, 806 | { 807 | `file:./testdata/test.sqlite3?a=b`, 808 | `sqlite3`, 809 | `./testdata/test.sqlite3?a=b`, 810 | ``, 811 | }, 812 | { 813 | `file:./testdata/test.duckdb?a=b`, 814 | `duckdb`, 815 | `./testdata/test.duckdb?a=b`, 816 | ``, 817 | }, 818 | { 819 | `file:__nonexistent__.db`, 820 | `sqlite3`, 821 | `__nonexistent__.db`, 822 | ``, 823 | }, 824 | { 825 | `file:__nonexistent__.sqlite3`, 826 | `sqlite3`, 827 | `__nonexistent__.sqlite3`, 828 | ``, 829 | }, 830 | { 831 | `file:__nonexistent__.duckdb`, 832 | `duckdb`, 833 | `__nonexistent__.duckdb`, 834 | ``, 835 | }, 836 | { 837 | `__nonexistent__.db`, 838 | `sqlite3`, 839 | `__nonexistent__.db`, 840 | ``, 841 | }, 842 | { 843 | `__nonexistent__.sqlite3`, 844 | `sqlite3`, 845 | `__nonexistent__.sqlite3`, 846 | ``, 847 | }, 848 | { 849 | `__nonexistent__.duckdb`, 850 | `duckdb`, 851 | `__nonexistent__.duckdb`, 852 | ``, 853 | }, 854 | { 855 | `file:fake.sqlite3?a=b`, 856 | `sqlite3`, 857 | `fake.sqlite3?a=b`, 858 | ``, 859 | }, 860 | { 861 | `fake.sq`, 862 | `sqlite3`, 863 | `fake.sq`, 864 | ``, 865 | }, 866 | { 867 | `file:fake.duckdb?a=b`, 868 | `duckdb`, 869 | `fake.duckdb?a=b`, 870 | ``, 871 | }, 872 | { 873 | `fake.dk`, 874 | `duckdb`, 875 | `fake.dk`, 876 | ``, 877 | }, 878 | { 879 | `file:/var/run/mysqld/mysqld.sock/mydb?timeout=90`, 880 | `mysql`, 881 | `unix(/var/run/mysqld/mysqld.sock)/mydb?timeout=90`, 882 | `/var/run/mysqld/mysqld.sock`, 883 | }, 884 | { 885 | `file:/var/run/postgresql`, 886 | `postgres`, 887 | `host=/var/run/postgresql`, 888 | `/var/run/postgresql`, 889 | }, 890 | { 891 | `file:/var/run/postgresql:6666/mydb`, 892 | `postgres`, 893 | `dbname=mydb host=/var/run/postgresql port=6666`, 894 | `/var/run/postgresql`, 895 | }, 896 | { 897 | `file:/var/run/postgresql/mydb`, 898 | `postgres`, 899 | `dbname=mydb host=/var/run/postgresql`, 900 | `/var/run/postgresql`, 901 | }, 902 | { 903 | `file:/var/run/postgresql:7777`, 904 | `postgres`, 905 | `host=/var/run/postgresql port=7777`, 906 | `/var/run/postgresql`, 907 | }, 908 | { 909 | `file://user:pass@/var/run/postgresql/mydb`, 910 | `postgres`, 911 | `dbname=mydb host=/var/run/postgresql password=pass user=user`, 912 | `/var/run/postgresql`, 913 | }, 914 | { 915 | `hive://myhost/mydb`, 916 | `hive`, 917 | `myhost:10000/mydb`, 918 | ``, 919 | }, 920 | { 921 | `hi://myhost:9999/mydb?auth=PLAIN`, 922 | `hive`, 923 | `myhost:9999/mydb?auth=PLAIN`, 924 | ``, 925 | }, 926 | { 927 | `hive2://user:pass@myhost:9999/mydb?auth=PLAIN`, 928 | `hive`, 929 | `user:pass@myhost:9999/mydb?auth=PLAIN`, 930 | ``, 931 | }, 932 | { 933 | `dy://user:pass@myhost:9999?TimeoutMs=1000`, 934 | `godynamo`, 935 | `Region=myhost;AkId=user;Secret_Key=pass;TimeoutMs=1000`, 936 | ``, 937 | }, 938 | { 939 | `br://user:pass@dbname`, 940 | `databricks`, 941 | `token:user@pass.databricks.com:443/sql/1.0/endpoints/dbname`, 942 | ``, 943 | }, 944 | { 945 | `brick://user:pass@dbname?timeout=1000&maxRows=1000`, 946 | `databricks`, 947 | `token:user@pass.databricks.com:443/sql/1.0/endpoints/dbname?maxRows=1000&timeout=1000`, 948 | ``, 949 | }, 950 | { 951 | `ydb://`, 952 | `ydb`, 953 | `grpc://localhost:2136/`, 954 | ``, 955 | }, 956 | { 957 | `yds://`, 958 | `ydb`, 959 | `grpcs://localhost:2135/`, 960 | ``, 961 | }, 962 | { 963 | `ydbs://user:pass@localhost:8888/?opt1=a&opt2=b`, 964 | `ydb`, 965 | `grpcs://user:pass@localhost:8888/?opt1=a&opt2=b`, 966 | ``, 967 | }, 968 | { 969 | `clickhouse://user:pass@localhost/?opt1=a&opt2=b`, 970 | `clickhouse`, 971 | `clickhouse://user:pass@localhost:9000/?opt1=a&opt2=b`, 972 | ``, 973 | }, 974 | { 975 | `clickhouse+http://user:pass@localhost/?opt1=a&opt2=b`, 976 | `clickhouse`, 977 | `http://user:pass@localhost/?opt1=a&opt2=b`, 978 | ``, 979 | }, 980 | { 981 | `clickhouse+https://user:pass@host/?opt1=a&opt2=b`, 982 | `clickhouse`, 983 | `https://user:pass@host/?opt1=a&opt2=b`, 984 | ``, 985 | }, 986 | } 987 | m := make(map[string]bool) 988 | for i, test := range tests { 989 | t.Run(strconv.Itoa(i), func(t *testing.T) { 990 | if _, ok := m[test.s]; ok { 991 | t.Fatalf("%s is already tested", test.s) 992 | } 993 | m[test.s] = true 994 | testParse(t, test.s, test.d, test.exp, test.path) 995 | }) 996 | } 997 | } 998 | 999 | func testParse(t *testing.T, s, d, exp, path string) { 1000 | t.Helper() 1001 | u, err := Parse(s) 1002 | switch { 1003 | case err != nil: 1004 | t.Errorf("%q expected no error, got: %v", s, err) 1005 | case u.GoDriver != "" && u.GoDriver != d: 1006 | t.Errorf("%q expected go driver %q, got: %q", s, d, u.GoDriver) 1007 | case u.GoDriver == "" && u.Driver != d: 1008 | t.Errorf("%q expected driver %q, got: %q", s, d, u.Driver) 1009 | case u.DSN != exp: 1010 | _, err := os.Stat(path) 1011 | if path != "" && err != nil && os.IsNotExist(err) { 1012 | t.Logf("%q expected dsn %q, got: %q -- ignoring because `%s` does not exist", s, exp, u.DSN, path) 1013 | } else { 1014 | t.Errorf("%q expected:\n%q\ngot:\n%q", s, exp, u.DSN) 1015 | } 1016 | } 1017 | } 1018 | 1019 | func TestBuildURL(t *testing.T) { 1020 | tests := []struct { 1021 | m map[string]any 1022 | exp string 1023 | err error 1024 | }{ 1025 | {nil, "", ErrInvalidDatabaseScheme}, 1026 | { 1027 | map[string]any{ 1028 | "proto": "mysql", 1029 | "transport": "tcp", 1030 | "host": "localhost", 1031 | "port": 999, 1032 | "q": map[string]any{ 1033 | "foo": "bar", 1034 | "opt1": "b", 1035 | }, 1036 | }, 1037 | "mysql+tcp://localhost:999?foo=bar&opt1=b", nil, 1038 | }, 1039 | { 1040 | map[string]any{ 1041 | "proto": "sqlserver", 1042 | "host": "localhost", 1043 | "port": "5555", 1044 | "instance": "instance", 1045 | "database": "dbname", 1046 | "q": map[string]any{ 1047 | "foo": "bar", 1048 | "opt1": "b", 1049 | }, 1050 | }, 1051 | "sqlserver://localhost:5555/instance/dbname?foo=bar&opt1=b", nil, 1052 | }, 1053 | { 1054 | map[string]any{ 1055 | "proto": "pg", 1056 | "host": "host name", 1057 | "user": "user name", 1058 | "password": "P!!!@@@@ 👀", 1059 | "database": "my awesome db", 1060 | "q": map[string]any{ 1061 | "foo": "bar is cool", 1062 | "opt1": "b zzzz@@@:/", 1063 | }, 1064 | }, 1065 | "pg://user+name:P%21%21%21%40%40%40%40+%F0%9F%91%80@host+name/my%20awesome%20db?foo=bar+is+cool&opt1=b+zzzz%40%40%40%3A%2F", nil, 1066 | }, 1067 | { 1068 | map[string]any{ 1069 | "file": "fake.sqlite3", 1070 | "q": map[string]any{ 1071 | "foo": "bar", 1072 | "opt1": "b", 1073 | }, 1074 | }, 1075 | "file:fake.sqlite3?foo=bar&opt1=b", nil, 1076 | }, 1077 | } 1078 | for i, test := range tests { 1079 | t.Run(strconv.Itoa(i), func(t *testing.T) { 1080 | switch s, err := BuildURL(test.m); { 1081 | case err != nil && !errors.Is(err, test.err): 1082 | t.Fatalf("expected error %v, got: %v", test.err, err) 1083 | case err != nil && test.err == nil: 1084 | t.Fatalf("expected no error, got: %v", err) 1085 | case s != test.exp: 1086 | t.Errorf("expected %q, got: %q", test.exp, s) 1087 | default: 1088 | t.Logf("dsn: %q", s) 1089 | } 1090 | switch u, err := FromMap(test.m); { 1091 | case err != nil: 1092 | t.Logf("parse error: %v", err) 1093 | default: 1094 | t.Logf("url: %q", u.String()) 1095 | } 1096 | }) 1097 | } 1098 | } 1099 | 1100 | func init() { 1101 | statFile, openFile := Stat, OpenFile 1102 | Stat = func(name string) (fs.FileInfo, error) { 1103 | if s, ok := newStat(name); ok { 1104 | return s, nil 1105 | } 1106 | return statFile(name) 1107 | } 1108 | OpenFile = func(name string) (fs.File, error) { 1109 | if s, ok := newStat(name); ok { 1110 | return s, nil 1111 | } 1112 | return openFile(name) 1113 | } 1114 | } 1115 | 1116 | type stat struct { 1117 | name string 1118 | mode fs.FileMode 1119 | content string 1120 | } 1121 | 1122 | func newStat(name string) (stat, bool) { 1123 | const ( 1124 | sqlite3Header = "SQLite format 3\000.........." 1125 | duckdbHeader = "12345678DUCK87654321.............." 1126 | ) 1127 | files := map[string]string{ 1128 | "fake.sqlite3": sqlite3Header, 1129 | "fake.sq": sqlite3Header, 1130 | "fake.duckdb": duckdbHeader, 1131 | "fake.dk": duckdbHeader, 1132 | } 1133 | switch name { 1134 | case "/var/run/postgresql": 1135 | return stat{name, fs.ModeDir, ""}, true 1136 | case "/var/run/mysqld/mysqld.sock": 1137 | return stat{name, fs.ModeSocket, ""}, true 1138 | case "fake.sqlite3", "fake.sq", "fake.duckdb", "fake.dk": 1139 | return stat{name, 0, files[name]}, true 1140 | } 1141 | return stat{}, false 1142 | } 1143 | 1144 | func (s stat) Name() string { return s.name } 1145 | func (s stat) Size() int64 { return int64(len(s.content)) } 1146 | func (s stat) Mode() fs.FileMode { return s.mode } 1147 | func (s stat) ModTime() time.Time { return time.Now() } 1148 | func (s stat) IsDir() bool { return s.mode&fs.ModeDir != 0 } 1149 | func (s stat) Sys() any { return nil } 1150 | func (s stat) Close() error { return nil } 1151 | 1152 | func (s stat) Stat() (fs.FileInfo, error) { 1153 | return s, nil 1154 | } 1155 | 1156 | func (s stat) Read(b []byte) (int, error) { 1157 | v := []byte(s.content) 1158 | copy(b, v) 1159 | return len(v), nil 1160 | } 1161 | --------------------------------------------------------------------------------