Skip to content

Commit 535ff10

Browse files
authored
render psql supports non-interactive outputs (#254)
Adds --command/-c flag that runs a query and exits, with output controlled by -o json|yaml|text. Additional psql flags pass through via -- [args]. # Interactive mode (default) render psql my-database # Non-interactive outputs render psql my-database -c "SELECT NOW();" -o text render psql my-database -c "SELECT NOW();" -o json render psql my-database -c "SELECT NOW();" -o yaml # Passthrough psql flags render psql my-database -c "SELECT id, email FROM users;" -o text -- --csv render psql my-database -c "SELECT count(*) FROM orders;" -o text -- -t -A
1 parent a6f3dc4 commit 535ff10

6 files changed

Lines changed: 194 additions & 3 deletions

File tree

cmd/psql.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ package cmd
22

33
import (
44
"context"
5+
"fmt"
56

67
tea "github.com/charmbracelet/bubbletea"
78
"github.com/spf13/cobra"
89

910
"github.com/render-oss/cli/pkg/client"
1011
"github.com/render-oss/cli/pkg/command"
1112
"github.com/render-oss/cli/pkg/postgres"
13+
"github.com/render-oss/cli/pkg/text"
1214
"github.com/render-oss/cli/pkg/tui"
1315
"github.com/render-oss/cli/pkg/tui/flows"
1416
"github.com/render-oss/cli/pkg/tui/views"
@@ -19,7 +21,13 @@ var psqlCmd = &cobra.Command{
1921
Use: "psql [postgresID|postgresName]",
2022
Short: "Open a psql session to a PostgreSQL database",
2123
Long: `Open a psql session to a PostgreSQL database. Optionally pass the database id or name as an argument.
22-
To pass arguments to psql, use the following syntax: render psql [postgresID|postgresName] -- [psql args]`,
24+
To pass arguments to psql, use the following syntax: render psql [postgresID|postgresName] -- [psql args]
25+
26+
For non-interactive usage, use the --command flag:
27+
render psql [postgresID|postgresName] -c "SELECT * FROM users;" -o text
28+
29+
Additional psql flags can be passed after --:
30+
render psql [postgresID|postgresName] -c "SELECT 1;" -o json -- --csv -q`,
2331
GroupID: GroupSession.ID,
2432
}
2533

@@ -51,10 +59,12 @@ func getPsqlTableOptions(ctx context.Context, input *views.PSQLInput) []tui.Cust
5159
func init() {
5260
rootCmd.AddCommand(psqlCmd)
5361

62+
psqlCmd.Flags().StringP("command", "c", "", "SQL command to execute (enables non-interactive mode)")
63+
5464
psqlCmd.RunE = func(cmd *cobra.Command, args []string) error {
5565
ctx := cmd.Context()
5666
var input views.PSQLInput
57-
err := command.ParseCommandInteractiveOnly(cmd, args, &input)
67+
err := command.ParseCommand(cmd, args, &input)
5868
if err != nil {
5969
return err
6070
}
@@ -67,6 +77,27 @@ func init() {
6777
input.Args = args[cmd.ArgsLenAtDash():]
6878
}
6979

80+
input.Tool = views.PSQL
81+
82+
outputFormat := command.GetFormatFromContext(ctx)
83+
if outputFormat != nil && !outputFormat.Interactive() {
84+
if input.Command == "" {
85+
return fmt.Errorf("--command flag is required in non-interactive mode\nUsage: render psql <postgresID> --command \"SELECT ...\" -o json")
86+
}
87+
88+
if input.PostgresIDOrName == "" {
89+
return fmt.Errorf("postgres ID or name is required in non-interactive mode")
90+
}
91+
92+
result, err := views.ExecutePSQLNonInteractive(ctx, &input)
93+
if err != nil {
94+
return err
95+
}
96+
97+
_, err = command.PrintData(cmd, result, text.PSQLResultText)
98+
return err
99+
}
100+
70101
InteractivePSQLView(ctx, &input)
71102
return nil
72103
}

pkg/text/psql.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package text
2+
3+
import "github.com/render-oss/cli/pkg/tui/views"
4+
5+
func PSQLResultText(result *views.PSQLResult) string {
6+
return result.Output
7+
}

pkg/text/psql_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package text
2+
3+
import (
4+
"testing"
5+
6+
"github.com/render-oss/cli/pkg/tui/views"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestPSQLResultText(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
result *views.PSQLResult
14+
expected string
15+
}{
16+
{
17+
name: "returns raw output",
18+
result: &views.PSQLResult{Output: " id | name\n----+------\n 1 | test\n(1 row)\n"},
19+
expected: " id | name\n----+------\n 1 | test\n(1 row)\n",
20+
},
21+
{
22+
name: "empty output",
23+
result: &views.PSQLResult{Output: ""},
24+
expected: "",
25+
},
26+
}
27+
28+
for _, tt := range tests {
29+
t.Run(tt.name, func(t *testing.T) {
30+
result := PSQLResultText(tt.result)
31+
require.Equal(t, tt.expected, result)
32+
})
33+
}
34+
}

pkg/tui/stack.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,16 @@ type UserFacingError struct {
2020
}
2121

2222
func (u UserFacingError) Error() string {
23-
return u.Err.Error()
23+
if u.Err != nil {
24+
return u.Err.Error()
25+
}
26+
if u.Message != "" {
27+
return u.Message
28+
}
29+
if u.Title != "" {
30+
return u.Title
31+
}
32+
return "unknown error"
2433
}
2534

2635
var stackHeaderStyle = lipgloss.NewStyle().MarginTop(1).MarginBottom(1)

pkg/tui/views/psql.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package views
22

33
import (
4+
"bytes"
45
"context"
56
"fmt"
67
"io"
78
"net"
89
"net/http"
910
"os/exec"
11+
"strings"
1012

1113
tea "github.com/charmbracelet/bubbletea"
1214

@@ -26,6 +28,7 @@ type PSQLInput struct {
2628
Project *client.Project
2729
EnvironmentIDs []string
2830
Tool PSQLTool
31+
Command string `cli:"command"`
2932

3033
Args []string
3134
}
@@ -35,6 +38,63 @@ type PSQLView struct {
3538
execModel *tui.ExecModel
3639
}
3740

41+
type PSQLResult struct {
42+
Output string `json:"output"`
43+
}
44+
45+
func ExecutePSQLNonInteractive(ctx context.Context, input *PSQLInput) (*PSQLResult, error) {
46+
c, err := client.NewDefaultClient()
47+
if err != nil {
48+
return nil, err
49+
}
50+
51+
pgc := postgres.NewRepo(c)
52+
53+
pg, err := getPostgresFromIDOrName(ctx, c, input.PostgresIDOrName)
54+
if err != nil {
55+
return nil, err
56+
}
57+
58+
userIP, ok := getUserIP()
59+
if ok {
60+
hasAccess, err := hasAccessToPostgres(pg, userIP)
61+
if err != nil {
62+
return nil, err
63+
}
64+
65+
if !hasAccess {
66+
return nil, fmt.Errorf("IP address (%s) not in allow list for %s", userIP, pg.Name)
67+
}
68+
}
69+
70+
connectionInfo, err := pgc.GetPostgresConnectionInfo(ctx, pg.Id)
71+
if err != nil {
72+
return nil, err
73+
}
74+
75+
args := []string{connectionInfo.ExternalConnectionString, "-c", input.Command}
76+
for _, arg := range input.Args {
77+
args = append(args, arg)
78+
}
79+
80+
cmd := exec.CommandContext(ctx, string(input.Tool), args...)
81+
82+
var stdout, stderr bytes.Buffer
83+
cmd.Stdout = &stdout
84+
cmd.Stderr = &stderr
85+
86+
err = cmd.Run()
87+
if err != nil {
88+
errMsg := stderr.String()
89+
if errMsg != "" {
90+
return nil, fmt.Errorf("%s: %s", err, strings.TrimSpace(errMsg))
91+
}
92+
return nil, err
93+
}
94+
95+
return &PSQLResult{Output: stdout.String()}, nil
96+
}
97+
3898
func NewPSQLView(ctx context.Context, input *PSQLInput, opts ...tui.TableOption[*postgres.Model]) *PSQLView {
3999
psqlView := &PSQLView{
40100
execModel: tui.NewExecModel(string(input.Tool), handlePSQLError(input.Tool), command.LoadCmd(ctx, loadDataPSQL, input)),

pkg/tui/views/psql_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package views
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
"gopkg.in/yaml.v3"
9+
)
10+
11+
func TestPSQLResult_JSONMarshal(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
result PSQLResult
15+
expected string
16+
}{
17+
{
18+
name: "tabular output",
19+
result: PSQLResult{Output: " id | name\n----+------\n 1 | test\n(1 row)\n"},
20+
expected: `{"output":" id | name\n----+------\n 1 | test\n(1 row)\n"}`,
21+
},
22+
{
23+
name: "empty output",
24+
result: PSQLResult{Output: ""},
25+
expected: `{"output":""}`,
26+
},
27+
{
28+
name: "csv output from passthrough",
29+
result: PSQLResult{Output: "id,name\n1,alice\n2,bob\n"},
30+
expected: `{"output":"id,name\n1,alice\n2,bob\n"}`,
31+
},
32+
}
33+
34+
for _, tt := range tests {
35+
t.Run(tt.name, func(t *testing.T) {
36+
b, err := json.Marshal(tt.result)
37+
require.NoError(t, err)
38+
require.JSONEq(t, tt.expected, string(b))
39+
})
40+
}
41+
}
42+
43+
func TestPSQLResult_YAMLMarshal(t *testing.T) {
44+
result := PSQLResult{Output: "hello world\n"}
45+
46+
b, err := yaml.Marshal(result)
47+
require.NoError(t, err)
48+
require.Contains(t, string(b), "output:")
49+
require.Contains(t, string(b), "hello world")
50+
}

0 commit comments

Comments
 (0)