Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ xsql query "<SQL>" -p <profile> -f json

## 可用命令
- xsql query "SQL" -p <profile> -f json # 执行查询
- xsql schema dump -p <profile> -f json # 导出数据库结构
- xsql profile list -f json # 列出所有 profile
- xsql profile show <name> -f json # 查看 profile 详情

Expand Down Expand Up @@ -146,6 +147,7 @@ xsql query "<SQL>" -p <profile> -f json

使用 xsql 工具查询数据库:
- 查询: `xsql query "SELECT ..." -p <profile> -f json`
- 导出结构: `xsql schema dump -p <profile> -f json`
- 列出配置: `xsql profile list -f json`

注意: 默认只读模式,写操作需要 --unsafe-allow-write 标志。
Expand All @@ -160,6 +162,7 @@ xsql query "<SQL>" -p <profile> -f json
| 命令 | 说明 |
|------|------|
| `xsql query <SQL>` | 执行 SQL 查询(默认只读) |
| `xsql schema dump` | 导出数据库结构(表、列、索引、外键) |
| `xsql profile list` | 列出所有 profile |
| `xsql profile show <name>` | 查看 profile 详情(密码脱敏) |
| `xsql mcp server` | 启动 MCP Server(AI 助手集成) |
Expand All @@ -182,6 +185,33 @@ id name
(1 rows)
```

### Schema 发现(AI 自动理解数据库)

```bash
# 导出数据库结构(供 AI 理解表结构)
xsql schema dump -p dev -f json

# 过滤特定表
xsql schema dump -p dev --table "user*" -f json

# 输出示例
{
"ok": true,
"data": {
"database": "mydb",
"tables": [
{
"name": "users",
"columns": [
{"name": "id", "type": "bigint", "primary_key": true},
{"name": "email", "type": "varchar(255)", "nullable": false}
]
}
]
}
}
```

### SSH 隧道连接

```yaml
Expand Down
229 changes: 116 additions & 113 deletions cmd/xsql/command_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,13 @@ package main
import (
"bytes"
"context"
"database/sql"
"database/sql/driver"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"testing"
"time"

"github.com/zx06/xsql/internal/app"
"github.com/zx06/xsql/internal/config"
xdb "github.com/zx06/xsql/internal/db"
"github.com/zx06/xsql/internal/errors"
"github.com/zx06/xsql/internal/output"
)
Expand Down Expand Up @@ -98,27 +92,116 @@ func TestRunQuery_MissingDB(t *testing.T) {
}
}

func TestRunQuery_Success(t *testing.T) {
driverName := registerStubDriver(t, map[string]*stubRows{
"select 1": {
columns: []string{"value"},
rows: [][]driver.Value{{1}},
},
})
func TestRunQuery_UnsupportedDriver(t *testing.T) {
GlobalConfig.Resolved.Profile = configProfile("sqlite")
GlobalConfig.FormatStr = "json"

var out bytes.Buffer
w := output.New(&out, &bytes.Buffer{})
err := runQuery(nil, []string{"select 1"}, &QueryFlags{}, &w)
if err == nil {
t.Fatal("expected error for unsupported driver")
}
if xe, ok := errors.As(err); !ok || xe.Code != errors.CodeDBDriverUnsupported {
t.Fatalf("expected CodeDBDriverUnsupported, got %v", err)
}
}

func TestRunQuery_PlaintextPasswordNotAllowed(t *testing.T) {
GlobalConfig.Resolved.Profile = config.Profile{
DB: driverName,
DB: "mysql",
Password: "plain_password",
AllowPlaintext: false,
}
GlobalConfig.FormatStr = "json"

var out bytes.Buffer
w := output.New(&out, &bytes.Buffer{})
err := runQuery(nil, []string{"select 1"}, &QueryFlags{UnsafeAllowWrite: true}, &w)
if err != nil {
t.Fatalf("unexpected error: %v", err)
err := runQuery(nil, []string{"select 1"}, &QueryFlags{}, &w)
if err == nil {
t.Fatal("expected error for plaintext password not allowed")
}
if !json.Valid(out.Bytes()) {
t.Fatalf("expected json output, got %s", out.String())
if xe, ok := errors.As(err); !ok || xe.Code != errors.CodeCfgInvalid {
t.Fatalf("expected CodeCfgInvalid, got %v", err)
}
}

func TestRunSchemaDump_UnsupportedDriver(t *testing.T) {
GlobalConfig.Resolved.Profile = configProfile("sqlite")
GlobalConfig.FormatStr = "json"

var out bytes.Buffer
w := output.New(&out, &bytes.Buffer{})
err := runSchemaDump(nil, nil, &SchemaFlags{}, &w)
if err == nil {
t.Fatal("expected error for unsupported driver")
}
if xe, ok := errors.As(err); !ok || xe.Code != errors.CodeDBDriverUnsupported {
t.Fatalf("expected CodeDBDriverUnsupported, got %v", err)
}
}

func TestRunSchemaDump_PlaintextPasswordNotAllowed(t *testing.T) {
GlobalConfig.Resolved.Profile = config.Profile{
DB: "mysql",
Password: "plain_password",
AllowPlaintext: false,
}
GlobalConfig.FormatStr = "json"

var out bytes.Buffer
w := output.New(&out, &bytes.Buffer{})
err := runSchemaDump(nil, nil, &SchemaFlags{}, &w)
if err == nil {
t.Fatal("expected error for plaintext password not allowed")
}
if xe, ok := errors.As(err); !ok || xe.Code != errors.CodeCfgInvalid {
t.Fatalf("expected CodeCfgInvalid, got %v", err)
}
}

func TestRunQuery_InvalidFormat(t *testing.T) {
GlobalConfig.Resolved.Profile = configProfile("mysql")
GlobalConfig.FormatStr = "invalid"

var out bytes.Buffer
w := output.New(&out, &bytes.Buffer{})
err := runQuery(nil, []string{"select 1"}, &QueryFlags{}, &w)
if err == nil {
t.Fatal("expected error for invalid format")
}
if xe, ok := errors.As(err); !ok || xe.Code != errors.CodeCfgInvalid {
t.Fatalf("expected CodeCfgInvalid, got %v", err)
}
}

func TestRunSchemaDump_MissingDB(t *testing.T) {
GlobalConfig.Resolved.Profile = configProfile("")
GlobalConfig.FormatStr = "json"

var out bytes.Buffer
w := output.New(&out, &bytes.Buffer{})
err := runSchemaDump(nil, nil, &SchemaFlags{}, &w)
if err == nil {
t.Fatal("expected error for missing db type")
}
if xe, ok := errors.As(err); !ok || xe.Code != errors.CodeCfgInvalid {
t.Fatalf("expected CodeCfgInvalid, got %v", err)
}
}

func TestRunSchemaDump_InvalidFormat(t *testing.T) {
GlobalConfig.Resolved.Profile = configProfile("mysql")
GlobalConfig.FormatStr = "invalid"

var out bytes.Buffer
w := output.New(&out, &bytes.Buffer{})
err := runSchemaDump(nil, nil, &SchemaFlags{}, &w)
if err == nil {
t.Fatal("expected error for invalid format")
}
if xe, ok := errors.As(err); !ok || xe.Code != errors.CodeCfgInvalid {
t.Fatalf("expected CodeCfgInvalid, got %v", err)
}
}

Expand Down Expand Up @@ -516,104 +599,24 @@ profiles:
}
}

func configProfile(dbType string) config.Profile {
return config.Profile{DB: dbType}
}

type stubDriver struct {
responseRows map[string]*stubRows
}

type stubConnector struct {
driver *stubDriver
}

func (c *stubConnector) Connect(context.Context) (driver.Conn, error) {
return &stubConn{driver: c.driver}, nil
}

func (c *stubConnector) Driver() driver.Driver {
return c.driver
}

func (d *stubDriver) Open(string) (driver.Conn, error) {
return &stubConn{driver: d}, nil
}

type stubConn struct {
driver *stubDriver
}

func (c *stubConn) Prepare(string) (driver.Stmt, error) {
return nil, fmt.Errorf("prepare not supported")
}

func (c *stubConn) Close() error {
return nil
}

func (c *stubConn) Begin() (driver.Tx, error) {
return &stubTx{}, nil
}

func (c *stubConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
if rows, ok := c.driver.responseRows[query]; ok {
return rows, nil
func TestValueIfSet(t *testing.T) {
if got := valueIfSet(false, "x"); got != "" {
t.Fatalf("expected empty when not set, got %q", got)
}
return nil, fmt.Errorf("unexpected query: %s", query)
}

type stubTx struct{}

func (t *stubTx) Commit() error {
return nil
}

func (t *stubTx) Rollback() error {
return nil
}

type stubRows struct {
columns []string
rows [][]driver.Value
idx int
}

func (r *stubRows) Columns() []string {
return r.columns
}

func (r *stubRows) Close() error {
return nil
}

func (r *stubRows) Next(dest []driver.Value) error {
if r.idx >= len(r.rows) {
return io.EOF
if got := valueIfSet(true, "x"); got != "x" {
t.Fatalf("expected value when set, got %q", got)
}
copy(dest, r.rows[r.idx])
r.idx++
return nil
}

func registerStubDriver(t *testing.T, rows map[string]*stubRows) string {
t.Helper()

name := fmt.Sprintf("stub-%d", time.Now().UnixNano())
driver := &stubDriver{responseRows: rows}
db := sql.OpenDB(&stubConnector{driver: driver})
t.Cleanup(func() {
_ = db.Close()
})

xdb.Register(name, fakeDriver{db: db})
return name
}

type fakeDriver struct {
db *sql.DB
func TestFirstNonEmpty(t *testing.T) {
if got := firstNonEmpty("", "", "a", "b"); got != "a" {
t.Fatalf("expected first non-empty value, got %q", got)
}
if got := firstNonEmpty("", ""); got != "" {
t.Fatalf("expected empty when all empty, got %q", got)
}
}

func (d fakeDriver) Open(ctx context.Context, opts xdb.ConnOptions) (*sql.DB, *errors.XError) {
return d.db, nil
func configProfile(dbType string) config.Profile {
return config.Profile{DB: dbType}
}
1 change: 1 addition & 0 deletions cmd/xsql/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func run() int {
root.AddCommand(NewVersionCommand(&a, &w))
root.AddCommand(NewQueryCommand(&w))
root.AddCommand(NewProfileCommand(&w))
root.AddCommand(NewSchemaCommand(&w))
root.AddCommand(NewMCPCommand())
root.AddCommand(NewProxyCommand(&w))

Expand Down
Loading