├── .github ├── assets │ └── example.png └── workflows │ └── release.yml ├── .gitignore ├── Dockerfile ├── Makefile ├── README.md ├── cmd ├── dcat │ └── main.go ├── dconn │ ├── main.go │ └── rpc.go ├── dkill │ └── main.go ├── dls │ └── main.go ├── dps │ └── main.go ├── dsql │ └── main.go └── dtail │ └── main.go ├── go.mod ├── go.sum └── pkg ├── dconf ├── default.go ├── dsn.go ├── open.go └── types.go ├── ddb ├── connection.go ├── mysql.go ├── open.go ├── open_daemon.go ├── postgres.go ├── rpc.go ├── sqlite.go └── types.go └── dio ├── csv.go ├── error.go ├── gloss.go ├── json.go ├── jsonl.go ├── open.go ├── sql.go ├── usage.go └── writer.go /.github/assets/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yznts/dsh/dd098441519bcabee0a42a0f310db313b69c12f1/.github/assets/example.png -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | permissions: 9 | contents: write 10 | packages: write 11 | 12 | env: 13 | REGISTRY: ghcr.io 14 | IMAGE_NAME: ${{ github.repository }} 15 | 16 | jobs: 17 | 18 | docker: 19 | name: Release Docker image 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Check out 23 | uses: actions/checkout@v4 24 | 25 | - name: Log in to the Container registry 26 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 27 | with: 28 | registry: ${{ env.REGISTRY }} 29 | username: ${{ github.actor }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Extract metadata 33 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 34 | id: meta 35 | with: 36 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 37 | 38 | - name: Build and push Docker image 39 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 40 | with: 41 | context: . 42 | push: true 43 | tags: ${{ steps.meta.outputs.tags }} 44 | labels: ${{ steps.meta.outputs.labels }} 45 | 46 | binaries: 47 | name: Release Binaries 48 | runs-on: ubuntu-latest 49 | strategy: 50 | matrix: 51 | goos: [linux, windows, darwin] 52 | goarch: [amd64, arm64] 53 | exclude: 54 | - goarch: arm64 55 | goos: windows 56 | steps: 57 | - name: Check out 58 | uses: actions/checkout@v4 59 | 60 | - name: Go Release Daemon 61 | uses: wangyoucao577/go-release-action@v1 62 | with: 63 | github_token: ${{ secrets.GITHUB_TOKEN }} 64 | goos: ${{ matrix.goos }} 65 | goarch: ${{ matrix.goarch }} 66 | multi_binaries: true 67 | project_path: ./cmd/... 68 | ldflags: -s -w 69 | build_flags: -tags daemon 70 | asset_name: dsh-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }} 71 | 72 | - name: Go Release Daemonless 73 | uses: wangyoucao577/go-release-action@v1 74 | with: 75 | github_token: ${{ secrets.GITHUB_TOKEN }} 76 | goos: ${{ matrix.goos }} 77 | goarch: ${{ matrix.goarch }} 78 | multi_binaries: true 79 | project_path: ./cmd/... 80 | ldflags: -s -w 81 | asset_name: dsh-daemonless-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }} 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # We don't want binaries in the repo 3 | bin 4 | 5 | # For testing purposes 6 | .env* 7 | compose.yml 8 | compose.yaml 9 | sakila.sqlite3 10 | 11 | # Not ready yet 12 | cmd/dbrowse 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ------------- 2 | # build stage 3 | # ------------- 4 | FROM golang:alpine AS build 5 | 6 | # System deps 7 | RUN apk add build-base 8 | 9 | # Attach sources 10 | WORKDIR /src 11 | ADD . /src 12 | 13 | # Build 14 | RUN make build 15 | 16 | # ------------- 17 | # runtime stage 18 | # ------------- 19 | FROM alpine 20 | 21 | # Copy utilities 22 | COPY --from=build /src/bin/* /usr/local/bin/ 23 | 24 | # Set workdir 25 | WORKDIR /root 26 | 27 | # Entrypoint 28 | ENTRYPOINT ["sh"] 29 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | build-daemon: 3 | go build -ldflags="-w -s" -tags daemon -o bin/ ./cmd/... 4 | go build -ldflags="-w -s" -o bin/ ./cmd/dconn # dconn is a special case 5 | 6 | build-daemonless: 7 | go build -ldflags="-w -s" -o bin/ ./cmd/... 8 | 9 | build: build-daemonless 10 | 11 | install-daemon: 12 | go install -tags daemon ./cmd/... 13 | go install ./cmd/dconn 14 | 15 | install-daemonless: 16 | go install ./cmd/... 17 | 18 | install: install-daemonless 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

dsh

3 | 4 |

5 | A set of command line database tools 6 |

7 | 8 | ```go 9 | go install github.com/yznts/dsh/cmd/...@latest 10 | ``` 11 | 12 | Main goal of the project is to provide a set of multiplatform tools 13 | to work with databases in a unified way, 14 | avoiding differences in UX between clients like `psql`, `sqlite3`, `mysql`, etc. 15 | 16 | It tries to stick with the UNIX-like naming and approach, 17 | where each tool does one thing and does it well. 18 | List database tables, or table columns? Just use `dls`. 19 | Get table contents? Use `dcat`. 20 | Or, if you need just to execute an SQL query, `dsql` is here for you. 21 | Want to get the output in JSON, JSONL, CSV? 22 | No problem, just specify an according flag, like `-json` or `-csv`. 23 | 24 | ![example](.github/assets/example.png) 25 | 26 | Now, utility set includes: 27 | - `dls` - lists database tables or table columns 28 | - `dsql` - executes SQL queries 29 | - `dcat` - outputs table data (in the not-so-dumb way) 30 | - `dps` - lists database processes (if supported by the database) 31 | - `dkill` - kills database processes (if supported by the database) 32 | 33 | May be used with: 34 | - `sqlite` 35 | - `postgresql` 36 | - `mysql` (no certificates support yet) 37 | 38 | And supports this output formats: 39 | - `json` (partial support) 40 | - `jsonl` 41 | - `csv` 42 | - `gloss` (default terminal output) 43 | 44 | ## Installation 45 | 46 | You have multiple ways to install/use this utility set: 47 | - Install in Go-way 48 | - Build by yourself 49 | - Download binaries 50 | - Spin-up a Docker container 51 | 52 | ### Install in Go-way 53 | 54 | This is the easiest way to install, 55 | but you need to have Go installed on your machine, 56 | including `GOBIN` in your `PATH`. 57 | 58 | ```bash 59 | go install github.com/yznts/dsh/cmd/...@latest 60 | ``` 61 | 62 | ### Build by yourself 63 | 64 | This way still requires Go to be installed on your machine, 65 | but it's up to you to decide where to put the binaries. 66 | 67 | ```bash 68 | mkdir -p /tmp/dsh && cd /tmp/dsh 69 | git clone git@github.com:yznts/dsh.git . 70 | make build 71 | 72 | # You'll find binaries in the `bin` directory. 73 | # Feel free to move them to the desired location, e.g. /usr/local/bin. 74 | ``` 75 | 76 | ### Download binaries 77 | 78 | Also you have an option to download the latest binaries from the [Releases](https://github.com/yznts/dsh/releases) page. 79 | Please note, that darwin(macos) binaries are not signed! 80 | If you know a simple way to handle this issue, please open issue or PR. 81 | 82 | ### Spin-up a Docker container 83 | 84 | Docker way doesn't require Go to be installed on your machine 85 | and it allows you to use the tooling in isolated way, 86 | without polluting your system. 87 | 88 | ```bash 89 | docker run --rm -it ghcr.io/yznts/dsh:latest 90 | ``` 91 | 92 | ## Usage 93 | 94 | No need to copy-paste utilities descriptions here. 95 | Most of them you can recognize by their names. 96 | Each tool has its own help message, which you can get by running it with `-h` flag. 97 | From there you can understand tool purpose, how to use it and what flags are available. 98 | 99 | To avoid providing database connection details each time you run a tool, 100 | you can use environment variables. 101 | 102 | ```bash 103 | $ export DSN="postgres://user:password@localhost:5432/dbname" 104 | $ dls # No need to provide -dsn here 105 | ``` 106 | 107 | DSN composition might be a bit challenging. 108 | Here is a general template for it: 109 | 110 | ``` 111 | [protocol]://[username]:[password]@[host]:[port]/[database]?[params] 112 | ``` 113 | 114 | Some examples of DSNs for different databases: 115 | 116 | ``` 117 | # SQLite 118 | # We can use both absolute and relative paths. 119 | sqlite:///abs/path/to/db.sqlite 120 | sqlite3://rel/path/to/db.sqlite 121 | 122 | # Postgres 123 | # Postgres DSN is quite straightforward. 124 | postgres://user:password@localhost:5432/dbname 125 | postgresql://user:password@localhost:5432/dbname 126 | postgresql://user:password@localhost:5432/dbname?sslmode=verify-full&sslrootcert=/path/to/ca.pem&sslkey=/path/to/client-key.pem&sslcert=/path/to/client-cert.pem 127 | 128 | # MySQL 129 | # Please note, that our MySQL integration doesn't support certificates yet. 130 | # Also, DSN is a bit different from the standard one. 131 | # It doesn't have a protocol part, which wraps the host+port part. 132 | mysql://user:password@localhost:3306/dbname?parseTime=true 133 | ``` 134 | -------------------------------------------------------------------------------- /cmd/dcat/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strings" 10 | 11 | "github.com/yznts/dsh/pkg/dconf" 12 | "github.com/yznts/dsh/pkg/ddb" 13 | "github.com/yznts/dsh/pkg/dio" 14 | ) 15 | 16 | // Tool flags 17 | var ( 18 | // Default 19 | fdsn = flag.String("dsn", "", "Database connection (can be set via DSN/DATABASE/DATABASE_URL env)") 20 | // Formats 21 | fsql = flag.Bool("sql", false, "Output in SQL format") 22 | fcsv = flag.Bool("csv", false, "Output in CSV format") 23 | fjsonl = flag.Bool("jsonl", false, "Output in JSON lines format") 24 | // Options 25 | fwhere = flag.String("where", "", "WHERE clause") 26 | ) 27 | 28 | // Tool usage / description 29 | var ( 30 | fusage = "[flags...] table" 31 | fdescr = "The dcat utility reads table data and writes it to the standard output in desired format. " + 32 | "Because of chunked data fetching, output options might be limited. " + 33 | "Utility tries to avoid accumulating data in the memory. " + 34 | "If dcat output options are not enough and memory usage is not a concern, consider using dsql instead." 35 | ) 36 | 37 | // Database connection 38 | var db ddb.Database 39 | 40 | // Output writers 41 | var ( 42 | stdout dio.Writer 43 | stderr dio.Writer 44 | ) 45 | 46 | // Simplify assignments 47 | var err error 48 | 49 | func main() { 50 | // Provide usage 51 | flag.Usage = dio.Usage(fusage, fdescr) 52 | 53 | // Parse flags 54 | flag.Parse() 55 | 56 | // Resolve output writer. 57 | // 58 | // We are using only multiline writers here 59 | // because we are going to query the database in chunks 60 | // and write the result in chunks as well. 61 | // Otherwise, we will have to store the whole result in memory. 62 | // 63 | // The only exception is gloss writer. 64 | // We will limit the output to 1k rows in that case. 65 | stdout = dio.Open(os.Stdout, *fsql, *fcsv, false, *fjsonl) 66 | stderr = dio.Open(os.Stderr, *fsql, *fcsv, false, *fjsonl) 67 | 68 | // Determine if the output format supports multiple writes. 69 | // Otherwise, we are limiting the output to 1k rows with a warning. 70 | limited := false 71 | if !stdout.Multi() { 72 | limited = true 73 | // Let's also check if the output format is gloss. 74 | // Other formats are not supposed to be used without multiple writes. 75 | if _, gloss := stdout.(*dio.Gloss); !gloss { 76 | dio.Assert(stderr, errors.New("output format does not support multiple writes")) 77 | } 78 | } 79 | 80 | // Resolve dsn and database connection 81 | dsn, err := dconf.GetDsn(*fdsn) 82 | dio.Assert(stderr, err) 83 | db, err = ddb.Open(dsn) 84 | dio.Assert(stderr, err) 85 | if db, iscloser := db.(io.Closer); iscloser { 86 | defer db.Close() 87 | } 88 | 89 | // Extract table name from arguments 90 | table := flag.Arg(0) 91 | if table == "" { 92 | dio.Assert(stderr, errors.New("missing table name")) 93 | } 94 | 95 | // Get rows count 96 | data, err := db.QueryData(fmt.Sprintf("SELECT COUNT(*) FROM %s", table)) 97 | dio.Assert(stderr, err) 98 | count := int(data.Rows[0][0].(int64)) 99 | 100 | // If the total rows count is less than 1k, we can get back to non-limited mode. 101 | if count < 1000 { 102 | limited = false 103 | } 104 | 105 | // Make offsets list 106 | offsets := []int{} 107 | for offset := 0; offset < count; offset += 1000 { 108 | offsets = append(offsets, offset) 109 | } 110 | 111 | // If we are limited, we need only first chunk. 112 | // Also, we need to warn the user about it. 113 | if limited { 114 | offsets = offsets[:1] 115 | if stdout, warner := stdout.(dio.WarningWriter); warner { 116 | stdout.WriteWarning("output is limited to 1k rows") 117 | } 118 | } 119 | 120 | // If writer is SQL, we're setting appropriate mode and table name 121 | if stdout, ok := stdout.(*dio.Sql); ok { 122 | stdout.SetMode("data") 123 | stdout.SetTable(table) 124 | } 125 | 126 | // Iterate over chunks and query the database 127 | for _, offset := range offsets { 128 | // Compose limit/offset query with WHERE clause 129 | query := &strings.Builder{} 130 | query.WriteString(fmt.Sprintf("SELECT * FROM %s ", table)) 131 | if *fwhere != "" { 132 | query.WriteString(fmt.Sprintf("WHERE %s ", *fwhere)) 133 | } 134 | query.WriteString(fmt.Sprintf("LIMIT 1000 OFFSET %d", offset)) 135 | // Execute query 136 | data, err := db.QueryData(query.String()) 137 | dio.Assert(stderr, err) 138 | // Don't collect the data and just write it to the output, 139 | // because we don't want to keep it in memory. 140 | // That's why we are requiring closable writers here. 141 | stdout.WriteData(data) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /cmd/dconn/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | 7 | "github.com/yznts/dsh/pkg/dconf" 8 | "github.com/yznts/dsh/pkg/ddb" 9 | "github.com/yznts/dsh/pkg/dio" 10 | ) 11 | 12 | // Tool flags 13 | var ( 14 | // Default 15 | fdsn = flag.String("dsn", "", "Database connection (can be set via DSN/DATABASE/DATABASE_URL env)") 16 | // Options 17 | frpc = flag.String("rpc", ":25123", "RPC server address to listen on") 18 | ) 19 | 20 | // Tool usage / description 21 | var ( 22 | fusage = "[flags...]" 23 | fdescr = "The dconn utility is a simple intermediary between the database and the client. " + 24 | "Supports the same databases as pkg/ddb allows. " + 25 | "Client also included in pkg/ddb. " + 26 | "Replicates the same method set as ddb.Database provides (for compatibility). " + 27 | "Main purpose is to provide an ability to communicate with database without using additional drivers/libraries. " + 28 | "It might be useful for cases when driver is not supported, or you don't want to import a driver at all for some reason." 29 | ) 30 | 31 | // Database connection 32 | var db ddb.Database 33 | 34 | // Output writers 35 | var ( 36 | stdout dio.Writer 37 | stderr dio.Writer 38 | ) 39 | 40 | // Simplify assignments 41 | var err error 42 | 43 | func main() { 44 | // Provide usage 45 | flag.Usage = dio.Usage(fusage, fdescr) 46 | 47 | // Parse flags 48 | flag.Parse() 49 | 50 | // Resolve output writer 51 | stdout = dio.Open(os.Stdout) 52 | stderr = dio.Open(os.Stderr) 53 | 54 | // Resolve dsn and database connection 55 | dsn, err := dconf.GetDsn(*fdsn) 56 | dio.Assert(stderr, err) 57 | db, err = ddb.Open(dsn) 58 | dio.Assert(stderr, err) 59 | 60 | // Start rpc server 61 | rpcserver(*frpc).Await() 62 | } 63 | -------------------------------------------------------------------------------- /cmd/dconn/rpc.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net" 5 | "net/rpc" 6 | 7 | "github.com/yznts/dsh/pkg/ddb" 8 | "go.kyoto.codes/zen/v3/async" 9 | ) 10 | 11 | // RpcKillProcessArgs holds arguments for Rpc.KillProcess. 12 | type RpcKillProcessArgs struct { 13 | Pid int 14 | Force bool 15 | } 16 | 17 | // Rpc provides a set of RPC-compatible wrap methods 18 | // around ddb.Database. 19 | type Rpc struct{} 20 | 21 | // QueryData is a wrap method around ddb.Database.QueryData. 22 | func (s *Rpc) QueryData(query string, res *ddb.Data) error { 23 | data, err := db.QueryData(query) 24 | if err != nil { 25 | return err 26 | } 27 | *res = *data 28 | return nil 29 | } 30 | 31 | // QueryTables is a wrap method around ddb.Database.QueryTables. 32 | func (s *Rpc) QueryTables(empty string, res *[]ddb.Table) error { 33 | tables, err := db.QueryTables() 34 | if err != nil { 35 | return err 36 | } 37 | *res = tables 38 | return nil 39 | } 40 | 41 | // QueryColumns is a wrap method around ddb.Database.QueryColumns. 42 | func (s *Rpc) QueryColumns(table string, res *[]ddb.Column) error { 43 | columns, err := db.QueryColumns(table) 44 | if err != nil { 45 | return err 46 | } 47 | *res = columns 48 | return nil 49 | } 50 | 51 | // QueryProcesses is a wrap method around ddb.Database.QueryProcesses. 52 | func (s *Rpc) QueryProcesses(empty string, res *[]ddb.Process) error { 53 | processes, err := db.QueryProcesses() 54 | if err != nil { 55 | return err 56 | } 57 | *res = processes 58 | return nil 59 | } 60 | 61 | // KillProcess is a wrap method around ddb.Database.KillProcess. 62 | func (s *Rpc) KillProcess(args RpcKillProcessArgs, res *bool) error { 63 | err := db.KillProcess(args.Pid, args.Force) 64 | if err != nil { 65 | *res = false 66 | } 67 | return err 68 | } 69 | 70 | // rpcserver starts an RPC server on the given address. 71 | func rpcserver(addr string) *async.Future[bool] { 72 | return async.New(func() (bool, error) { 73 | ln, err := net.Listen("tcp", addr) 74 | if err != nil { 75 | panic(err) 76 | } 77 | server := &Rpc{} 78 | rpc.Register(server) 79 | rpc.Accept(ln) 80 | return false, nil 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /cmd/dkill/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "io" 6 | "os" 7 | "regexp" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/yznts/dsh/pkg/dconf" 12 | "github.com/yznts/dsh/pkg/ddb" 13 | "github.com/yznts/dsh/pkg/dio" 14 | "go.kyoto.codes/zen/v3/slice" 15 | ) 16 | 17 | // Tool flags 18 | var ( 19 | // Default 20 | fdsn = flag.String("dsn", "", "Database connection (can be set via DSN/DATABASE/DATABASE_URL env)") 21 | // Options 22 | fforce = flag.Bool("force", false, "Terminate the process, instead of graceful shutdown") 23 | fexceed = flag.Bool("exceed", false, "We're killing all processes exceeding a provided duration (Go time.Duration format)") 24 | fquery = flag.Bool("query", false, "We're killing all processes for query regex") 25 | fuser = flag.Bool("user", false, "We're killing all processes for username") 26 | fpid = flag.Bool("pid", false, "We're killing a process by PID (default)") 27 | fdb = flag.Bool("db", false, "We're killing all processes for database") 28 | ) 29 | 30 | // Tool usage / description 31 | var ( 32 | fusage = "[flags...] " 33 | fdescr = "The dkill utility kills processes, depending on the flag and argument provided." 34 | ) 35 | 36 | // Database connection 37 | var db ddb.Database 38 | 39 | // Output writers 40 | var ( 41 | stdout dio.Writer 42 | stderr dio.Writer 43 | ) 44 | 45 | // Simplify assignments 46 | var err error 47 | 48 | func main() { 49 | // Provide usage 50 | flag.Usage = dio.Usage(fusage, fdescr) 51 | 52 | // Parse flags 53 | flag.Parse() 54 | 55 | // Resolve output writer 56 | stdout = dio.Open(os.Stdout, false, false, false) 57 | stderr = dio.Open(os.Stderr, false, false, false) 58 | 59 | // Resolve dsn and database connection 60 | dsn, err := dconf.GetDsn(*fdsn) 61 | dio.Assert(stderr, err) 62 | db, err = ddb.Open(dsn) 63 | dio.Assert(stderr, err) 64 | if db, iscloser := db.(io.Closer); iscloser { 65 | defer db.Close() 66 | } 67 | 68 | // Query the database for the currently running processes 69 | processes, err := db.QueryProcesses() 70 | dio.Assert(stderr, err) 71 | 72 | // Find out processes to kill 73 | kill := []ddb.Process{} 74 | switch { 75 | case *fpid: 76 | pid, err := strconv.Atoi(flag.Arg(0)) 77 | dio.Assert(stderr, err, "provided PID is not a number") 78 | kill = slice.Filter(processes, func(p ddb.Process) bool { 79 | return p.Pid == pid 80 | }) 81 | break 82 | case *fexceed: 83 | dur, err := time.ParseDuration(flag.Arg(0)) 84 | dio.Assert(stderr, err, "provided duration is not a valid Go time.Duration") 85 | kill = slice.Filter(processes, func(p ddb.Process) bool { 86 | return p.Duration > dur 87 | }) 88 | break 89 | case *fquery: 90 | rgx, err := regexp.Compile(flag.Arg(0)) 91 | dio.Assert(stderr, err, "provided regex is not a valid Go regexp") 92 | kill = slice.Filter(processes, func(p ddb.Process) bool { 93 | return rgx.MatchString(p.Query) 94 | }) 95 | break 96 | case *fuser: 97 | kill = slice.Filter(processes, func(p ddb.Process) bool { 98 | return p.Username == flag.Arg(0) 99 | }) 100 | break 101 | case *fdb: 102 | kill = slice.Filter(processes, func(p ddb.Process) bool { 103 | return p.Database == flag.Arg(0) 104 | }) 105 | break 106 | default: 107 | pid, err := strconv.Atoi(flag.Arg(0)) 108 | dio.Assert(stderr, err, "provided PID is not a number") 109 | kill = slice.Filter(processes, func(p ddb.Process) bool { 110 | return p.Pid == pid 111 | }) 112 | break 113 | } 114 | 115 | // Kill the processes 116 | statuses := map[int]error{} 117 | for _, p := range kill { 118 | statuses[p.Pid] = db.KillProcess(p.Pid, *fforce) 119 | } 120 | 121 | // Report the status 122 | stdout.WriteData(&ddb.Data{ 123 | Cols: []string{"PID", "STATUS"}, 124 | Rows: slice.Map(kill, func(p ddb.Process) []any { 125 | status := "Killed" 126 | if err := statuses[p.Pid]; err != nil { 127 | status = err.Error() 128 | } 129 | return []any{p.Pid, status} 130 | }), 131 | }) 132 | } 133 | -------------------------------------------------------------------------------- /cmd/dls/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "os" 9 | 10 | "github.com/yznts/dsh/pkg/dconf" 11 | "github.com/yznts/dsh/pkg/ddb" 12 | "github.com/yznts/dsh/pkg/dio" 13 | "go.kyoto.codes/zen/v3/logic" 14 | "go.kyoto.codes/zen/v3/slice" 15 | ) 16 | 17 | // Tool flags 18 | var ( 19 | // Default 20 | fdsn = flag.String("dsn", "", "Database connection (can be set via DSN/DATABASE/DATABASE_URL env)") 21 | // Formats 22 | fsql = flag.Bool("sql", false, "Output in SQL format") 23 | fcsv = flag.Bool("csv", false, "Output in CSV format") 24 | fjson = flag.Bool("json", false, "Output in JSON format") 25 | fjsonl = flag.Bool("jsonl", false, "Output in JSON lines format") 26 | // Options 27 | fsys = flag.Bool("sys", false, "Include system tables") 28 | fverbose = flag.Bool("verbose", false, "Output in verbose format (with additional information)") 29 | ) 30 | 31 | // Tool usage / description 32 | var ( 33 | fusage = "[flags...] [table]" 34 | fdescr = "The dls utility lists tables/columns in the database." 35 | ) 36 | 37 | // Database connection 38 | var db ddb.Database 39 | 40 | // Output writers 41 | var ( 42 | stdout dio.Writer 43 | stderr dio.Writer 44 | ) 45 | 46 | // Simplify assignments 47 | var err error 48 | 49 | func main() { 50 | // Provide usage 51 | flag.Usage = dio.Usage(fusage, fdescr) 52 | 53 | // Parse flags 54 | flag.Parse() 55 | 56 | // Resolve output writer 57 | stdout = dio.Open(os.Stdout, *fsql, *fcsv, *fjson, *fjsonl) 58 | stderr = dio.Open(os.Stderr, *fsql, *fcsv, *fjson, *fjsonl) 59 | 60 | // Resolve dsn and database connection 61 | dsn, err := dconf.GetDsn(*fdsn) 62 | dio.Assert(stderr, err) 63 | db, err = ddb.Open(dsn) 64 | dio.Assert(stderr, err) 65 | if db, iscloser := db.(io.Closer); iscloser { 66 | defer db.Close() 67 | } 68 | 69 | // Validate flags compatibility 70 | if *fsys && *fsql { 71 | dio.Assert(stderr, errors.New("flag -sys is not compatible with -sql (export of system columns)")) 72 | } 73 | 74 | // If writer is SQL, we have a separate processing for it. 75 | if stdout, ok := stdout.(*dio.Sql); ok { 76 | // Determine tables we want to extract. 77 | // If no arguments, list all tables. 78 | // Otherwise, use provided table name. 79 | tables, err := db.QueryTables() 80 | dio.Assert(stderr, err) 81 | if len(flag.Args()) > 0 { 82 | tables = slice.Filter(tables, func(t ddb.Table) bool { 83 | return t.Name == flag.Arg(0) 84 | }) 85 | } 86 | 87 | // Filter system tables 88 | tables = slice.Filter(tables, func(t ddb.Table) bool { 89 | return !t.IsSystem 90 | }) 91 | 92 | // Write schema for each table 93 | for _, table := range tables { 94 | // Get columns 95 | columns, err := db.QueryColumns(table.Name) 96 | dio.Assert(stderr, err) 97 | // Set mode and table name 98 | stdout.SetMode("schema") 99 | stdout.SetTable(table.Name) 100 | // Write columns 101 | stdout.WriteData(&ddb.Data{ 102 | Cols: []string{"COLUMN_NAME", "COLUMN_TYPE", "IS_PK", "IS_NL", "DEF", "FK"}, 103 | Rows: slice.Map(columns, func(c ddb.Column) []any { 104 | return []any{c.Name, c.Type, c.IsPrimary, c.IsNullable, c.Default, c.ForeignRef} 105 | }), 106 | }) 107 | } 108 | 109 | // Exit, we're done here 110 | return 111 | } 112 | 113 | // Otherwise, proceed with regular listing. 114 | 115 | // If no arguments, list tables. 116 | // Otherwise, list columns for provided table name. 117 | if len(flag.Args()) == 0 { 118 | // Get database tables 119 | tables, err := db.QueryTables() 120 | dio.Assert(stderr, err) 121 | 122 | // Filter system tables 123 | if !*fsys { 124 | tables = slice.Filter(tables, func(t ddb.Table) bool { 125 | return !t.IsSystem 126 | }) 127 | } 128 | 129 | // If no schema, print 'N/A' 130 | if slice.All(tables, func(t ddb.Table) bool { return t.Schema == "" }) { 131 | tables = slice.Map(tables, func(t ddb.Table) ddb.Table { 132 | t.Schema = "N/A" 133 | return t 134 | }) 135 | } 136 | 137 | // Write tables 138 | stdout.WriteData(&ddb.Data{ 139 | Cols: []string{"TABLE_SCHEMA", "TABLE_NAME", "IS_SYSTEM"}, 140 | Rows: slice.Map(tables, func(t ddb.Table) []any { 141 | return []any{t.Schema, t.Name, t.IsSystem} 142 | }), 143 | }) 144 | } else { 145 | // Get database columns 146 | columns, err := db.QueryColumns(flag.Arg(0)) 147 | dio.Assert(stderr, err) 148 | 149 | // Switch behavior based on -long flag. 150 | // If -long is set, list all column information. 151 | // Otherwise, list only column names and types. 152 | var ( 153 | cols = []string{} 154 | rows = [][]any{} 155 | ) 156 | if *fverbose { 157 | cols = []string{ 158 | "COLUMN_NAME", 159 | "COLUMN_TYPE", 160 | "IS_PK", 161 | "IS_NL", 162 | "DEF", 163 | "FK", 164 | } 165 | rows = slice.Map(columns, func(c ddb.Column) []any { 166 | return []any{ 167 | c.Name, 168 | c.Type, 169 | c.IsPrimary, 170 | c.IsNullable, 171 | c.Default, 172 | logic.Tr(c.ForeignRef != "", 173 | fmt.Sprintf("%s upd(%s) del(%s)", c.ForeignRef, c.ForeignOnUpdate, c.ForeignOnDelete), 174 | "", 175 | ), 176 | } 177 | }) 178 | } else { 179 | cols = []string{ 180 | "COLUMN_NAME", 181 | "COLUMN_TYPE", 182 | } 183 | rows = slice.Map(columns, func(c ddb.Column) []any { 184 | return []any{ 185 | c.Name, 186 | c.Type, 187 | } 188 | }) 189 | } 190 | 191 | // Write data 192 | stdout.WriteData(&ddb.Data{ 193 | Cols: cols, 194 | Rows: rows, 195 | }) 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /cmd/dps/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "io" 6 | "os" 7 | 8 | "github.com/yznts/dsh/pkg/dconf" 9 | "github.com/yznts/dsh/pkg/ddb" 10 | "github.com/yznts/dsh/pkg/dio" 11 | "go.kyoto.codes/zen/v3/slice" 12 | ) 13 | 14 | // Tool flags 15 | var ( 16 | // Default 17 | fdsn = flag.String("dsn", "", "Database connection (can be set via DSN/DATABASE/DATABASE_URL env)") 18 | // Formats 19 | fcsv = flag.Bool("csv", false, "Output in CSV format") 20 | fjson = flag.Bool("json", false, "Output in JSON format") 21 | fjsonl = flag.Bool("jsonl", false, "Output in JSON lines format") 22 | ) 23 | 24 | // Tool usage / description 25 | var ( 26 | fusage = "[flags...]" 27 | fdescr = "The dps utility outputs list of database processes." 28 | ) 29 | 30 | // Database connection 31 | var db ddb.Database 32 | 33 | // Output writers 34 | var ( 35 | stdout dio.Writer 36 | stderr dio.Writer 37 | ) 38 | 39 | // Simplify assignments 40 | var err error 41 | 42 | func main() { 43 | // Provide usage 44 | flag.Usage = dio.Usage(fusage, fdescr) 45 | 46 | // Parse flags 47 | flag.Parse() 48 | 49 | // Resolve output writer 50 | stdout = dio.Open(os.Stdout, *fcsv, *fjson, *fjsonl) 51 | stderr = dio.Open(os.Stderr, *fcsv, *fjson, *fjsonl) 52 | 53 | // Resolve dsn and database connection 54 | dsn, err := dconf.GetDsn(*fdsn) 55 | dio.Assert(stderr, err) 56 | db, err = ddb.Open(dsn) 57 | dio.Assert(stderr, err) 58 | if db, iscloser := db.(io.Closer); iscloser { 59 | defer db.Close() 60 | } 61 | 62 | // Query the database for the currently running processes 63 | processes, err := db.QueryProcesses() 64 | dio.Assert(stderr, err) 65 | 66 | // Write processes 67 | stdout.WriteData(&ddb.Data{ 68 | Cols: []string{"PID", "DURATION", "USERNAME", "DATABASE", "QUERY"}, 69 | Rows: slice.Map(processes, func(p ddb.Process) []any { 70 | return []any{p.Pid, p.Duration, p.Username, p.Database, p.Query} 71 | }), 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /cmd/dsql/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "io" 6 | "os" 7 | "strings" 8 | 9 | "github.com/yznts/dsh/pkg/dconf" 10 | "github.com/yznts/dsh/pkg/ddb" 11 | "github.com/yznts/dsh/pkg/dio" 12 | ) 13 | 14 | // Tool flags 15 | var ( 16 | // Default 17 | fdsn = flag.String("dsn", "", "Database connection (can be set via DSN/DATABASE/DATABASE_URL env)") 18 | // Formats 19 | fcsv = flag.Bool("csv", false, "Output in CSV format") 20 | fjson = flag.Bool("json", false, "Output in JSON format") 21 | fjsonl = flag.Bool("jsonl", false, "Output in JSON lines format") 22 | ) 23 | 24 | // Tool usage / description 25 | var ( 26 | fusage = "[flags...] sql" 27 | fdescr = "The dsql utility executes SQL query and writes the result to the standard output in desired format. " + 28 | "It designed to be simple, therefore edge cases handling isn't included, like trying to query large tables in a formatted way. \n\n" + 29 | "The query can be provided as argument or piped from another command (STDIN). " 30 | ) 31 | 32 | // Database connection 33 | var db ddb.Database 34 | 35 | // Output writers 36 | var ( 37 | stdout dio.Writer 38 | stderr dio.Writer 39 | ) 40 | 41 | // Simplify assignments 42 | var err error 43 | 44 | func main() { 45 | // Provide usage 46 | flag.Usage = dio.Usage(fusage, fdescr) 47 | 48 | // Parse flags 49 | flag.Parse() 50 | 51 | // Resolve output writer 52 | stdout = dio.Open(os.Stdout, false, *fcsv, *fjson, *fjsonl) 53 | stderr = dio.Open(os.Stderr, false, *fcsv, *fjson, *fjsonl) 54 | 55 | // Resolve dsn and database connection 56 | dsn, err := dconf.GetDsn(*fdsn) 57 | dio.Assert(stderr, err) 58 | db, err = ddb.Open(dsn) 59 | dio.Assert(stderr, err) 60 | if db, iscloser := db.(io.Closer); iscloser { 61 | defer db.Close() 62 | } 63 | 64 | // Extract sql query from arguments 65 | query := strings.Join(flag.Args(), " ") 66 | // If no query provided, read from STDIN 67 | if query == "" { 68 | querybts, err := io.ReadAll(os.Stdin) 69 | dio.Assert(stderr, err) 70 | query = string(querybts) 71 | } 72 | 73 | // Execute the query 74 | data, err := db.QueryData(query) 75 | dio.Assert(stderr, err) 76 | 77 | // Write the result 78 | stdout.WriteData(data) 79 | } 80 | -------------------------------------------------------------------------------- /cmd/dtail/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strings" 10 | 11 | "github.com/yznts/dsh/pkg/dconf" 12 | "github.com/yznts/dsh/pkg/ddb" 13 | "github.com/yznts/dsh/pkg/dio" 14 | ) 15 | 16 | // Tool flags 17 | var ( 18 | // Default 19 | fdsn = flag.String("dsn", "", "Database connection (can be set via DSN/DATABASE/DATABASE_URL env)") 20 | // Formats 21 | fsql = flag.Bool("sql", false, "Output in SQL format") 22 | fcsv = flag.Bool("csv", false, "Output in CSV format") 23 | fjsonl = flag.Bool("jsonl", false, "Output in JSON lines format") 24 | // Options 25 | fwhere = flag.String("where", "", "WHERE clause") 26 | forder = flag.String("order", "", "ORDER BY clause") 27 | fn = flag.Int("n", 10, "Number of rows to fetch (default: 10)") 28 | ) 29 | 30 | // Tool usage / description 31 | var ( 32 | fusage = "[flags...] table" 33 | fdescr = "The dtail utility reads last rows of the table data and writes it to the standard output in desired format. " 34 | ) 35 | 36 | // Database connection 37 | var db ddb.Database 38 | 39 | // Output writers 40 | var ( 41 | stdout dio.Writer 42 | stderr dio.Writer 43 | ) 44 | 45 | // Simplify assignments 46 | var err error 47 | 48 | func main() { 49 | // Provide usage 50 | flag.Usage = dio.Usage(fusage, fdescr) 51 | 52 | // Parse flags 53 | flag.Parse() 54 | 55 | // Resolve output writer 56 | stdout = dio.Open(os.Stdout, *fsql, *fcsv, false, *fjsonl) 57 | stderr = dio.Open(os.Stderr, *fsql, *fcsv, false, *fjsonl) 58 | 59 | // Resolve dsn and database connection 60 | dsn, err := dconf.GetDsn(*fdsn) 61 | dio.Assert(stderr, err) 62 | db, err = ddb.Open(dsn) 63 | dio.Assert(stderr, err) 64 | if db, iscloser := db.(io.Closer); iscloser { 65 | defer db.Close() 66 | } 67 | 68 | // Extract table name from arguments 69 | table := flag.Arg(0) 70 | if table == "" { 71 | dio.Assert(stderr, errors.New("missing table name")) 72 | } 73 | 74 | // Get rows count 75 | data, err := db.QueryData(fmt.Sprintf("SELECT COUNT(*) FROM %s", table)) 76 | dio.Assert(stderr, err) 77 | count := int(data.Rows[0][0].(int64)) 78 | 79 | // Check if the num is greater than the count 80 | if *fn > count { 81 | *fn = count 82 | } 83 | 84 | // If writer is SQL, we're setting appropriate mode and table name 85 | if stdout, ok := stdout.(*dio.Sql); ok { 86 | stdout.SetMode("data") 87 | stdout.SetTable(table) 88 | } 89 | 90 | // Build the query 91 | query := &strings.Builder{} 92 | query.WriteString(fmt.Sprintf("SELECT * FROM %s ", table)) 93 | if *fwhere != "" { 94 | query.WriteString(fmt.Sprintf("WHERE %s ", *fwhere)) 95 | } 96 | if *forder != "" { 97 | query.WriteString(fmt.Sprintf("ORDER BY %s ", *forder)) 98 | } 99 | // Limit/offset 100 | query.WriteString(fmt.Sprintf("LIMIT %d OFFSET %d", *fn, count-*fn)) 101 | 102 | // Execute the query 103 | data, err = db.QueryData(query.String()) 104 | dio.Assert(stderr, err) 105 | 106 | // Write the result 107 | stdout.WriteData(data) 108 | } 109 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/yznts/dsh 2 | 3 | go 1.21.3 4 | 5 | require ( 6 | github.com/charmbracelet/lipgloss v0.13.0 7 | github.com/go-sql-driver/mysql v1.8.1 8 | github.com/jackc/pgx/v5 v5.7.4 9 | go.kyoto.codes/zen/v3 v3.2.0 10 | gopkg.in/yaml.v3 v3.0.1 11 | modernc.org/sqlite v1.31.1 12 | ) 13 | 14 | require ( 15 | filippo.io/edwards25519 v1.1.0 // indirect 16 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 17 | github.com/charmbracelet/x/ansi v0.1.4 // indirect 18 | github.com/dustin/go-humanize v1.0.1 // indirect 19 | github.com/google/uuid v1.6.0 // indirect 20 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 21 | github.com/jackc/pgpassfile v1.0.0 // indirect 22 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 23 | github.com/jackc/puddle/v2 v2.2.2 // indirect 24 | github.com/kr/text v0.2.0 // indirect 25 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 26 | github.com/mattn/go-isatty v0.0.20 // indirect 27 | github.com/mattn/go-runewidth v0.0.16 // indirect 28 | github.com/muesli/termenv v0.15.2 // indirect 29 | github.com/ncruces/go-strftime v0.1.9 // indirect 30 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 31 | github.com/rivo/uniseg v0.4.7 // indirect 32 | github.com/rogpeppe/go-internal v1.12.0 // indirect 33 | golang.org/x/crypto v0.31.0 // indirect 34 | golang.org/x/sync v0.10.0 // indirect 35 | golang.org/x/sys v0.28.0 // indirect 36 | golang.org/x/text v0.21.0 // indirect 37 | modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect 38 | modernc.org/libc v1.55.3 // indirect 39 | modernc.org/mathutil v1.6.0 // indirect 40 | modernc.org/memory v1.8.0 // indirect 41 | modernc.org/strutil v1.2.0 // indirect 42 | modernc.org/token v1.1.0 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= 6 | github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= 7 | github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= 8 | github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= 9 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 14 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 15 | github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= 16 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 17 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= 18 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= 19 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 20 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 21 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 22 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 23 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 24 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 25 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 26 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 27 | github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= 28 | github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= 29 | github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 30 | github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 31 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 32 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 33 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 34 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 35 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 36 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 37 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 38 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 39 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 40 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 41 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 42 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 43 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 44 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 45 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 46 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 47 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 48 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 49 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 50 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 51 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 52 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 53 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 54 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 55 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 56 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 57 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 58 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 59 | go.kyoto.codes/zen/v3 v3.2.0 h1:YI8rn7JQoQGZ/TwLwBAeowX0ZWsCRDjNT3GvsPIqGho= 60 | go.kyoto.codes/zen/v3 v3.2.0/go.mod h1:mL1cTOqQ9EgZ1QeItYltjO4MPQakLXz/Xeu+dd/LO1g= 61 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 62 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 63 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 64 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 65 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 66 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 67 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 68 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 69 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 70 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 71 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 72 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 73 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 74 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 75 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 76 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 77 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 78 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 79 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 80 | modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= 81 | modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= 82 | modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= 83 | modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= 84 | modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= 85 | modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= 86 | modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= 87 | modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= 88 | modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= 89 | modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= 90 | modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= 91 | modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= 92 | modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= 93 | modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= 94 | modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= 95 | modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= 96 | modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= 97 | modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= 98 | modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= 99 | modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= 100 | modernc.org/sqlite v1.31.1 h1:XVU0VyzxrYHlBhIs1DiEgSl0ZtdnPtbLVy8hSkzxGrs= 101 | modernc.org/sqlite v1.31.1/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA= 102 | modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= 103 | modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= 104 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 105 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 106 | -------------------------------------------------------------------------------- /pkg/dconf/default.go: -------------------------------------------------------------------------------- 1 | package dconf 2 | 3 | import "os" 4 | 5 | // Default configuration, 6 | // loaded from $HOME/.dsh/config.*. 7 | // If something goes wrong, DefaultErr will be set. 8 | // Please note, even if DefaultErr is nil, 9 | // Default also can be nil (no configuration found). 10 | var ( 11 | Default *Configuration 12 | DefaultErr error 13 | ) 14 | 15 | // init loads the default configuration. 16 | func init() { 17 | Default, DefaultErr = OpenDefault() 18 | } 19 | 20 | // OpenDefault reads a configuration file from a predefined paths. 21 | // It tries to resolve from multiple common locations: 22 | // - "$HOME/.dsh/config.{json,yaml}" 23 | // - "$HOME/.config/dsh/config.{json,yaml}" 24 | func OpenDefault() (*Configuration, error) { 25 | // Defmine common locations 26 | home := os.Getenv("HOME") 27 | locations := []string{ 28 | home + "/.dsh/config.json", 29 | home + "/.dsh/config.yaml", 30 | home + "/.config/dsh/config.json", 31 | home + "/.config/dsh/config.yaml", 32 | } 33 | // We are taking the first configuration file found 34 | for _, location := range locations { 35 | if _, err := os.Stat(location); err == nil { 36 | return Open(location) 37 | } 38 | } 39 | // If no configuration file is found, it' s not an error. 40 | // We just return nil. 41 | return nil, nil 42 | } 43 | -------------------------------------------------------------------------------- /pkg/dconf/dsn.go: -------------------------------------------------------------------------------- 1 | package dconf 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | "os" 7 | 8 | "go.kyoto.codes/zen/v3/logic" 9 | ) 10 | 11 | // GetDsn is a common dsn resolver. 12 | // It tries to resolve provided dsn string to the actual dsn. 13 | // Sometimes it might be empty (expecting env resolving), 14 | // sometimes it's a name from configuration file (expecting conf resolving). 15 | // 16 | // It tries to resolve dsn in the following order: 17 | // - if dsn is empty, it tries to resolve it from environment variable 18 | // - if dsn schema not found, it tries to resolve actual dsn from configuration file 19 | // - if dsn schema found, it returns dsn as is 20 | func GetDsn(dsn string) (string, error) { 21 | // If dsn is empty, try to resolve it from environment variable 22 | dsn = logic.Or(dsn, 23 | os.Getenv("DSN"), 24 | os.Getenv("DATABASE"), 25 | os.Getenv("DATABASE_URL")) 26 | // If it's still empty, return an error 27 | if dsn == "" { 28 | return "", errors.New("dsn is empty") 29 | } 30 | // Parse dsn 31 | dsnurl, err := url.Parse(dsn) 32 | if err != nil { 33 | return "", err 34 | } 35 | // If dsn schema not found, try to resolve actual dsn from default configuration file 36 | if dsnurl.Scheme == "" { 37 | // If no default configuration found, return an error 38 | if Default == nil { 39 | return "", errors.New("dsn schema not found and no configuration file provided") 40 | } 41 | // If connection configuration found, go through the same process. 42 | // Otherwise, it's dsn error. 43 | if con, ok := Default.GetConnection(dsnurl.Path); ok { 44 | return GetDsn(con.GetConn()) 45 | } else { 46 | return "", errors.New("dsn schema not found") 47 | } 48 | } 49 | // Return dsn as is 50 | return dsn, nil 51 | } 52 | -------------------------------------------------------------------------------- /pkg/dconf/open.go: -------------------------------------------------------------------------------- 1 | package dconf 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | // Open reads a configuration file from the given path. 12 | func Open(path string) (*Configuration, error) { 13 | // Read the configuration file. 14 | confbts, err := os.ReadFile(path) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | // Unmarshal the configuration file, 20 | // depending on the file extension. 21 | conf := &Configuration{} 22 | switch filepath.Ext(path) { 23 | case ".json": 24 | err = json.Unmarshal(confbts, conf) 25 | case ".yaml": 26 | err = yaml.Unmarshal(confbts, conf) 27 | } 28 | 29 | // Return 30 | return conf, err 31 | } 32 | -------------------------------------------------------------------------------- /pkg/dconf/types.go: -------------------------------------------------------------------------------- 1 | package dconf 2 | 3 | import ( 4 | "net/url" 5 | "strconv" 6 | 7 | "go.kyoto.codes/zen/v3/slice" 8 | ) 9 | 10 | // Configuration is the top-level configuration object. 11 | // Please note, it holds only the data stored in the configuration file 12 | // and doesn't take into account the environment variables, args, etc. 13 | type Configuration struct { 14 | Connections []Connection `json:"connections" yaml:"connections"` 15 | } 16 | 17 | func (c *Configuration) GetConnection(name string) (Connection, bool) { 18 | for _, conn := range c.Connections { 19 | if conn.Name == name { 20 | return conn, true 21 | } 22 | } 23 | return Connection{}, false 24 | } 25 | 26 | // Connection is a connection object 27 | type Connection struct { 28 | Name string `json:"name" yaml:"name"` 29 | 30 | // Conn is the raw connection string, DSN. 31 | // It will be passed to the driver as-is. 32 | Conn string `json:"conn" yaml:"conn"` 33 | 34 | // As an alternative, 35 | // we're giving the ability to specify connection parameters separately. 36 | // This approach might be more convenient for reading and modifying. 37 | Type string `json:"type" yaml:"type"` 38 | Host string `json:"host" yaml:"host"` 39 | Port int `json:"port" yaml:"port"` 40 | User string `json:"user" yaml:"user"` 41 | Pass string `json:"pass" yaml:"pass"` 42 | DB string `json:"db" yaml:"db"` 43 | SslMode string `json:"ssl_mode" yaml:"ssl_mode"` 44 | SslCert string `json:"ssl_cert" yaml:"ssl_cert"` 45 | SslKey string `json:"ssl_key" yaml:"ssl_key"` 46 | SslCa string `json:"ssl_ca" yaml:"ssl_ca"` 47 | } 48 | 49 | // GetConn returns the connection string. 50 | // If the Conn field is set, it will be returned as is. 51 | // Otherwise, the connection string will be built from the separate fields. 52 | func (c *Connection) GetConn() string { 53 | // If the Conn field is set, return it as is. 54 | if c.Conn != "" { 55 | return c.Conn 56 | } 57 | // Otherwise, build the DSN from the separate fields. 58 | dsn := &url.URL{} 59 | dsn.Scheme = c.Type 60 | dsn.User = url.UserPassword(c.User, c.Pass) 61 | dsn.Host = c.Host 62 | if c.Port != 0 { 63 | dsn.Host += ":" + strconv.Itoa(c.Port) 64 | } 65 | dsn.Path = c.DB 66 | q := dsn.Query() 67 | // Query parameters format may vary depending on the driver. 68 | switch { 69 | case slice.Contains([]string{"postgres", "postgresql"}, c.Type): 70 | q.Add("sslmode", c.SslMode) 71 | q.Add("sslcert", c.SslCert) 72 | q.Add("sslkey", c.SslKey) 73 | q.Add("sslrootcert", c.SslCa) 74 | case slice.Contains([]string{"mysql"}, c.Type): 75 | q.Add("ssl_mode", c.SslMode) 76 | q.Add("ssl_cert", c.SslCert) 77 | q.Add("ssl_key", c.SslKey) 78 | q.Add("ssl_ca", c.SslCa) 79 | } 80 | dsn.RawQuery = q.Encode() 81 | return dsn.String() 82 | } 83 | -------------------------------------------------------------------------------- /pkg/ddb/connection.go: -------------------------------------------------------------------------------- 1 | //go:build !daemon 2 | 3 | package ddb 4 | 5 | import ( 6 | "database/sql" 7 | "net/url" 8 | "reflect" 9 | ) 10 | 11 | // Connection is a wrapper around sql.DB that also stores the DSN and scheme. 12 | // Also, it holds database-agnostic methods. 13 | type Connection struct { 14 | *sql.DB 15 | 16 | DSN *url.URL 17 | Scheme string 18 | } 19 | 20 | // QueryData is a database-agnostic method that queries the database 21 | // with the given query and returns the result as a Data struct pointer. 22 | // 23 | // The Data struct contains the columns and rows of the result. 24 | // Method is returning a pointer to avoid copying the Data struct, 25 | // which might be large. 26 | // 27 | // This exact implementation is the most generic one. 28 | // It utilizes 'any' type to store the values of the result 29 | // and leaves all type assertion to the underlying driver. 30 | // For some databases, like MySQL, we might need to override this method. 31 | func (c *Connection) QueryData(query string) (*Data, error) { 32 | // Execute the query. 33 | rows, err := c.Query(query) 34 | if err != nil { 35 | return nil, err 36 | } 37 | defer rows.Close() 38 | // Get columns information. 39 | cols, err := rows.Columns() 40 | if err != nil { 41 | return nil, err 42 | } 43 | // Initialize the Data struct. 44 | // It holds both the columns and rows of the result. 45 | data := &Data{ 46 | Cols: cols, 47 | } 48 | // Define scan target row. 49 | // This is a slice of pointers, 50 | // so we need to copy values on each iteration. 51 | var scan []any 52 | for range cols { 53 | // We're using new(any) here as the most generic solution, 54 | // so we're leaving the type assertion to the driver. 55 | // In some cases (like MySQL) we will need to override QueryData method 56 | // to handle type assertion correctly. 57 | // 58 | // We're not using col.ScanType() here because it's not always correct. 59 | // For example, postgres driver doesn't report nullable types correctly (sql.NullString). 60 | scan = append(scan, new(any)) 61 | } 62 | for rows.Next() { 63 | // Scan the row into prepared pointers 64 | err = rows.Scan(scan...) 65 | if err != nil { 66 | return nil, err 67 | } 68 | // Copy exact values from the pointers to the Data struct 69 | var row []any 70 | for _, ptr := range scan { 71 | // Get value from the pointer and append it to the row 72 | row = append(row, reflect.ValueOf(ptr).Elem().Interface()) 73 | } 74 | // Append the row to the Data holder 75 | data.Rows = append(data.Rows, row) 76 | } 77 | return data, nil 78 | } 79 | -------------------------------------------------------------------------------- /pkg/ddb/mysql.go: -------------------------------------------------------------------------------- 1 | //go:build !daemon 2 | 3 | package ddb 4 | 5 | import ( 6 | "database/sql" 7 | "database/sql/driver" 8 | "fmt" 9 | "reflect" 10 | "strings" 11 | "time" 12 | 13 | "go.kyoto.codes/zen/v3/slice" 14 | ) 15 | 16 | type Mysql struct { 17 | Connection 18 | } 19 | 20 | // QueryData is a method that queries the database 21 | // with the given query and returns the result as a Data struct pointer. 22 | // 23 | // The Data struct contains the columns and rows of the result. 24 | // Method is returning a pointer to avoid copying the Data struct, 25 | // which might be large. 26 | // 27 | // MySQL driver doesn't make any type assertions on scan, 28 | // so we need to utilize .ColumnTypes() information to get the correct types. 29 | func (m *Mysql) QueryData(query string) (*Data, error) { 30 | // Execute the query. 31 | rows, err := m.Query(query) 32 | if err != nil { 33 | return nil, err 34 | } 35 | defer rows.Close() 36 | // Get columns information. 37 | cols, err := rows.ColumnTypes() 38 | if err != nil { 39 | return nil, err 40 | } 41 | // Initialize the Data struct. 42 | // It holds both the columns and rows of the result. 43 | data := &Data{ 44 | Cols: slice.Map(cols, func(c *sql.ColumnType) string { return c.Name() }), 45 | } 46 | // Define scan target row. 47 | // This is a slice of pointers, 48 | // so we need to copy values on each iteration. 49 | var scan []any 50 | for _, col := range cols { 51 | // Create a new pointer if corresponding column type. 52 | ptr := reflect.New(col.ScanType()) 53 | // Append the pointer to the scan row 54 | scan = append(scan, ptr.Interface()) 55 | } 56 | for rows.Next() { 57 | // Scan the row into prepared pointers 58 | err = rows.Scan(scan...) 59 | if err != nil { 60 | return nil, err 61 | } 62 | // Copy the values from the pointers to the Data struct 63 | var row []any 64 | for _, ptr := range scan { 65 | // If it's a nullable type, get the value 66 | if ptr, ok := ptr.(interface{ Value() (driver.Value, error) }); ok { 67 | val, _ := ptr.Value() 68 | row = append(row, val) 69 | continue 70 | } 71 | // Otherwise, get the value from the pointer 72 | row = append(row, reflect.ValueOf(ptr).Elem().Interface()) 73 | } 74 | // Append the row to the Data holder 75 | data.Rows = append(data.Rows, row) 76 | } 77 | return data, nil 78 | } 79 | 80 | func (m *Mysql) systemSchemas() []string { 81 | return []string{"mysql", "information_schema", "performance_schema", "sys"} 82 | } 83 | 84 | func (m *Mysql) QueryTables() ([]Table, error) { 85 | // Query the database for the tables 86 | data, err := m.QueryData("SELECT table_name,table_schema FROM information_schema.tables") 87 | if err != nil { 88 | return nil, err 89 | } 90 | // Convert the data to a slice of Table objects 91 | tables := slice.Map(data.Rows, func(r []any) Table { 92 | return Table{ 93 | Name: r[0].(string), 94 | Schema: r[1].(string), 95 | } 96 | }) 97 | // Mark system tables 98 | tables = slice.Map(tables, func(t Table) Table { 99 | if slice.Contains(m.systemSchemas(), t.Schema) { 100 | t.IsSystem = true 101 | } 102 | return t 103 | }) 104 | // Return 105 | return tables, nil 106 | } 107 | 108 | func (m *Mysql) QueryColumns(table string) ([]Column, error) { 109 | // Query the database for the columns 110 | dataCols, err := m.QueryData(fmt.Sprintf(` 111 | SELECT 112 | column_name, 113 | data_type, 114 | (CASE WHEN is_nullable = 'YES' THEN true ELSE false END) AS is_nullable, 115 | column_default 116 | FROM information_schema.columns 117 | WHERE table_name = '%s'`, table)) 118 | if err != nil { 119 | return nil, err 120 | } 121 | // Query the database for constraints 122 | dataCons, err := m.QueryData(fmt.Sprintf(` 123 | SELECT DISTINCT 124 | tc.CONSTRAINT_NAME, 125 | tc.CONSTRAINT_TYPE, 126 | kcu.TABLE_NAME AS referencing_table, 127 | kcu.COLUMN_NAME AS referencing_column, 128 | kcu.REFERENCED_TABLE_NAME AS referenced_table, 129 | kcu.REFERENCED_COLUMN_NAME AS referenced_column, 130 | rc.UPDATE_RULE AS foreign_on_update, 131 | rc.DELETE_RULE AS foreign_on_delete 132 | FROM 133 | information_schema.TABLE_CONSTRAINTS AS tc 134 | JOIN information_schema.KEY_COLUMN_USAGE AS kcu 135 | ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME 136 | AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA 137 | LEFT JOIN information_schema.REFERENTIAL_CONSTRAINTS AS rc 138 | ON rc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME 139 | AND rc.CONSTRAINT_SCHEMA = tc.TABLE_SCHEMA 140 | WHERE 141 | tc.TABLE_NAME = '%s'; 142 | `, table)) 143 | if err != nil { 144 | return nil, err 145 | } 146 | // Compose the columns 147 | columns := slice.Map(dataCols.Rows, func(r []any) Column { 148 | // Compose base column 149 | col := Column{ 150 | Name: r[0].(string), 151 | Type: r[1].(string), 152 | IsNullable: r[2].(int64) == 1, 153 | Default: r[3], 154 | } 155 | // Find constraints information 156 | for _, con := range dataCons.Rows { 157 | if con[2].(string) == table && con[3].(string) == col.Name { 158 | if con[1].(string) == "PRIMARY KEY" { 159 | col.IsPrimary = true 160 | } 161 | if con[1].(string) == "FOREIGN KEY" { 162 | col.ForeignRef = fmt.Sprintf("%s(%s)", con[4].(string), con[5].(string)) 163 | col.ForeignOnUpdate = con[6].(string) 164 | col.ForeignOnDelete = con[7].(string) 165 | } 166 | } 167 | } 168 | // Compose constraints 169 | return col 170 | }) 171 | // Return 172 | return columns, nil 173 | } 174 | 175 | func (m *Mysql) QueryProcesses() ([]Process, error) { 176 | // Query the database for the currently running processes 177 | query := ` 178 | SELECT id, time, user, db, info 179 | FROM information_schema.processlist 180 | ` 181 | data, err := m.QueryData(query) 182 | if err != nil { 183 | return nil, err 184 | } 185 | 186 | // Convert the data to a slice of Process objects 187 | def := func(v any, def any) any { 188 | if v == nil { 189 | return def 190 | } 191 | return v 192 | } 193 | processes := slice.Map(data.Rows, func(r []any) Process { 194 | return Process{ 195 | Pid: int(def(r[0], 0).(uint64)), 196 | Duration: time.Duration(def(r[1], 0).(int32)) * time.Second, 197 | Username: def(r[2], "").(string), 198 | Database: def(r[3], "").(string), 199 | Query: strings.Join(strings.Fields(def(r[4], "").(string)), " "), 200 | } 201 | }) 202 | 203 | // Return the list of processes 204 | return processes, nil 205 | } 206 | 207 | func (m *Mysql) KillProcess(pid int, force bool) error { 208 | _, err := m.Exec(fmt.Sprintf("KILL %d", pid)) 209 | return err 210 | } 211 | -------------------------------------------------------------------------------- /pkg/ddb/open.go: -------------------------------------------------------------------------------- 1 | //go:build !daemon 2 | 3 | package ddb 4 | 5 | import ( 6 | "database/sql" 7 | "errors" 8 | "net/url" 9 | "strings" 10 | 11 | _ "github.com/go-sql-driver/mysql" 12 | _ "github.com/jackc/pgx/v5/stdlib" 13 | _ "modernc.org/sqlite" 14 | ) 15 | 16 | // Open opens a database connection based on the provided DSN. 17 | // For now, DSN must be a valid URL. 18 | // This must to be improved in the future. 19 | func Open(dsn string) (Database, error) { 20 | // Validate and parse dsn 21 | if dsn == "" { 22 | return nil, errors.New("empty DSN") 23 | } 24 | dsnurl, err := url.Parse(dsn) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | // Resolve connection and actual scheme, depending on the provided DSN scheme 30 | switch dsnurl.Scheme { 31 | 32 | case "sqlite", "sqlite3": 33 | // To open a SQLite database, we need to remove the scheme and leading slashes 34 | _dsnurl, _ := url.Parse(dsn) 35 | _dsnurl.Scheme = "" 36 | _dsnurlstr := strings.ReplaceAll(_dsnurl.String(), "//", "") 37 | // Open sql database connection 38 | sqldb, err := sql.Open("sqlite", _dsnurlstr) 39 | if err != nil { 40 | return nil, err 41 | } 42 | // Compose the database object 43 | return &Sqlite{ 44 | Connection: Connection{ 45 | DB: sqldb, 46 | DSN: dsnurl, 47 | Scheme: "sqlite", 48 | }, 49 | }, nil 50 | 51 | case "postgres", "postgresql": 52 | // Open sql database connection 53 | sqldb, err := sql.Open("pgx", dsn) 54 | if err != nil { 55 | return nil, err 56 | } 57 | // Compose the database object 58 | return &Postgres{ 59 | Connection: Connection{ 60 | DB: sqldb, 61 | DSN: dsnurl, 62 | Scheme: "postgres", 63 | }, 64 | }, nil 65 | 66 | case "mysql": 67 | // We're using url-formatted DSNs. 68 | // MySQL is "special" in our case. 69 | // - We need to remove the driver prefix from the DSN 70 | // - We need to specify the protocol in non-url way, so 71 | // url parsing of the url-like mysql DSN will not work 72 | // (example: user:password@tcp(host:port)/dbname) 73 | // 74 | // For initial MySQL support we'll try to avoid complex parsing 75 | // and will stick to the non-standard DSN format. 76 | // Example: mysql://user:password@host:port/dbname 77 | // 78 | // So, we're implicitly setting tcp protocol 79 | // and removing the scheme from the DSN. 80 | 81 | // First, let's parse the DSN 82 | _dsnurl, _ := url.Parse(dsn) 83 | // Remove the scheme 84 | _dsnurl.Scheme = "" 85 | // Wrap host and port in tcp() protocol 86 | _dsnurl.Host = "tcp(" + _dsnurl.Host + ")" 87 | _dsnurlstr := strings.ReplaceAll(_dsnurl.String(), "//", "") 88 | // Open sql database connection 89 | sqldb, err := sql.Open("mysql", _dsnurlstr) 90 | if err != nil { 91 | return nil, err 92 | } 93 | // Compose the database object 94 | return &Mysql{ 95 | Connection: Connection{ 96 | DB: sqldb, 97 | DSN: dsnurl, 98 | Scheme: "mysql", 99 | }, 100 | }, nil 101 | 102 | default: 103 | return nil, errors.New("unsupported database") 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /pkg/ddb/open_daemon.go: -------------------------------------------------------------------------------- 1 | //go:build daemon 2 | 3 | package ddb 4 | 5 | import ( 6 | "net/rpc" 7 | "os/exec" 8 | "time" 9 | ) 10 | 11 | func Open(dsn string) (Database, error) { 12 | // Start daemon 13 | cmd := exec.Command("dconn", "-dsn", dsn, "-rpc", "127.0.0.1:25123") 14 | err := cmd.Start() 15 | if err != nil { 16 | return nil, err 17 | } 18 | // Wait for daemon to start and open connection 19 | var ( 20 | client *rpc.Client 21 | start = time.Now() 22 | ) 23 | for { 24 | // Check timeout 25 | if time.Since(start) > 5*time.Second { 26 | return nil, err 27 | } 28 | // Pause 29 | time.Sleep(10 * time.Millisecond) 30 | // Open connection 31 | client, err = rpc.Dial("tcp", "127.0.0.1:25123") 32 | if err != nil { 33 | continue 34 | } 35 | // Exit 36 | break 37 | } 38 | // Compose and return 39 | return &Rpc{client, cmd}, nil 40 | } 41 | -------------------------------------------------------------------------------- /pkg/ddb/postgres.go: -------------------------------------------------------------------------------- 1 | //go:build !daemon 2 | 3 | package ddb 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "go.kyoto.codes/zen/v3/slice" 11 | ) 12 | 13 | type Postgres struct { 14 | Connection 15 | } 16 | 17 | func (p *Postgres) systemSchemas() []string { 18 | return []string{"pg_catalog", "information_schema"} 19 | } 20 | 21 | func (p *Postgres) QueryTables() ([]Table, error) { 22 | // Query the database for the tables 23 | data, err := p.QueryData("SELECT table_name,table_schema FROM information_schema.tables") 24 | if err != nil { 25 | return nil, err 26 | } 27 | // Convert the data to a slice of Table objects 28 | tables := slice.Map(data.Rows, func(r []any) Table { 29 | return Table{ 30 | Name: r[0].(string), 31 | Schema: r[1].(string), 32 | } 33 | }) 34 | // Mark system tables 35 | tables = slice.Map(tables, func(t Table) Table { 36 | if slice.Contains(p.systemSchemas(), t.Schema) { 37 | t.IsSystem = true 38 | } 39 | return t 40 | }) 41 | // Return 42 | return tables, nil 43 | } 44 | 45 | func (p *Postgres) QueryColumns(table string) ([]Column, error) { 46 | // Query the database for the columns 47 | dataCols, err := p.QueryData(fmt.Sprintf(` 48 | SELECT 49 | column_name, 50 | data_type, 51 | (CASE WHEN is_nullable = 'YES' THEN true ELSE false END) AS is_nullable, 52 | column_default 53 | FROM information_schema.columns 54 | WHERE table_name = '%s'`, table)) 55 | if err != nil { 56 | return nil, err 57 | } 58 | // Query the database for constraints 59 | dataCons, err := p.QueryData(fmt.Sprintf(` 60 | SELECT DISTINCT 61 | tc.constraint_name, 62 | tc.constraint_type, 63 | tc.table_name AS referencing_table, 64 | kcu.column_name AS referencing_column, 65 | ccu.table_name AS referenced_table, 66 | ccu.column_name AS referenced_column, 67 | fk.update_rule AS foreign_on_update, 68 | fk.delete_rule AS foreign_on_delete 69 | FROM 70 | information_schema.table_constraints AS tc 71 | JOIN information_schema.key_column_usage AS kcu 72 | ON tc.constraint_name = kcu.constraint_name 73 | AND tc.table_schema = kcu.table_schema 74 | JOIN information_schema.constraint_column_usage AS ccu 75 | ON ccu.constraint_name = tc.constraint_name 76 | AND ccu.table_schema = tc.table_schema 77 | LEFT JOIN information_schema.referential_constraints AS fk 78 | ON fk.constraint_name = tc.constraint_name 79 | WHERE 80 | tc.table_name = '%s'; 81 | `, table)) 82 | if err != nil { 83 | return nil, err 84 | } 85 | // Compose the columns 86 | columns := slice.Map(dataCols.Rows, func(r []any) Column { 87 | // Compose base column 88 | col := Column{ 89 | Name: r[0].(string), 90 | Type: r[1].(string), 91 | IsNullable: r[2].(bool), 92 | Default: r[3], 93 | } 94 | // Find constraints information 95 | for _, con := range dataCons.Rows { 96 | if con[2].(string) == table && con[3].(string) == col.Name { 97 | if con[1].(string) == "PRIMARY KEY" { 98 | col.IsPrimary = true 99 | } 100 | if con[1].(string) == "FOREIGN KEY" { 101 | col.ForeignRef = fmt.Sprintf("%s(%s)", con[4].(string), con[5].(string)) 102 | col.ForeignOnUpdate = con[6].(string) 103 | col.ForeignOnDelete = con[7].(string) 104 | } 105 | } 106 | } 107 | // Compose constraints 108 | return col 109 | }) 110 | // Return 111 | return columns, nil 112 | } 113 | 114 | func (p *Postgres) QueryProcesses() ([]Process, error) { 115 | // Query the database for the currently running processes 116 | query := ` 117 | SELECT 118 | pid, 119 | date_part('epoch', now() - pg_stat_activity.query_start) AS duration, 120 | usename, 121 | datname, 122 | query 123 | FROM 124 | pg_stat_activity 125 | ` 126 | data, err := p.QueryData(query) 127 | if err != nil { 128 | return nil, err 129 | } 130 | 131 | // Convert the data to a slice of Process objects 132 | def := func(v any, def any) any { 133 | if v == nil { 134 | return def 135 | } 136 | return v 137 | } 138 | processes := slice.Map(data.Rows, func(r []any) Process { 139 | return Process{ 140 | Pid: int(def(r[0], 0).(int64)), 141 | Duration: time.Duration(def(r[1], 0.0).(float64)) * time.Second, 142 | Username: def(r[2], "").(string), 143 | Database: def(r[3], "").(string), 144 | Query: strings.Join(strings.Fields(def(r[4], "").(string)), " "), 145 | } 146 | }) 147 | 148 | // Return the list of processes 149 | return processes, nil 150 | } 151 | 152 | func (p *Postgres) KillProcess(pid int, force bool) error { 153 | if !force { 154 | _, err := p.Exec(fmt.Sprintf("SELECT pg_cancel_backend(%d)", pid)) 155 | return err 156 | } else { 157 | _, err := p.Exec(fmt.Sprintf("SELECT pg_terminate_backend(%d)", pid)) 158 | return err 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /pkg/ddb/rpc.go: -------------------------------------------------------------------------------- 1 | package ddb 2 | 3 | import ( 4 | "net/rpc" 5 | "os/exec" 6 | ) 7 | 8 | type Rpc struct { 9 | *rpc.Client 10 | *exec.Cmd 11 | } 12 | 13 | func (c *Rpc) QueryData(query string) (*Data, error) { 14 | res := &Data{} 15 | err := c.Call("Rpc.QueryData", query, res) 16 | return res, err 17 | } 18 | 19 | func (c *Rpc) QueryTables() ([]Table, error) { 20 | res := &[]Table{} 21 | err := c.Call("Rpc.QueryTables", "", res) 22 | return *res, err 23 | } 24 | 25 | func (c *Rpc) QueryColumns(table string) ([]Column, error) { 26 | res := &[]Column{} 27 | err := c.Call("Rpc.QueryColumns", table, res) 28 | return *res, err 29 | } 30 | 31 | func (c *Rpc) QueryProcesses() ([]Process, error) { 32 | res := &[]Process{} 33 | err := c.Call("Rpc.QueryProcesses", "", res) 34 | return *res, err 35 | } 36 | 37 | func (c *Rpc) KillProcess(pid int, force bool) error { 38 | err := c.Call("Rpc.KillProcess", struct { 39 | Pid int 40 | Force bool 41 | }{pid, force}, nil) 42 | return err 43 | } 44 | 45 | func (c *Rpc) Close() error { 46 | // Close the connection 47 | c.Client.Close() 48 | // Kill the process 49 | return c.Cmd.Process.Kill() 50 | } 51 | -------------------------------------------------------------------------------- /pkg/ddb/sqlite.go: -------------------------------------------------------------------------------- 1 | //go:build !daemon 2 | 3 | package ddb 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | 9 | "go.kyoto.codes/zen/v3/slice" 10 | ) 11 | 12 | type Sqlite struct { 13 | Connection 14 | } 15 | 16 | func (s *Sqlite) systemTables() []string { 17 | return []string{"sqlite_master", "sqlite_sequence", "sqlite_stat1"} 18 | } 19 | 20 | func (s *Sqlite) QueryTables() ([]Table, error) { 21 | // Query the database for the tables 22 | data, err := s.QueryData("SELECT name,'' FROM sqlite_master WHERE type='table'") 23 | if err != nil { 24 | return nil, err 25 | } 26 | // Convert the data to a slice of Table objects 27 | tables := slice.Map(data.Rows, func(r []any) Table { 28 | return Table{ 29 | Name: r[0].(string), 30 | } 31 | }) 32 | // SQLite doesn't include system tables into sqlite_master, 33 | // so we have to manually add them. 34 | tables = append( 35 | tables, 36 | slice.Map(s.systemTables(), func(t string) Table { 37 | return Table{Name: t, IsSystem: true} 38 | })..., 39 | ) 40 | // Return 41 | return tables, nil 42 | } 43 | 44 | func (s *Sqlite) QueryColumns(table string) ([]Column, error) { 45 | // Query the database for the columns. 46 | // We can't select exact fields because of 'notnull' issue (syntax error near "notnull"). 47 | // So, here is a reference column list: 48 | // cid, name, type, notnull, dflt_value, pk 49 | dataCols, err := s.QueryData(fmt.Sprintf("SELECT * FROM PRAGMA_TABLE_INFO('%s')", table)) 50 | if err != nil { 51 | return nil, err 52 | } 53 | // Query the database for the foreign keys information. 54 | // Same as above, we can't select exact fields because of syntax error. 55 | // So, here is a reference column list: 56 | // id, seq, table, from, to, on_update, on_delete, match 57 | dataFks, err := s.QueryData(fmt.Sprintf("SELECT * FROM PRAGMA_FOREIGN_KEY_LIST('%s')", table)) 58 | if err != nil { 59 | return nil, err 60 | } 61 | // Compose the columns 62 | columns := slice.Map(dataCols.Rows, func(r []any) Column { 63 | // Compose base column 64 | col := Column{ 65 | Name: r[1].(string), 66 | Type: r[2].(string), 67 | IsPrimary: r[5].(int64) == 1, 68 | IsNullable: r[3].(int64) == 0, 69 | Default: r[4], 70 | } 71 | // Find foreign key information 72 | for _, fk := range dataFks.Rows { 73 | if fk[3].(string) == col.Name { 74 | col.ForeignRef = fmt.Sprintf("%s(%s)", fk[2].(string), fk[4].(string)) 75 | col.ForeignOnUpdate = fk[5].(string) 76 | col.ForeignOnDelete = fk[6].(string) 77 | break 78 | } 79 | } 80 | // Return 81 | return col 82 | }) 83 | // Return 84 | return columns, nil 85 | } 86 | 87 | func (s *Sqlite) QueryProcesses() ([]Process, error) { 88 | return nil, errors.New("sqlite doesn't support process list query, use `lsof ` instead") 89 | } 90 | 91 | func (s *Sqlite) KillProcess(pid int, force bool) error { 92 | return errors.New("sqlite doesn't support process killing, use `lsof ` + `kill` instead") 93 | } 94 | -------------------------------------------------------------------------------- /pkg/ddb/types.go: -------------------------------------------------------------------------------- 1 | package ddb 2 | 3 | import "time" 4 | 5 | // Database interface summarizes the methods 6 | // that our utilities are going to use to interact with databases. 7 | // 8 | // Most of the methods are database-specific and must be implemented 9 | // by the database-specific struct. 10 | // On the other hand, database-agnostic methods might be implemented 11 | // on Connection struct, which nested into each database-specific struct. 12 | type Database interface { 13 | // Data queries 14 | QueryData(query string) (*Data, error) // Return a pointer because data amount might be large 15 | 16 | // Schema queries 17 | QueryTables() ([]Table, error) 18 | QueryColumns(table string) ([]Column, error) 19 | 20 | // Process queries 21 | QueryProcesses() ([]Process, error) 22 | KillProcess(pid int, force bool) error 23 | } 24 | 25 | // Data holds query results. 26 | // Columns and rows are stored separately instead of using maps, 27 | // so we can minimize memory usage and output. 28 | type Data struct { 29 | Cols []string 30 | Rows [][]any 31 | } 32 | 33 | // Table holds table meta information, 34 | // not the actual data. 35 | type Table struct { 36 | Schema string 37 | Name string 38 | IsSystem bool // Indicates whether it's a system table 39 | } 40 | 41 | // Column holds column meta information. 42 | type Column struct { 43 | Name string 44 | Type string 45 | IsPrimary bool 46 | IsNullable bool 47 | Default any 48 | 49 | // Foreign key information 50 | ForeignRef string 51 | ForeignOnUpdate string 52 | ForeignOnDelete string 53 | } 54 | 55 | type Process struct { 56 | Pid int 57 | Duration time.Duration 58 | Username string 59 | Database string 60 | Query string 61 | } 62 | -------------------------------------------------------------------------------- /pkg/dio/csv.go: -------------------------------------------------------------------------------- 1 | package dio 2 | 3 | import ( 4 | "encoding/csv" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/yznts/dsh/pkg/ddb" 9 | "go.kyoto.codes/zen/v3/slice" 10 | ) 11 | 12 | // Csv is a writer that writes data as a csv. 13 | type Csv struct { 14 | *csv.Writer 15 | 16 | // flushed determines if the writer has been flushed. 17 | // If it hasn't, the first table write will write the columns. 18 | flushed bool 19 | } 20 | 21 | // write wraps the csv writer's Write method. 22 | // If an error occurs, it panics. 23 | // It's unexpected behavior in our case, 24 | // so panic is necessary. 25 | func (c *Csv) write(record []string) { 26 | err := c.Writer.Write(record) 27 | if err != nil { 28 | panic(err) 29 | } 30 | } 31 | 32 | // Multi returns true if the writer supports multiple writes. 33 | // Csv supports multiple writes. 34 | func (c *Csv) Multi() bool { 35 | return true 36 | } 37 | 38 | func (c *Csv) WriteError(err error) { 39 | c.write([]string{err.Error()}) 40 | } 41 | 42 | func (c *Csv) WriteData(data *ddb.Data) { 43 | // If it's the first write (no flushes), write the columns. 44 | if !c.flushed { 45 | c.flushed = true 46 | c.write(data.Cols) 47 | } 48 | // Write the rows. 49 | for _, row := range data.Rows { 50 | // Convert the row to a string slice. 51 | rowstr := slice.Map(row, func(v any) string { 52 | return fmt.Sprintf("%v", v) 53 | }) 54 | // Write the row. 55 | c.write(rowstr) 56 | } 57 | // Flush the writer. 58 | c.Flush() 59 | // If an error occurs, panic. 60 | if c.Error() != nil { 61 | panic(c.Error()) 62 | } 63 | } 64 | 65 | func NewCsv(w io.Writer) *Csv { 66 | return &Csv{ 67 | Writer: csv.NewWriter(w), 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /pkg/dio/error.go: -------------------------------------------------------------------------------- 1 | package dio 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | ) 7 | 8 | // Assert checks if the error presents, 9 | // writes the error to the writer and exits the program with non-zero code. 10 | // Override allows to provide a custom error message. 11 | func Assert(w Writer, err error, override ...string) { 12 | if err != nil { 13 | if os.Getenv("DEBUG") != "" { 14 | panic(err) 15 | } 16 | if len(override) > 0 { 17 | w.WriteError(errors.New(override[0])) 18 | } else { 19 | w.WriteError(err) 20 | } 21 | os.Exit(1) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /pkg/dio/gloss.go: -------------------------------------------------------------------------------- 1 | package dio 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/charmbracelet/lipgloss/table" 9 | "github.com/yznts/dsh/pkg/ddb" 10 | "go.kyoto.codes/zen/v3/slice" 11 | ) 12 | 13 | // Gloss is a writer that writes a formatted output, 14 | // like a table or styled error/warn messages. 15 | // Uses lipgloss for styling, 16 | // that's why it's called Gloss. 17 | type Gloss struct { 18 | w io.WriteCloser 19 | } 20 | 21 | // write wraps the io writer's Write method. 22 | // If an error occurs, it panics. 23 | // It's unexpected behavior in our case, 24 | // so panic is necessary. 25 | func (g *Gloss) write(data []byte) { 26 | _, err := g.w.Write(data) 27 | if err != nil { 28 | panic(err) 29 | } 30 | } 31 | 32 | // Multi returns true if the writer supports multiple writes. 33 | // Gloss does not support multiple writes, 34 | // because it outputs in a formatted way that cannot be appended to (i.e. closed table). 35 | func (g *Gloss) Multi() bool { 36 | return false 37 | } 38 | 39 | func (g *Gloss) WriteError(err error) { 40 | msg := lipgloss.NewStyle(). 41 | Foreground(lipgloss.Color("#f66f81")). 42 | Bold(true). 43 | Render(fmt.Sprintf("error occured: %s", err.Error())) 44 | g.write([]byte(msg + "\n")) 45 | // No need to close writer, because it's just an error message. 46 | // We can write more data after that. 47 | } 48 | 49 | func (g *Gloss) WriteData(data *ddb.Data) { 50 | // Transform rows to string 51 | rowsstr := slice.Map(data.Rows, func(v []any) []string { 52 | return slice.Map(v, func(v any) string { 53 | // If value is []uint8, don't print it, just mark as not supported. 54 | // Probably this type is a blob or something that driver can't convert. 55 | if _, ok := v.([]uint8); ok { 56 | return "" 57 | } 58 | return fmt.Sprintf("%v", v) 59 | }) 60 | }) 61 | // Create table 62 | t := table.New(). 63 | Border(lipgloss.NormalBorder()). 64 | BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99"))). 65 | StyleFunc(func(row, col int) lipgloss.Style { 66 | if row == 0 { 67 | return lipgloss.NewStyle().Foreground(lipgloss.Color("99")).Bold(true).Padding(0, 2) 68 | } else { 69 | return lipgloss.NewStyle().MaxHeight(5).MaxWidth(80).Padding(0, 2) 70 | } 71 | }). 72 | Headers(data.Cols...). 73 | Rows(rowsstr...) 74 | // Write table 75 | g.write([]byte(t.String() + "\n")) 76 | // Close writer. 77 | // After the table is written, it cannot be appended to. 78 | // If someone will try to write once more, it will panic. 79 | if err := g.w.Close(); err != nil { 80 | panic(err) 81 | } 82 | } 83 | 84 | func (g *Gloss) WriteWarning(msg string) { 85 | _msg := lipgloss.NewStyle(). 86 | Foreground(lipgloss.Color("#f6ef6f")). 87 | Bold(true). 88 | Render(fmt.Sprintf("warning: %s", msg)) 89 | g.w.Write([]byte(_msg + "\n")) 90 | // No need to close writer, because it's just a warning message. 91 | // We can write more data after that. 92 | } 93 | 94 | func NewGloss(w io.WriteCloser) *Gloss { 95 | return &Gloss{w: w} 96 | } 97 | -------------------------------------------------------------------------------- /pkg/dio/json.go: -------------------------------------------------------------------------------- 1 | package dio 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/yznts/dsh/pkg/ddb" 7 | "go.kyoto.codes/zen/v3/jsonx" 8 | ) 9 | 10 | // Json is a writer that writes a single json object. 11 | type Json struct { 12 | w io.WriteCloser 13 | } 14 | 15 | // write wraps the io writer's Write method. 16 | // If an error occurs, it panics. 17 | // It's unexpected behavior in our case, 18 | // so panic is necessary. 19 | func (j *Json) write(data []byte) { 20 | data = append(data, '\n') 21 | if _, err := j.w.Write(data); err != nil { 22 | panic(err) 23 | } 24 | 25 | if err := j.w.Close(); err != nil { 26 | panic(err) 27 | } 28 | } 29 | 30 | // Multi returns true if the writer supports multiple writes. 31 | // Json does not support multiple writes, 32 | // because it must output a single JSON object (unlike JSONL). 33 | func (j *Json) Multi() bool { 34 | return false 35 | } 36 | 37 | func (j *Json) WriteError(err error) { 38 | errmap := map[string]any{"ERROR": err.Error()} 39 | j.write(jsonx.Bytes(errmap)) 40 | } 41 | 42 | func (j *Json) WriteData(data *ddb.Data) { 43 | j.write(jsonx.Bytes(map[string]any{ 44 | "COLS": data.Cols, 45 | "ROWS": data.Rows, 46 | })) 47 | } 48 | 49 | func NewJson(w io.WriteCloser) *Json { 50 | return &Json{w: w} 51 | } 52 | -------------------------------------------------------------------------------- /pkg/dio/jsonl.go: -------------------------------------------------------------------------------- 1 | package dio 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/yznts/dsh/pkg/ddb" 7 | "go.kyoto.codes/zen/v3/jsonx" 8 | ) 9 | 10 | // Jsonl is a writer that writes json lines. 11 | type Jsonl struct { 12 | w io.Writer 13 | } 14 | 15 | // write wraps the io writer's Write method. 16 | // If an error occurs, it panics. 17 | // It's unexpected behavior in our case, 18 | // so panic is necessary. 19 | func (j *Jsonl) write(data []byte) { 20 | // Append newline 21 | data = append(data, '\n') 22 | // Write and panic on error 23 | _, err := j.w.Write(data) 24 | if err != nil { 25 | panic(err) 26 | } 27 | } 28 | 29 | // Multi returns true if the writer supports multiple writes. 30 | // Jsonl supports multiple writes. 31 | func (j *Jsonl) Multi() bool { 32 | return true 33 | } 34 | 35 | func (j *Jsonl) WriteError(err error) { 36 | errmap := map[string]any{"error": err.Error()} 37 | j.write(jsonx.Bytes(errmap)) 38 | } 39 | 40 | func (j *Jsonl) WriteData(data *ddb.Data) { 41 | for _, row := range data.Rows { 42 | obj := map[string]any{} 43 | for i, col := range data.Cols { 44 | obj[col] = row[i] 45 | } 46 | j.write(jsonx.Bytes(obj)) 47 | } 48 | } 49 | 50 | func NewJsonl(w io.Writer) *Jsonl { 51 | return &Jsonl{w: w} 52 | } 53 | -------------------------------------------------------------------------------- /pkg/dio/open.go: -------------------------------------------------------------------------------- 1 | package dio 2 | 3 | import "io" 4 | 5 | // Open returns a Writer based on the given flags. 6 | // Provide flags in the following order: 7 | // sql, csv, json, jsonl 8 | func Open( 9 | w io.WriteCloser, 10 | flags ...bool, // sql, csv, json, jsonl 11 | ) Writer { 12 | for i := 0; i < len(flags); i++ { 13 | if i > len(flags) { 14 | break 15 | } 16 | if flags[i] { 17 | switch i { 18 | case 0: 19 | return NewSql(w) 20 | case 1: 21 | return NewCsv(w) 22 | case 2: 23 | return NewJson(w) 24 | case 3: 25 | return NewJsonl(w) 26 | } 27 | } 28 | } 29 | return NewGloss(w) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/dio/sql.go: -------------------------------------------------------------------------------- 1 | package dio 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/yznts/dsh/pkg/ddb" 9 | "go.kyoto.codes/zen/v3/jsonx" 10 | "go.kyoto.codes/zen/v3/slice" 11 | ) 12 | 13 | // Sql is a writer that writes data as a multiple sql statements. 14 | // It can write both schema and data, depending on the mode. 15 | // 16 | // This is kind of special writer, 17 | // because it requires additional parameters to be set (mode and table). 18 | // You have to keep attention on them. 19 | type Sql struct { 20 | w io.Writer 21 | 22 | mode string // one of "data", "schema" 23 | table string 24 | } 25 | 26 | // write wraps the io writer's Write method. 27 | // If an error occurs, it panics. 28 | // It's unexpected behavior in our case, 29 | // so panic is necessary. 30 | func (s *Sql) write(data []byte) { 31 | // Write and panic on error 32 | _, err := s.w.Write(data) 33 | if err != nil { 34 | panic(err) 35 | } 36 | } 37 | 38 | // Multi returns true if the writer supports multiple writes. 39 | // Sql supports multiple writes (with multiple statements). 40 | func (s *Sql) Multi() bool { 41 | return true 42 | } 43 | 44 | // WriteError usually outputs an error message. 45 | // In our case we can't do that, so we panic. 46 | func (s *Sql) WriteError(err error) { 47 | panic(fmt.Errorf("error while writing sql: %w", err)) 48 | } 49 | 50 | // WriteData writes schema or data as a sql statement. 51 | func (s *Sql) WriteData(data *ddb.Data) { 52 | 53 | // If we're writing schema, we need to write a CREATE TABLE statement 54 | // with taking data rows as column definitions. 55 | if s.mode == "schema" { 56 | // Convert data rows to column definitions 57 | col := strings.Join(slice.Map(data.Rows, func(row []any) string { 58 | return fmt.Sprintf("%s %s", row[0], row[1]) 59 | }), ", \n") 60 | // Write the CREATE TABLE statement 61 | stm := fmt.Sprintf("CREATE TABLE %s (\n%s);\n\n", s.table, col) 62 | // Write the statement and return 63 | s.write([]byte(stm)) 64 | return 65 | } 66 | 67 | // Otherwise, we're writing INSERT statement 68 | // with taking data rows as values. 69 | 70 | // First, let's write the INSERT statement. 71 | col := strings.Join(data.Cols, ", ") 72 | stm := fmt.Sprintf("INSERT INTO %s (%s) VALUES\n", s.table, col) 73 | s.write([]byte(stm)) 74 | 75 | // And write data rows as values 76 | for i, row := range data.Rows { 77 | // If it's not the first row, write a comma and a newline 78 | if i != 0 { 79 | s.write([]byte(",\n")) 80 | } 81 | // Convert the row to a string slice. 82 | rowstr := strings.Join(slice.Map(row, func(val any) string { 83 | valstr := jsonx.String(val) 84 | if valstr[0] == '"' { 85 | valstr = fmt.Sprintf("'%s'", valstr[1:len(valstr)-1]) 86 | } 87 | return valstr 88 | }), ", ") 89 | s.write([]byte(fmt.Sprintf("(%s)", rowstr))) 90 | } 91 | 92 | // Close the statement 93 | s.write([]byte(";\n\n")) 94 | } 95 | 96 | // SetMode sets the mode of the writer. 97 | // It can be either "data" or "schema". 98 | func (s *Sql) SetMode(mode string) { 99 | s.mode = mode 100 | } 101 | 102 | // SetTable sets the table name. 103 | func (s *Sql) SetTable(table string) { 104 | s.table = table 105 | } 106 | 107 | // NewSql creates a new Sql writer. 108 | func NewSql(w io.Writer) *Sql { 109 | return &Sql{w: w} 110 | } 111 | -------------------------------------------------------------------------------- /pkg/dio/usage.go: -------------------------------------------------------------------------------- 1 | package dio 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/charmbracelet/lipgloss" 9 | ) 10 | 11 | func Usage(usage, descr string) func() { 12 | return func() { 13 | // Let's limit the description width to 90 characters 14 | // to make it more readable. 15 | descr = lipgloss.NewStyle().Width(90).Render(descr) 16 | // Provide usage 17 | fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s %s\n\n", os.Args[0], usage) 18 | // Provide description 19 | fmt.Fprintf(flag.CommandLine.Output(), "%s \n\n", descr) 20 | // Provide flags, 21 | // this handled by flag package. 22 | flag.PrintDefaults() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pkg/dio/writer.go: -------------------------------------------------------------------------------- 1 | package dio 2 | 3 | import "github.com/yznts/dsh/pkg/ddb" 4 | 5 | // Writer is an interface that must be implemented by all writers. 6 | // It provides a common interface for all tools to write data/errors/etc. 7 | type Writer interface { 8 | Multi() bool // Multi returns true if the writer supports multiple writes. 9 | WriteData(*ddb.Data) 10 | WriteError(error) 11 | } 12 | 13 | // WarningWriter is an optional interface that can be implemented by writers. 14 | // It allows writers to report warnings. 15 | type WarningWriter interface { 16 | WriteWarning(string) 17 | } 18 | --------------------------------------------------------------------------------