2024-01-25 09:29:55 +03:00
|
|
|
// Package location contains functionality related to source location.
|
|
|
|
package location
|
2020-08-06 20:58:47 +03:00
|
|
|
|
|
|
|
import (
|
|
|
|
"net/url"
|
|
|
|
"path"
|
|
|
|
"path/filepath"
|
|
|
|
"strconv"
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
"github.com/xo/dburl"
|
2023-11-20 04:06:36 +03:00
|
|
|
|
|
|
|
"github.com/neilotoole/sq/libsq/core/errz"
|
2023-11-21 00:42:38 +03:00
|
|
|
"github.com/neilotoole/sq/libsq/source/drivertype"
|
2020-08-06 20:58:47 +03:00
|
|
|
)
|
|
|
|
|
2024-01-15 04:45:34 +03:00
|
|
|
// dbSchemes is a list of known SQL driver schemes.
|
2020-08-06 20:58:47 +03:00
|
|
|
var dbSchemes = []string{
|
|
|
|
"mysql",
|
|
|
|
"sqlserver",
|
|
|
|
"postgres",
|
|
|
|
"sqlite3",
|
|
|
|
}
|
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
// Filename returns the final component of the file/URL path.
|
|
|
|
func Filename(loc string) (string, error) {
|
|
|
|
if IsSQL(loc) {
|
|
|
|
return "", errz.Errorf("location is not a file: %s", loc)
|
2020-08-06 20:58:47 +03:00
|
|
|
}
|
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
fields, err := Parse(loc)
|
2020-08-06 20:58:47 +03:00
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
return fields.Name + fields.Ext, nil
|
2020-08-06 20:58:47 +03:00
|
|
|
}
|
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
// IsSQL returns true if source location loc seems to be
|
2020-08-06 20:58:47 +03:00
|
|
|
// a DSN for a SQL driver.
|
2024-01-25 09:29:55 +03:00
|
|
|
func IsSQL(loc string) bool {
|
2020-08-06 20:58:47 +03:00
|
|
|
for _, dbScheme := range dbSchemes {
|
|
|
|
if strings.HasPrefix(loc, dbScheme+"://") {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
// WithPassword returns the location string with the password
|
2022-12-25 07:04:18 +03:00
|
|
|
// value set, overriding any existing password. If loc is not a URL
|
|
|
|
// (e.g. it's a file path), it is returned unmodified.
|
2024-01-25 09:29:55 +03:00
|
|
|
func WithPassword(loc, passw string) (string, error) {
|
2022-12-25 07:04:18 +03:00
|
|
|
if _, ok := isFpath(loc); ok {
|
|
|
|
return loc, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
u, err := url.ParseRequestURI(loc)
|
|
|
|
if err != nil {
|
|
|
|
return "", errz.Err(err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if passw == "" {
|
|
|
|
// This will effectively remove any existing password in loc
|
|
|
|
u.User = url.User(u.User.Username())
|
|
|
|
} else {
|
|
|
|
u.User = url.UserPassword(u.User.Username(), passw)
|
|
|
|
}
|
|
|
|
|
|
|
|
return u.String(), nil
|
|
|
|
}
|
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
// Short returns a short location string. For example, the
|
2022-12-25 07:04:18 +03:00
|
|
|
// base name (data.xlsx) for a file, or for a DSN, user@host[:port]/db.
|
2024-01-25 09:29:55 +03:00
|
|
|
func Short(loc string) string {
|
|
|
|
if !IsSQL(loc) {
|
2020-08-06 20:58:47 +03:00
|
|
|
// NOT a SQL location, must be a document (local filepath or URL).
|
|
|
|
|
|
|
|
// Let's check if it's http
|
2024-01-25 09:29:55 +03:00
|
|
|
u, ok := isHTTP(loc)
|
2020-08-06 20:58:47 +03:00
|
|
|
if ok {
|
|
|
|
name := path.Base(u.Path)
|
|
|
|
if name == "" || name == "/" || name == "." {
|
|
|
|
// Well, if we don't have a good name from u.Path, we'll
|
|
|
|
// fall back to the hostname.
|
|
|
|
name = u.Hostname()
|
|
|
|
}
|
|
|
|
return name
|
|
|
|
}
|
|
|
|
|
|
|
|
// It's not a http URL, so it must be a filepath
|
|
|
|
loc = filepath.Clean(loc)
|
|
|
|
return filepath.Base(loc)
|
|
|
|
}
|
|
|
|
|
|
|
|
// It's a SQL driver
|
|
|
|
u, err := dburl.Parse(loc)
|
|
|
|
if err != nil {
|
|
|
|
return loc
|
|
|
|
}
|
|
|
|
|
|
|
|
if u.Scheme == "sqlite3" {
|
|
|
|
// special handling for sqlite
|
|
|
|
return path.Base(u.DSN)
|
|
|
|
}
|
|
|
|
|
|
|
|
sb := strings.Builder{}
|
|
|
|
if u.User != nil && len(u.User.Username()) > 0 {
|
|
|
|
sb.WriteString(u.User.Username())
|
|
|
|
sb.WriteString("@")
|
|
|
|
}
|
|
|
|
|
|
|
|
sb.WriteString(u.Host)
|
|
|
|
if u.Path != "" {
|
|
|
|
sb.WriteString(u.Path)
|
|
|
|
// path contains the db name
|
|
|
|
return sb.String()
|
|
|
|
}
|
|
|
|
|
|
|
|
// Else path is empty, db name was prob part of params
|
2022-08-08 22:14:42 +03:00
|
|
|
u2, err := url.ParseRequestURI(loc)
|
|
|
|
if err != nil {
|
|
|
|
return loc
|
|
|
|
}
|
|
|
|
vals, err := url.ParseQuery(u2.RawQuery)
|
2020-08-06 20:58:47 +03:00
|
|
|
if err != nil {
|
|
|
|
return loc
|
|
|
|
}
|
|
|
|
|
|
|
|
db := vals.Get("database")
|
|
|
|
if db == "" {
|
|
|
|
return loc
|
|
|
|
}
|
|
|
|
|
|
|
|
sb.WriteRune('/')
|
|
|
|
sb.WriteString(db)
|
|
|
|
return sb.String()
|
|
|
|
}
|
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
// Fields is a parsed representation of a source location.
|
|
|
|
type Fields struct {
|
|
|
|
// Loc is the original unparsed location value.
|
|
|
|
Loc string
|
2020-08-06 20:58:47 +03:00
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
// DriverType is the associated source driver type, which may
|
2020-08-06 20:58:47 +03:00
|
|
|
// be empty until later determination.
|
2024-01-25 09:29:55 +03:00
|
|
|
DriverType drivertype.Type
|
2020-08-06 20:58:47 +03:00
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
// Scheme is the original location Scheme.
|
|
|
|
Scheme string
|
2020-08-06 20:58:47 +03:00
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
// User is the username, if applicable.
|
|
|
|
User string
|
2020-08-06 20:58:47 +03:00
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
// Pass is the password, if applicable.
|
|
|
|
Pass string
|
2020-08-06 20:58:47 +03:00
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
// Hostname is the Hostname, if applicable.
|
|
|
|
Hostname string
|
2020-08-06 20:58:47 +03:00
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
// Name is the "source Name", e.g. "sakila". Typically this
|
|
|
|
// is the database Name, but for a file location such
|
2020-08-06 20:58:47 +03:00
|
|
|
// as "/path/to/things.xlsx" it would be "things".
|
2024-01-25 09:29:55 +03:00
|
|
|
Name string
|
2020-08-06 20:58:47 +03:00
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
// Ext is the file extension, if applicable.
|
|
|
|
Ext string
|
2020-08-06 20:58:47 +03:00
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
// DSN is the connection "data source Name" that can be used in a
|
2024-01-25 07:01:24 +03:00
|
|
|
// call to sql.Open. Empty for non-SQL locations.
|
2024-01-25 09:29:55 +03:00
|
|
|
DSN string
|
2024-01-27 10:11:24 +03:00
|
|
|
|
|
|
|
// Port is the Port number or 0 if not applicable.
|
|
|
|
Port int
|
2020-08-06 20:58:47 +03:00
|
|
|
}
|
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
// Parse parses a location string, returning a Fields instance representing
|
|
|
|
// the decomposition of the location. On return the Fields.DriverType field
|
|
|
|
// may not be set: further processing may be required.
|
|
|
|
func Parse(loc string) (*Fields, error) {
|
|
|
|
fields := &Fields{Loc: loc}
|
2020-08-06 20:58:47 +03:00
|
|
|
|
2020-08-07 22:51:30 +03:00
|
|
|
if !strings.Contains(loc, "://") {
|
|
|
|
if strings.Contains(loc, ":/") {
|
|
|
|
// malformed location, such as "sqlite3:/path/to/file"
|
2023-04-02 22:49:45 +03:00
|
|
|
return nil, errz.Errorf("parse location: invalid scheme: %s", loc)
|
2020-08-07 22:51:30 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// no scheme: it's just a regular file path for a document such as an Excel file
|
2020-08-06 20:58:47 +03:00
|
|
|
name := filepath.Base(loc)
|
2024-01-25 09:29:55 +03:00
|
|
|
fields.Ext = filepath.Ext(name)
|
|
|
|
if fields.Ext != "" {
|
|
|
|
name = name[:len(name)-len(fields.Ext)]
|
2020-08-06 20:58:47 +03:00
|
|
|
}
|
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
fields.Name = name
|
|
|
|
return fields, nil
|
2020-08-06 20:58:47 +03:00
|
|
|
}
|
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
if u, ok := isHTTP(loc); ok {
|
2020-08-06 20:58:47 +03:00
|
|
|
// It's a http or https URL
|
2024-01-25 09:29:55 +03:00
|
|
|
fields.Scheme = u.Scheme
|
|
|
|
fields.Hostname = u.Hostname()
|
2020-08-06 20:58:47 +03:00
|
|
|
if u.Port() != "" {
|
|
|
|
var err error
|
2024-01-25 09:29:55 +03:00
|
|
|
fields.Port, err = strconv.Atoi(u.Port())
|
2020-08-06 20:58:47 +03:00
|
|
|
if err != nil {
|
2023-04-02 22:49:45 +03:00
|
|
|
return nil, errz.Wrapf(err, "parse location: invalid port {%s}: {%s}", u.Port(), loc)
|
2020-08-06 20:58:47 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
name := path.Base(u.Path)
|
2024-01-25 09:29:55 +03:00
|
|
|
fields.Ext = path.Ext(name)
|
|
|
|
if fields.Ext != "" {
|
|
|
|
name = name[:len(name)-len(fields.Ext)]
|
2020-08-06 20:58:47 +03:00
|
|
|
}
|
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
fields.Name = name
|
|
|
|
return fields, nil
|
2020-08-06 20:58:47 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// sqlite3 is a special case, handle it now
|
2020-08-07 22:51:30 +03:00
|
|
|
const sqlitePrefix = "sqlite3://"
|
|
|
|
if strings.HasPrefix(loc, sqlitePrefix) {
|
|
|
|
fpath := strings.TrimPrefix(loc, sqlitePrefix)
|
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
fields.Scheme = "sqlite3"
|
|
|
|
fields.DriverType = drivertype.SQLite
|
|
|
|
fields.DSN = fpath
|
2020-08-07 22:51:30 +03:00
|
|
|
|
|
|
|
// fpath could include params, e.g. "sqlite3://C:\sakila.db?param=val"
|
|
|
|
if i := strings.IndexRune(fpath, '?'); i >= 0 {
|
|
|
|
// Snip off the params
|
|
|
|
fpath = fpath[:i]
|
|
|
|
}
|
|
|
|
|
|
|
|
name := filepath.Base(fpath)
|
2024-01-25 09:29:55 +03:00
|
|
|
fields.Ext = filepath.Ext(name)
|
|
|
|
if fields.Ext != "" {
|
|
|
|
name = name[:len(name)-len(fields.Ext)]
|
2020-08-06 20:58:47 +03:00
|
|
|
}
|
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
fields.Name = name
|
|
|
|
return fields, nil
|
2020-08-06 20:58:47 +03:00
|
|
|
}
|
|
|
|
|
2020-08-07 22:51:30 +03:00
|
|
|
u, err := dburl.Parse(loc)
|
|
|
|
if err != nil {
|
|
|
|
return nil, errz.Err(err)
|
|
|
|
}
|
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
fields.Scheme = u.OriginalScheme
|
|
|
|
fields.DSN = u.DSN
|
|
|
|
fields.User = u.User.Username()
|
|
|
|
fields.Pass, _ = u.User.Password()
|
|
|
|
fields.Hostname = u.Hostname()
|
2020-08-06 20:58:47 +03:00
|
|
|
if u.Port() != "" {
|
2024-01-25 09:29:55 +03:00
|
|
|
fields.Port, err = strconv.Atoi(u.Port())
|
2020-08-06 20:58:47 +03:00
|
|
|
if err != nil {
|
2023-04-02 22:49:45 +03:00
|
|
|
return nil, errz.Wrapf(err, "parse location: invalid port {%s}: %s", u.Port(), loc)
|
2020-08-06 20:58:47 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
switch fields.Scheme {
|
2020-08-06 20:58:47 +03:00
|
|
|
default:
|
|
|
|
return nil, errz.Errorf("parse location: invalid scheme: %s", loc)
|
|
|
|
case "sqlserver":
|
2024-01-25 09:29:55 +03:00
|
|
|
fields.DriverType = drivertype.MSSQL
|
2022-08-08 22:14:42 +03:00
|
|
|
|
|
|
|
u2, err := url.ParseRequestURI(loc)
|
|
|
|
if err != nil {
|
2023-04-02 22:49:45 +03:00
|
|
|
return nil, errz.Wrapf(err, "parse location: %s", loc)
|
2022-08-08 22:14:42 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
vals, err := url.ParseQuery(u2.RawQuery)
|
2020-08-06 20:58:47 +03:00
|
|
|
if err != nil {
|
|
|
|
return nil,
|
2023-04-02 22:49:45 +03:00
|
|
|
errz.Wrapf(err, "parse location: %s", loc)
|
2020-08-06 20:58:47 +03:00
|
|
|
}
|
2024-01-25 09:29:55 +03:00
|
|
|
fields.Name = vals.Get("database")
|
2020-08-06 20:58:47 +03:00
|
|
|
case "postgres":
|
2024-01-25 09:29:55 +03:00
|
|
|
fields.DriverType = drivertype.Pg
|
|
|
|
fields.Name = strings.TrimPrefix(u.Path, "/")
|
2020-08-06 20:58:47 +03:00
|
|
|
case "mysql":
|
2024-01-25 09:29:55 +03:00
|
|
|
fields.DriverType = drivertype.MySQL
|
|
|
|
fields.Name = strings.TrimPrefix(u.Path, "/")
|
2020-08-06 20:58:47 +03:00
|
|
|
}
|
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
return fields, nil
|
2020-08-06 20:58:47 +03:00
|
|
|
}
|
2023-01-01 06:17:44 +03:00
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
// Abs returns the absolute path of loc. That is, relative
|
2023-01-01 06:17:44 +03:00
|
|
|
// paths etc. are resolved. If loc is not a file path or
|
|
|
|
// it cannot be processed, loc is returned unmodified.
|
2024-01-25 09:29:55 +03:00
|
|
|
func Abs(loc string) string {
|
2023-01-01 06:17:44 +03:00
|
|
|
if fpath, ok := isFpath(loc); ok {
|
|
|
|
return fpath
|
|
|
|
}
|
|
|
|
|
|
|
|
return loc
|
|
|
|
}
|
|
|
|
|
|
|
|
// isFpath returns the absolute filepath and true if loc is a file path.
|
|
|
|
func isFpath(loc string) (fpath string, ok bool) {
|
|
|
|
// This is not exactly an industrial-strength algorithm...
|
|
|
|
if strings.Contains(loc, ":/") {
|
|
|
|
// Excludes "http:/" etc
|
|
|
|
return "", false
|
|
|
|
}
|
|
|
|
|
2023-01-01 06:57:10 +03:00
|
|
|
if strings.Contains(loc, "sqlite:") {
|
2023-01-01 06:17:44 +03:00
|
|
|
// Excludes "sqlite:my_file.db"
|
2023-01-01 06:57:10 +03:00
|
|
|
// Be wary of windows paths, e.g. "D:\a\b\c.file"
|
2023-01-01 06:17:44 +03:00
|
|
|
return "", false
|
|
|
|
}
|
|
|
|
|
|
|
|
fpath, err := filepath.Abs(loc)
|
|
|
|
if err != nil {
|
|
|
|
return "", false
|
|
|
|
}
|
|
|
|
|
|
|
|
return fpath, true
|
|
|
|
}
|
2024-01-15 04:45:34 +03:00
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
// Type is an enumeration of the various types of source location.
|
|
|
|
type Type string
|
2024-01-15 04:45:34 +03:00
|
|
|
|
|
|
|
const (
|
2024-01-25 09:29:55 +03:00
|
|
|
TypeStdin = "stdin"
|
|
|
|
TypeLocalFile = "local_file"
|
|
|
|
TypeSQL = "sql"
|
|
|
|
TypeRemoteFile = "remote_file"
|
|
|
|
TypeUnknown = "unknown"
|
2024-01-15 04:45:34 +03:00
|
|
|
)
|
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
// TypeOf returns the type of loc, or locTypeUnknown if it
|
2024-01-15 04:45:34 +03:00
|
|
|
// can't be determined.
|
2024-01-25 09:29:55 +03:00
|
|
|
func TypeOf(loc string) Type {
|
2024-01-15 04:45:34 +03:00
|
|
|
switch {
|
2024-01-25 09:29:55 +03:00
|
|
|
case loc == "@stdin":
|
2024-01-15 04:45:34 +03:00
|
|
|
// Convention: the "location" of stdin is always "@stdin"
|
2024-01-25 09:29:55 +03:00
|
|
|
return TypeStdin
|
|
|
|
case IsSQL(loc):
|
|
|
|
return TypeSQL
|
2024-01-15 04:45:34 +03:00
|
|
|
case strings.HasPrefix(loc, "http://"),
|
|
|
|
strings.HasPrefix(loc, "https://"):
|
2024-01-25 09:29:55 +03:00
|
|
|
return TypeRemoteFile
|
2024-01-15 04:45:34 +03:00
|
|
|
default:
|
|
|
|
}
|
|
|
|
|
|
|
|
if _, err := filepath.Abs(loc); err != nil {
|
2024-01-25 09:29:55 +03:00
|
|
|
return TypeUnknown
|
2024-01-15 04:45:34 +03:00
|
|
|
}
|
2024-01-25 09:29:55 +03:00
|
|
|
return TypeLocalFile
|
2024-01-15 04:45:34 +03:00
|
|
|
}
|
|
|
|
|
2024-01-25 09:29:55 +03:00
|
|
|
// isHTTP tests if s is a well-structured HTTP or HTTPS url, and
|
2024-01-15 04:45:34 +03:00
|
|
|
// if so, returns the url and true.
|
2024-01-25 09:29:55 +03:00
|
|
|
func isHTTP(s string) (u *url.URL, ok bool) {
|
2024-01-15 04:45:34 +03:00
|
|
|
var err error
|
|
|
|
u, err = url.Parse(s)
|
|
|
|
if err != nil || u.Host == "" || !(u.Scheme == "http" || u.Scheme == "https") {
|
|
|
|
return nil, false
|
|
|
|
}
|
|
|
|
|
|
|
|
return u, true
|
|
|
|
}
|
2024-01-25 09:29:55 +03:00
|
|
|
|
|
|
|
// Redact returns a redacted version of the source
|
|
|
|
// location loc, with the password component (if any) of
|
|
|
|
// the location masked.
|
|
|
|
func Redact(loc string) string {
|
|
|
|
switch {
|
|
|
|
case loc == "",
|
|
|
|
strings.HasPrefix(loc, "/"),
|
|
|
|
strings.HasPrefix(loc, "sqlite3://"):
|
|
|
|
|
|
|
|
// REVISIT: If it's a sqlite URI, could it have auth details in there?
|
|
|
|
// e.g. "?_auth_pass=foo"
|
|
|
|
return loc
|
|
|
|
case strings.HasPrefix(loc, "http://"), strings.HasPrefix(loc, "https://"):
|
|
|
|
u, err := url.ParseRequestURI(loc)
|
|
|
|
if err != nil {
|
|
|
|
// If we can't parse it, just return the original loc
|
|
|
|
return loc
|
|
|
|
}
|
|
|
|
|
|
|
|
return u.Redacted()
|
|
|
|
}
|
|
|
|
|
|
|
|
// At this point, we expect it's a DSN
|
|
|
|
dbu, err := dburl.Parse(loc)
|
|
|
|
if err != nil {
|
|
|
|
// Shouldn't happen, but if it does, simply return the
|
|
|
|
// unmodified loc.
|
|
|
|
return loc
|
|
|
|
}
|
|
|
|
|
|
|
|
return dbu.Redacted()
|
|
|
|
}
|