├── .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 | 
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 |
--------------------------------------------------------------------------------