2020-08-06 20:58:47 +03:00
|
|
|
package source
|
|
|
|
|
|
|
|
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",
|
|
|
|
}
|
|
|
|
|
|
|
|
// LocationFileName returns the final component of the file/URL path.
|
|
|
|
func LocationFileName(src *Source) (string, error) {
|
|
|
|
if IsSQLLocation(src.Location) {
|
|
|
|
return "", errz.Errorf("location is not a file: %s", src.Location)
|
|
|
|
}
|
|
|
|
|
|
|
|
ploc, err := parseLoc(src.Location)
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
|
|
|
|
return ploc.name + ploc.ext, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// IsSQLLocation returns true if source location loc seems to be
|
|
|
|
// a DSN for a SQL driver.
|
|
|
|
func IsSQLLocation(loc string) bool {
|
|
|
|
for _, dbScheme := range dbSchemes {
|
|
|
|
if strings.HasPrefix(loc, dbScheme+"://") {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
2022-12-25 07:04:18 +03:00
|
|
|
// LocationWithPassword returns the location string with the password
|
|
|
|
// value set, overriding any existing password. If loc is not a URL
|
|
|
|
// (e.g. it's a file path), it is returned unmodified.
|
|
|
|
func LocationWithPassword(loc, passw string) (string, error) {
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2020-08-06 20:58:47 +03:00
|
|
|
// ShortLocation 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.
|
2020-08-06 20:58:47 +03:00
|
|
|
func ShortLocation(loc string) string {
|
|
|
|
if !IsSQLLocation(loc) {
|
|
|
|
// NOT a SQL location, must be a document (local filepath or URL).
|
|
|
|
|
|
|
|
// Let's check if it's http
|
2020-08-23 13:42:15 +03:00
|
|
|
u, ok := httpURL(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()
|
|
|
|
}
|
|
|
|
|
|
|
|
// parsedLoc is a parsed representation of a source location.
|
|
|
|
type parsedLoc struct {
|
|
|
|
// loc is the original unparsed location value.
|
|
|
|
loc string
|
|
|
|
|
|
|
|
// typ is the associated source driver type, which may
|
|
|
|
// be empty until later determination.
|
2023-11-21 00:42:38 +03:00
|
|
|
typ drivertype.Type
|
2020-08-06 20:58:47 +03:00
|
|
|
|
|
|
|
// scheme is the original location scheme
|
|
|
|
scheme string
|
|
|
|
|
|
|
|
// user is the username, if applicable.
|
|
|
|
user string
|
|
|
|
|
|
|
|
// pass is the password, if applicable.
|
|
|
|
pass string
|
|
|
|
|
|
|
|
// hostname is the hostname, if applicable.
|
|
|
|
hostname string
|
|
|
|
|
|
|
|
// port is the port number or 0 if not applicable.
|
|
|
|
port int
|
|
|
|
|
|
|
|
// name is the "source name", e.g. "sakila". Typically this
|
|
|
|
// is the database name, but for a file location such
|
|
|
|
// as "/path/to/things.xlsx" it would be "things".
|
|
|
|
name string
|
|
|
|
|
|
|
|
// ext is the file extension, if applicable.
|
|
|
|
ext string
|
|
|
|
|
2023-05-22 18:08:14 +03:00
|
|
|
// dsn is the connection "data source name" that can be used in a
|
2020-08-06 20:58:47 +03:00
|
|
|
// call to sql/Open. Empty for non-SQL locations.
|
|
|
|
dsn string
|
|
|
|
}
|
|
|
|
|
|
|
|
// parseLoc parses a location string. On return
|
|
|
|
// the typ field may not be set: further processing
|
|
|
|
// may be required.
|
|
|
|
func parseLoc(loc string) (*parsedLoc, error) {
|
|
|
|
ploc := &parsedLoc{loc: loc}
|
|
|
|
|
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)
|
|
|
|
ploc.ext = filepath.Ext(name)
|
|
|
|
if ploc.ext != "" {
|
|
|
|
name = name[:len(name)-len(ploc.ext)]
|
|
|
|
}
|
|
|
|
|
|
|
|
ploc.name = name
|
|
|
|
return ploc, nil
|
|
|
|
}
|
|
|
|
|
2020-08-23 13:42:15 +03:00
|
|
|
if u, ok := httpURL(loc); ok {
|
2020-08-06 20:58:47 +03:00
|
|
|
// It's a http or https URL
|
|
|
|
ploc.scheme = u.Scheme
|
|
|
|
ploc.hostname = u.Hostname()
|
|
|
|
if u.Port() != "" {
|
|
|
|
var err error
|
|
|
|
ploc.port, err = strconv.Atoi(u.Port())
|
|
|
|
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)
|
|
|
|
ploc.ext = path.Ext(name)
|
|
|
|
if ploc.ext != "" {
|
|
|
|
name = name[:len(name)-len(ploc.ext)]
|
|
|
|
}
|
|
|
|
|
|
|
|
ploc.name = name
|
|
|
|
return ploc, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
|
|
ploc.scheme = "sqlite3"
|
2020-08-06 20:58:47 +03:00
|
|
|
ploc.typ = typeSL3
|
2020-08-07 22:51:30 +03:00
|
|
|
ploc.dsn = fpath
|
|
|
|
|
|
|
|
// 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)
|
2020-08-06 20:58:47 +03:00
|
|
|
ploc.ext = filepath.Ext(name)
|
|
|
|
if ploc.ext != "" {
|
|
|
|
name = name[:len(name)-len(ploc.ext)]
|
|
|
|
}
|
|
|
|
|
|
|
|
ploc.name = name
|
|
|
|
return ploc, nil
|
|
|
|
}
|
|
|
|
|
2020-08-07 22:51:30 +03:00
|
|
|
u, err := dburl.Parse(loc)
|
|
|
|
if err != nil {
|
|
|
|
return nil, errz.Err(err)
|
|
|
|
}
|
|
|
|
|
|
|
|
ploc.scheme = u.OriginalScheme
|
|
|
|
ploc.dsn = u.DSN
|
|
|
|
ploc.user = u.User.Username()
|
|
|
|
ploc.pass, _ = u.User.Password()
|
2020-08-06 20:58:47 +03:00
|
|
|
ploc.hostname = u.Hostname()
|
|
|
|
if u.Port() != "" {
|
|
|
|
ploc.port, err = strconv.Atoi(u.Port())
|
|
|
|
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
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
switch ploc.scheme {
|
|
|
|
default:
|
|
|
|
return nil, errz.Errorf("parse location: invalid scheme: %s", loc)
|
|
|
|
case "sqlserver":
|
|
|
|
ploc.typ = typeMS
|
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
|
|
|
}
|
|
|
|
ploc.name = vals.Get("database")
|
|
|
|
case "postgres":
|
|
|
|
ploc.typ = typePg
|
|
|
|
ploc.name = strings.TrimPrefix(u.Path, "/")
|
|
|
|
case "mysql":
|
|
|
|
ploc.typ = typeMy
|
|
|
|
ploc.name = strings.TrimPrefix(u.Path, "/")
|
|
|
|
}
|
|
|
|
|
|
|
|
return ploc, nil
|
|
|
|
}
|
2023-01-01 06:17:44 +03:00
|
|
|
|
|
|
|
// AbsLocation returns the absolute path of loc. That is, relative
|
|
|
|
// paths etc. are resolved. If loc is not a file path or
|
|
|
|
// it cannot be processed, loc is returned unmodified.
|
|
|
|
func AbsLocation(loc string) string {
|
|
|
|
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
|
|
|
|
|
|
|
// locType is an enumeration of the various types of source location.
|
|
|
|
type locType string
|
|
|
|
|
|
|
|
const (
|
|
|
|
locTypeStdin = "stdin"
|
|
|
|
locTypeLocalFile = "local_file"
|
|
|
|
locTypeSQL = "sql"
|
|
|
|
locTypeRemoteFile = "remote_file"
|
|
|
|
locTypeUnknown = "unknown"
|
|
|
|
)
|
|
|
|
|
|
|
|
// getLocType returns the type of loc, or locTypeUnknown if it
|
|
|
|
// can't be determined.
|
|
|
|
func getLocType(loc string) locType {
|
|
|
|
switch {
|
|
|
|
case loc == StdinHandle:
|
|
|
|
// Convention: the "location" of stdin is always "@stdin"
|
|
|
|
return locTypeStdin
|
|
|
|
case IsSQLLocation(loc):
|
|
|
|
return locTypeSQL
|
|
|
|
case strings.HasPrefix(loc, "http://"),
|
|
|
|
strings.HasPrefix(loc, "https://"):
|
|
|
|
return locTypeRemoteFile
|
|
|
|
default:
|
|
|
|
}
|
|
|
|
|
|
|
|
if _, err := filepath.Abs(loc); err != nil {
|
|
|
|
return locTypeUnknown
|
|
|
|
}
|
|
|
|
return locTypeLocalFile
|
|
|
|
}
|
|
|
|
|
|
|
|
// httpURL tests if s is a well-structured HTTP or HTTPS url, and
|
|
|
|
// if so, returns the url and true.
|
|
|
|
func httpURL(s string) (u *url.URL, ok bool) {
|
|
|
|
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
|
|
|
|
}
|