diff --git a/README.md b/README.md index b07d6d5..b536cf7 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![CI](https://github.com/zx06/xsql/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/zx06/xsql/actions/workflows/ci.yml?query=branch%3Amain) [![codecov](https://codecov.io/github/zx06/xsql/graph/badge.svg?token=LrcR0pifCj)](https://codecov.io/github/zx06/xsql) +[![Go Reference](https://pkg.go.dev/badge/github.com/zx06/xsql.svg)](https://pkg.go.dev/github.com/zx06/xsql) [![Go Version](https://img.shields.io/github/go-mod/go-version/zx06/xsql)](https://github.com/zx06/xsql/blob/main/go.mod) [![Go Report Card](https://goreportcard.com/badge/github.com/zx06/xsql)](https://goreportcard.com/report/github.com/zx06/xsql) [![License](https://img.shields.io/github/license/zx06/xsql)](https://github.com/zx06/xsql/blob/main/LICENSE) @@ -10,28 +11,30 @@ [![npm](https://img.shields.io/npm/v/xsql-cli)](https://www.npmjs.com/package/xsql-cli) [![npm downloads](https://img.shields.io/npm/dm/xsql-cli)](https://www.npmjs.com/package/xsql-cli) -**让 AI 安全地查询你的数据库** 🤖🔒 +**Let AI safely query your databases** 🤖🔒 -xsql 是专为 AI Agent 设计的跨数据库 CLI 工具。默认只读、结构化输出、开箱即用。 +[中文文档](README_zh.md) + +xsql is a cross-database CLI tool designed for AI agents. Read-only by default, structured output, ready out of the box. ```bash -# AI 可以这样查询你的数据库 +# AI can query your database like this xsql query "SELECT * FROM users WHERE created_at > '2024-01-01'" -p prod -f json ``` -## ✨ 为什么选择 xsql? +## ✨ Why xsql? -| 特性 | 说明 | -|------|------| -| 🔒 **默认安全** | 双重只读保护,防止 AI 误操作 | -| 🤖 **AI-first** | JSON 结构化输出,便于 AI 解析 | -| 🔑 **密钥安全** | 集成 OS Keyring,密码不落盘 | -| 🌐 **SSH 隧道** | 一行配置连接内网数据库 | -| 📦 **零依赖** | 单二进制文件,开箱即用 | +| Feature | Description | +|---------|-------------| +| 🔒 **Safe by Default** | Dual-layer read-only protection prevents accidental writes by AI | +| 🤖 **AI-first** | JSON structured output designed for machine consumption | +| 🔑 **Secure Credentials** | OS Keyring integration — passwords never touch disk | +| 🌐 **SSH Tunneling** | One-line config to connect to internal databases | +| 📦 **Zero Dependencies** | Single binary, works out of the box | -## 🚀 30 秒上手 +## 🚀 Quick Start -### 1. 安装 +### 1. Install ```bash # macOS @@ -43,10 +46,10 @@ scoop bucket add zx06 https://github.com/zx06/scoop-bucket && scoop install xsql # npm / npx npm install -g xsql-cli -# 或直接下载: https://github.com/zx06/xsql/releases +# Or download directly: https://github.com/zx06/xsql/releases ``` -### 2. 配置 +### 2. Configure ```bash mkdir -p ~/.config/xsql @@ -59,11 +62,11 @@ profiles: user: root password: your_password database: mydb - allow_plaintext: true # 生产环境建议用 keyring + allow_plaintext: true # Use keyring for production EOF ``` -### 3. 使用 +### 3. Use ```bash xsql query "SELECT 1" -p dev -f json @@ -72,58 +75,58 @@ xsql query "SELECT 1" -p dev -f json --- -## 🤖 让 AI 使用 xsql +## 🤖 Let AI Use xsql -### 方式一:Claude Code Plugin(推荐) +### Option 1: Claude Code Plugin (Recommended) ```bash -# 1. 添加 marketplace +# 1. Add marketplace /plugin marketplace add zx06/xsql -# 2. 安装插件 +# 2. Install plugin /plugin install xsql@xsql ``` -安装后 Claude 自动获得 xsql 技能,可直接查询数据库。 +After installation, Claude automatically gains xsql skills and can query databases directly. -### 方式二:复制 Skill 给任意 AI +### Option 2: Copy Skill Prompt to Any AI -将以下内容发送给你的 AI 助手(ChatGPT/Claude/Cursor 等): +Send the following to your AI assistant (ChatGPT/Claude/Cursor, etc.):
-📋 点击展开 AI Skill Prompt(复制即用) +📋 Click to expand AI Skill Prompt (copy & paste) ``` -你现在可以使用 xsql 工具查询数据库。 +You can now use the xsql tool to query databases. -## 基本用法 +## Basic Usage xsql query "" -p -f json -## 可用命令 -- xsql query "SQL" -p -f json # 执行查询 -- xsql schema dump -p -f json # 导出数据库结构 -- xsql profile list -f json # 列出所有 profile -- xsql profile show -f json # 查看 profile 详情 +## Available Commands +- xsql query "SQL" -p -f json # Execute query +- xsql schema dump -p -f json # Export database schema +- xsql profile list -f json # List all profiles +- xsql profile show -f json # Show profile details -## 输出格式 -成功: {"ok":true,"schema_version":1,"data":{"columns":[...],"rows":[...]}} -失败: {"ok":false,"schema_version":1,"error":{"code":"XSQL_...","message":"..."}} +## Output Format +Success: {"ok":true,"schema_version":1,"data":{"columns":[...],"rows":[...]}} +Failure: {"ok":false,"schema_version":1,"error":{"code":"XSQL_...","message":"..."}} -## 重要规则 -1. 默认只读模式,无法执行 INSERT/UPDATE/DELETE -2. 始终使用 -f json 获取结构化输出 -3. 先用 profile list 查看可用的数据库配置 -4. 检查 ok 字段判断执行是否成功 +## Important Rules +1. Read-only mode by default — cannot execute INSERT/UPDATE/DELETE +2. Always use -f json for structured output +3. Use profile list to see available database configurations +4. Check the ok field to determine execution success -## 退出码 -0=成功, 2=配置错误, 3=连接错误, 4=只读拦截, 5=SQL执行错误 +## Exit Codes +0=success, 2=config error, 3=connection error, 4=read-only violation, 5=SQL execution error ```
-### 方式三:MCP Server(Claude Desktop 等) +### Option 3: MCP Server (Claude Desktop, etc.) -在 Claude Desktop 配置中添加 xsql MCP server: +Add the xsql MCP server to your Claude Desktop configuration: ```json { @@ -136,47 +139,47 @@ xsql query "" -p -f json } ``` -启动后,Claude 可以直接通过 MCP 协议查询数据库。 +Once started, Claude can query databases directly via the MCP protocol. -### 方式四:AGENTS.md / Rules(Cursor/Windsurf) +### Option 4: AGENTS.md / Rules (Cursor/Windsurf) -在项目根目录创建 `.cursor/rules` 或编辑 `AGENTS.md`: +Create `.cursor/rules` or edit `AGENTS.md` in your project root: ```markdown -## 数据库查询 +## Database Queries -使用 xsql 工具查询数据库: -- 查询: `xsql query "SELECT ..." -p -f json` -- 导出结构: `xsql schema dump -p -f json` -- 列出配置: `xsql profile list -f json` +Use xsql to query databases: +- Query: `xsql query "SELECT ..." -p -f json` +- Export schema: `xsql schema dump -p -f json` +- List profiles: `xsql profile list -f json` -注意: 默认只读模式,写操作需要 --unsafe-allow-write 标志。 +Note: Read-only mode by default. Write operations require the --unsafe-allow-write flag. ``` --- -## 📖 功能详情 +## 📖 Features -### 命令一览 +### Command Reference -| 命令 | 说明 | -|------|------| -| `xsql query ` | 执行 SQL 查询(默认只读) | -| `xsql schema dump` | 导出数据库结构(表、列、索引、外键) | -| `xsql profile list` | 列出所有 profile | -| `xsql profile show ` | 查看 profile 详情(密码脱敏) | -| `xsql mcp server` | 启动 MCP Server(AI 助手集成) | -| `xsql spec` | 导出 AI Tool Spec(支持 `--format yaml`) | -| `xsql version` | 显示版本信息 | +| Command | Description | +|---------|-------------| +| `xsql query ` | Execute SQL queries (read-only by default) | +| `xsql schema dump` | Export database schema (tables, columns, indexes, foreign keys) | +| `xsql profile list` | List all profiles | +| `xsql profile show ` | Show profile details (passwords are masked) | +| `xsql mcp server` | Start MCP Server (AI assistant integration) | +| `xsql spec` | Export AI Tool Spec (supports `--format yaml`) | +| `xsql version` | Show version information | -### 输出格式 +### Output Formats ```bash -# JSON(AI/程序) +# JSON (for AI/programs) xsql query "SELECT id, name FROM users" -p dev -f json {"ok":true,"schema_version":1,"data":{"columns":["id","name"],"rows":[{"id":1,"name":"Alice"}]}} -# Table(终端) +# Table (for terminals) xsql query "SELECT id, name FROM users" -p dev -f table id name -- ----- @@ -185,16 +188,16 @@ id name (1 rows) ``` -### Schema 发现(AI 自动理解数据库) +### Schema Discovery (AI auto-understands your database) ```bash -# 导出数据库结构(供 AI 理解表结构) +# Export database schema (for AI to understand table structures) xsql schema dump -p dev -f json -# 过滤特定表 +# Filter specific tables xsql schema dump -p dev --table "user*" -f json -# 输出示例 +# Example output { "ok": true, "data": { @@ -212,7 +215,7 @@ xsql schema dump -p dev --table "user*" -f json } ``` -### SSH 隧道连接 +### SSH Tunnel Connection ```yaml ssh_proxies: @@ -224,48 +227,48 @@ ssh_proxies: profiles: prod: db: pg - host: db.internal # 内网地址 + host: db.internal # Internal network address port: 5432 user: readonly password: "keyring:prod/password" database: mydb - ssh_proxy: bastion # 引用 SSH 代理 + ssh_proxy: bastion # Reference SSH proxy ``` -### 端口转发代理(xsql proxy) +### Port Forwarding Proxy (xsql proxy) -当需要传统的 `ssh -L` 行为或希望暴露本地端口给 GUI 客户端时,可以使用 `xsql proxy`: +When you need traditional `ssh -L` behavior or want to expose a local port for GUI clients, use `xsql proxy`: ```bash -# 启动端口转发(自动分配本地端口) +# Start port forwarding (auto-assign local port) xsql proxy -p prod -# 指定本地端口 +# Specify local port xsql proxy -p prod --local-port 13306 ``` -> 该命令要求 profile 配置 `ssh_proxy`,并会在本地监听端口,将流量转发到目标数据库。 +> This command requires the profile to have `ssh_proxy` configured. It listens on a local port and forwards traffic to the target database. -### 安全特性 +### Security Features -- **双重只读保护**:SQL 静态分析 + 数据库事务级只读 -- **Keyring 集成**:`password: "keyring:prod/password"` -- **密码脱敏**:`profile show` 不泄露密码 -- **SSH 安全**:默认验证 known_hosts +- **Dual-layer Read-only Protection**: SQL static analysis + database transaction-level read-only +- **Keyring Integration**: `password: "keyring:prod/password"` +- **Password Masking**: `profile show` never exposes passwords +- **SSH Security**: known_hosts verification enabled by default --- -## 📚 文档 - -| 文档 | 说明 | -|------|------| -| [CLI 规范](docs/cli-spec.md) | 命令行接口详细说明 | -| [配置指南](docs/config.md) | 配置文件格式和选项 | -| [SSH 代理](docs/ssh-proxy.md) | SSH 隧道配置 | -| [错误处理](docs/error-contract.md) | 错误码和退出码 | -| [AI 集成](docs/ai.md) | MCP Server 和 AI 助手集成 | -| [RFC 文档](docs/rfcs/) | 设计变更记录 | -| [开发指南](docs/dev.md) | 贡献和开发说明 | +## 📚 Documentation + +| Document | Description | +|----------|-------------| +| [CLI Specification](docs/cli-spec.md) | Detailed CLI interface reference | +| [Configuration Guide](docs/config.md) | Config file format and options | +| [SSH Proxy](docs/ssh-proxy.md) | SSH tunnel configuration | +| [Error Handling](docs/error-contract.md) | Error codes and exit codes | +| [AI Integration](docs/ai.md) | MCP Server and AI assistant integration | +| [RFC Documents](docs/rfcs/) | Design change records | +| [Development Guide](docs/dev.md) | Contributing and development notes | --- diff --git a/README_zh.md b/README_zh.md new file mode 100644 index 0000000..7901a61 --- /dev/null +++ b/README_zh.md @@ -0,0 +1,277 @@ +# xsql + +[![CI](https://github.com/zx06/xsql/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/zx06/xsql/actions/workflows/ci.yml?query=branch%3Amain) +[![codecov](https://codecov.io/github/zx06/xsql/graph/badge.svg?token=LrcR0pifCj)](https://codecov.io/github/zx06/xsql) +[![Go Reference](https://pkg.go.dev/badge/github.com/zx06/xsql.svg)](https://pkg.go.dev/github.com/zx06/xsql) +[![Go Version](https://img.shields.io/github/go-mod/go-version/zx06/xsql)](https://github.com/zx06/xsql/blob/main/go.mod) +[![Go Report Card](https://goreportcard.com/badge/github.com/zx06/xsql)](https://goreportcard.com/report/github.com/zx06/xsql) +[![License](https://img.shields.io/github/license/zx06/xsql)](https://github.com/zx06/xsql/blob/main/LICENSE) +[![Release](https://img.shields.io/github/v/release/zx06/xsql)](https://github.com/zx06/xsql/releases/latest) +[![GitHub Downloads](https://img.shields.io/github/downloads/zx06/xsql/total)](https://github.com/zx06/xsql/releases) +[![npm](https://img.shields.io/npm/v/xsql-cli)](https://www.npmjs.com/package/xsql-cli) +[![npm downloads](https://img.shields.io/npm/dm/xsql-cli)](https://www.npmjs.com/package/xsql-cli) + +**让 AI 安全地查询你的数据库** 🤖🔒 + +[English](README.md) + +xsql 是专为 AI Agent 设计的跨数据库 CLI 工具。默认只读、结构化输出、开箱即用。 + +```bash +# AI 可以这样查询你的数据库 +xsql query "SELECT * FROM users WHERE created_at > '2024-01-01'" -p prod -f json +``` + +## ✨ 为什么选择 xsql? + +| 特性 | 说明 | +|------|------| +| 🔒 **默认安全** | 双重只读保护,防止 AI 误操作 | +| 🤖 **AI-first** | JSON 结构化输出,便于 AI 解析 | +| 🔑 **密钥安全** | 集成 OS Keyring,密码不落盘 | +| 🌐 **SSH 隧道** | 一行配置连接内网数据库 | +| 📦 **零依赖** | 单二进制文件,开箱即用 | + +## 🚀 30 秒上手 + +### 1. 安装 + +```bash +# macOS +brew install zx06/tap/xsql + +# Windows +scoop bucket add zx06 https://github.com/zx06/scoop-bucket && scoop install xsql + +# npm / npx +npm install -g xsql-cli + +# 或直接下载: https://github.com/zx06/xsql/releases +``` + +### 2. 配置 + +```bash +mkdir -p ~/.config/xsql +cat > ~/.config/xsql/xsql.yaml << 'EOF' +profiles: + dev: + db: mysql + host: 127.0.0.1 + port: 3306 + user: root + password: your_password + database: mydb + allow_plaintext: true # 生产环境建议用 keyring +EOF +``` + +### 3. 使用 + +```bash +xsql query "SELECT 1" -p dev -f json +# {"ok":true,"schema_version":1,"data":{"columns":["1"],"rows":[{"1":1}]}} +``` + +--- + +## 🤖 让 AI 使用 xsql + +### 方式一:Claude Code Plugin(推荐) + +```bash +# 1. 添加 marketplace +/plugin marketplace add zx06/xsql + +# 2. 安装插件 +/plugin install xsql@xsql +``` + +安装后 Claude 自动获得 xsql 技能,可直接查询数据库。 + +### 方式二:复制 Skill 给任意 AI + +将以下内容发送给你的 AI 助手(ChatGPT/Claude/Cursor 等): + +
+📋 点击展开 AI Skill Prompt(复制即用) + +``` +你现在可以使用 xsql 工具查询数据库。 + +## 基本用法 +xsql query "" -p -f json + +## 可用命令 +- xsql query "SQL" -p -f json # 执行查询 +- xsql schema dump -p -f json # 导出数据库结构 +- xsql profile list -f json # 列出所有 profile +- xsql profile show -f json # 查看 profile 详情 + +## 输出格式 +成功: {"ok":true,"schema_version":1,"data":{"columns":[...],"rows":[...]}} +失败: {"ok":false,"schema_version":1,"error":{"code":"XSQL_...","message":"..."}} + +## 重要规则 +1. 默认只读模式,无法执行 INSERT/UPDATE/DELETE +2. 始终使用 -f json 获取结构化输出 +3. 先用 profile list 查看可用的数据库配置 +4. 检查 ok 字段判断执行是否成功 + +## 退出码 +0=成功, 2=配置错误, 3=连接错误, 4=只读拦截, 5=SQL执行错误 +``` + +
+ +### 方式三:MCP Server(Claude Desktop 等) + +在 Claude Desktop 配置中添加 xsql MCP server: + +```json +{ + "mcpServers": { + "xsql": { + "command": "xsql", + "args": ["mcp", "server", "--config", "/path/to/xsql.yaml"] + } + } +} +``` + +启动后,Claude 可以直接通过 MCP 协议查询数据库。 + +### 方式四:AGENTS.md / Rules(Cursor/Windsurf) + +在项目根目录创建 `.cursor/rules` 或编辑 `AGENTS.md`: + +```markdown +## 数据库查询 + +使用 xsql 工具查询数据库: +- 查询: `xsql query "SELECT ..." -p -f json` +- 导出结构: `xsql schema dump -p -f json` +- 列出配置: `xsql profile list -f json` + +注意: 默认只读模式,写操作需要 --unsafe-allow-write 标志。 +``` + +--- + +## 📖 功能详情 + +### 命令一览 + +| 命令 | 说明 | +|------|------| +| `xsql query ` | 执行 SQL 查询(默认只读) | +| `xsql schema dump` | 导出数据库结构(表、列、索引、外键) | +| `xsql profile list` | 列出所有 profile | +| `xsql profile show ` | 查看 profile 详情(密码脱敏) | +| `xsql mcp server` | 启动 MCP Server(AI 助手集成) | +| `xsql spec` | 导出 AI Tool Spec(支持 `--format yaml`) | +| `xsql version` | 显示版本信息 | + +### 输出格式 + +```bash +# JSON(AI/程序) +xsql query "SELECT id, name FROM users" -p dev -f json +{"ok":true,"schema_version":1,"data":{"columns":["id","name"],"rows":[{"id":1,"name":"Alice"}]}} + +# Table(终端) +xsql query "SELECT id, name FROM users" -p dev -f table +id name +-- ----- +1 Alice + +(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 +ssh_proxies: + bastion: + host: jump.example.com + user: admin + identity_file: ~/.ssh/id_ed25519 + +profiles: + prod: + db: pg + host: db.internal # 内网地址 + port: 5432 + user: readonly + password: "keyring:prod/password" + database: mydb + ssh_proxy: bastion # 引用 SSH 代理 +``` + +### 端口转发代理(xsql proxy) + +当需要传统的 `ssh -L` 行为或希望暴露本地端口给 GUI 客户端时,可以使用 `xsql proxy`: + +```bash +# 启动端口转发(自动分配本地端口) +xsql proxy -p prod + +# 指定本地端口 +xsql proxy -p prod --local-port 13306 +``` + +> 该命令要求 profile 配置 `ssh_proxy`,并会在本地监听端口,将流量转发到目标数据库。 + +### 安全特性 + +- **双重只读保护**:SQL 静态分析 + 数据库事务级只读 +- **Keyring 集成**:`password: "keyring:prod/password"` +- **密码脱敏**:`profile show` 不泄露密码 +- **SSH 安全**:默认验证 known_hosts + +--- + +## 📚 文档 + +| 文档 | 说明 | +|------|------| +| [CLI 规范](docs/cli-spec.md) | 命令行接口详细说明 | +| [配置指南](docs/config.md) | 配置文件格式和选项 | +| [SSH 代理](docs/ssh-proxy.md) | SSH 隧道配置 | +| [错误处理](docs/error-contract.md) | 错误码和退出码 | +| [AI 集成](docs/ai.md) | MCP Server 和 AI 助手集成 | +| [RFC 文档](docs/rfcs/) | 设计变更记录 | +| [开发指南](docs/dev.md) | 贡献和开发说明 | + +--- + +## License + +[MIT](LICENSE) diff --git a/cmd/xsql/doc.go b/cmd/xsql/doc.go new file mode 100644 index 0000000..32bc436 --- /dev/null +++ b/cmd/xsql/doc.go @@ -0,0 +1,3 @@ +// Command xsql is an AI-first cross-database CLI tool with default read-only +// protection, structured output, and SSH tunnel support. +package main diff --git a/internal/config/load.go b/internal/config/load.go index 344266d..e7a3dbf 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -41,7 +41,7 @@ func readFile(path string) (File, *errors.XError) { return f, nil } -// LoadConfig 加载配置文件,返回完整配置和配置文件路径。 +// LoadConfig loads the configuration file and returns the full config along with its path. func LoadConfig(opts Options) (File, string, *errors.XError) { workDir := opts.WorkDir if workDir == "" { diff --git a/internal/config/resolve.go b/internal/config/resolve.go index 3bb0d3b..b9e4f1e 100644 --- a/internal/config/resolve.go +++ b/internal/config/resolve.go @@ -7,7 +7,7 @@ import ( "github.com/zx06/xsql/internal/errors" ) -// Resolve 实现第一阶段 config/profile/format 合并:CLI > ENV > Config。 +// Resolve performs phase-1 config/profile/format merging: CLI > ENV > Config. func Resolve(opts Options) (Resolved, *errors.XError) { workDir := opts.WorkDir if workDir == "" { @@ -20,7 +20,7 @@ func Resolve(opts Options) (Resolved, *errors.XError) { } } - // 1) 读取配置文件(如有) + // 1) Read config file (if any) var cfg File var cfgPath string if opts.ConfigPath != "" { @@ -49,7 +49,7 @@ func Resolve(opts Options) (Resolved, *errors.XError) { } } - // 2) 选择 profile:--profile > XSQL_PROFILE > profiles.default > 空 + // 2) Select profile: --profile > XSQL_PROFILE > profiles.default > empty profile := "" if opts.CLIProfileSet { profile = opts.CLIProfile @@ -61,7 +61,7 @@ func Resolve(opts Options) (Resolved, *errors.XError) { } } - // 3) 获取完整 profile + // 3) Retrieve full profile var selectedProfile Profile if profile != "" { p, ok := cfg.Profiles[profile] @@ -70,7 +70,7 @@ func Resolve(opts Options) (Resolved, *errors.XError) { map[string]any{"profile": profile}) } selectedProfile = p - // 解析 ssh_proxy 引用 + // Resolve ssh_proxy reference if selectedProfile.SSHProxy != "" { if proxy, ok := cfg.SSHProxies[selectedProfile.SSHProxy]; ok { selectedProfile.SSHConfig = &proxy @@ -79,7 +79,7 @@ func Resolve(opts Options) (Resolved, *errors.XError) { map[string]any{"profile": profile, "ssh_proxy": selectedProfile.SSHProxy}) } } - // 设置默认端口 + // Set default port if selectedProfile.Port == 0 { switch selectedProfile.DB { case "mysql": @@ -90,7 +90,7 @@ func Resolve(opts Options) (Resolved, *errors.XError) { } } - // 4) 合并 format:--format > XSQL_FORMAT > profile.format > auto + // 4) Merge format: --format > XSQL_FORMAT > profile.format > auto format := "auto" if selectedProfile.Format != "" { format = selectedProfile.Format diff --git a/internal/config/types.go b/internal/config/types.go index e7cb059..6eb1e0a 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -1,77 +1,77 @@ package config -// File 表示 xsql.yaml 的配置结构。 -// 约束:配置优先级为 CLI > ENV > Config。 +// File represents the xsql.yaml configuration structure. +// Constraint: config priority is CLI > ENV > Config. type File struct { SSHProxies map[string]SSHProxy `yaml:"ssh_proxies"` Profiles map[string]Profile `yaml:"profiles"` MCP MCPConfig `yaml:"mcp"` } -// SSHProxy 定义可复用的 SSH 代理配置。 +// SSHProxy defines a reusable SSH proxy configuration. type SSHProxy struct { Host string `yaml:"host"` Port int `yaml:"port"` User string `yaml:"user"` IdentityFile string `yaml:"identity_file"` - Passphrase string `yaml:"passphrase"` // 支持 keyring:xxx 引用 + Passphrase string `yaml:"passphrase"` // supports keyring:xxx reference KnownHostsFile string `yaml:"known_hosts_file"` - SkipHostKey bool `yaml:"skip_host_key"` // 极不推荐 + SkipHostKey bool `yaml:"skip_host_key"` // strongly discouraged } type Profile struct { - Description string `yaml:"description"` // 描述,用于区分不同数据库 + Description string `yaml:"description"` // description to distinguish databases Format string `yaml:"format"` - // DB 连接 + // DB connection DB string `yaml:"db"` // mysql | pg - DSN string `yaml:"dsn"` // 原生 DSN(优先) + DSN string `yaml:"dsn"` // raw DSN (takes precedence) Host string `yaml:"host"` Port int `yaml:"port"` User string `yaml:"user"` - Password string `yaml:"password"` // 支持 keyring:xxx 引用 + Password string `yaml:"password"` // supports keyring:xxx reference Database string `yaml:"database"` - // 安全选项 - AllowPlaintext bool `yaml:"allow_plaintext"` // 允许明文密码 - UnsafeAllowWrite bool `yaml:"unsafe_allow_write"` // 允许写操作(绕过只读保护) + // Security options + AllowPlaintext bool `yaml:"allow_plaintext"` // allow plaintext password + UnsafeAllowWrite bool `yaml:"unsafe_allow_write"` // allow write operations (bypass read-only protection) - // 超时配置(秒) - QueryTimeout int `yaml:"query_timeout"` // 查询超时,默认 30 秒 - SchemaTimeout int `yaml:"schema_timeout"` // Schema 导出超时,默认 60 秒 + // Timeout settings (seconds) + QueryTimeout int `yaml:"query_timeout"` // query timeout, default 30s + SchemaTimeout int `yaml:"schema_timeout"` // schema export timeout, default 60s - // SSH proxy 引用(引用 ssh_proxies 中定义的名称) + // SSH proxy reference (refers to a name defined in ssh_proxies) SSHProxy string `yaml:"ssh_proxy"` - // Proxy 本地端口(用于 xsql proxy 命令) + // Local port for the proxy (used by xsql proxy command) LocalPort int `yaml:"local_port"` - // 解析后的 SSH 配置(由 Resolve 填充,不从 YAML 读取) + // Resolved SSH config (populated by Resolve, not read from YAML) SSHConfig *SSHProxy `yaml:"-"` } -// MCPConfig 定义 MCP server 配置。 +// MCPConfig defines the MCP server configuration. type MCPConfig struct { Transport string `yaml:"transport"` // stdio | streamable_http HTTP MCPHTTPConfig `yaml:"http"` } -// MCPHTTPConfig 定义 MCP Streamable HTTP 传输配置。 +// MCPHTTPConfig defines the MCP Streamable HTTP transport configuration. type MCPHTTPConfig struct { Addr string `yaml:"addr"` - AuthToken string `yaml:"auth_token"` // 支持 keyring:xxx 引用 - AllowPlaintextToken bool `yaml:"allow_plaintext_token"` // 允许明文 token + AuthToken string `yaml:"auth_token"` // supports keyring:xxx reference + AllowPlaintextToken bool `yaml:"allow_plaintext_token"` // allow plaintext token } type Resolved struct { ConfigPath string ProfileName string Format string - Profile Profile // 完整 profile 供 query 使用 + Profile Profile // full profile for query use } type Options struct { - // ConfigPath: 若非空,则只读取该文件(不存在报错)。 + // ConfigPath: if non-empty, only this file is read (error if not found). ConfigPath string // CLI @@ -80,14 +80,14 @@ type Options struct { CLIFormat string CLIFormatSet bool - // ENV(由调用方注入,便于测试) + // ENV (injected by caller for testability) EnvProfile string EnvFormat string - // HomeDir 用于默认路径计算(为空则自动探测)。 + // HomeDir is used for default path resolution (auto-detected if empty). HomeDir string - // WorkDir 用于默认路径(为空则使用进程当前工作目录)。 + // WorkDir is used for default paths (falls back to process cwd if empty). WorkDir string } diff --git a/internal/db/mysql/driver.go b/internal/db/mysql/driver.go index 2d327d3..43ce78a 100644 --- a/internal/db/mysql/driver.go +++ b/internal/db/mysql/driver.go @@ -1,3 +1,4 @@ +// Package mysql implements the MySQL database driver. package mysql import ( diff --git a/internal/db/mysql/schema.go b/internal/db/mysql/schema.go index 990b911..c3e2f42 100644 --- a/internal/db/mysql/schema.go +++ b/internal/db/mysql/schema.go @@ -9,40 +9,40 @@ import ( "github.com/zx06/xsql/internal/errors" ) -// DumpSchema 导出 MySQL 数据库结构 +// DumpSchema exports the MySQL database schema. func (d *Driver) DumpSchema(ctx context.Context, conn *sql.DB, opts db.SchemaOptions) (*db.SchemaInfo, *errors.XError) { info := &db.SchemaInfo{} - // 获取当前数据库名 + // Get the current database name var database string if err := conn.QueryRowContext(ctx, "SELECT DATABASE()").Scan(&database); err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "failed to get database name", nil, err) } info.Database = database - // 获取表列表 + // Get table list tables, xe := d.listTables(ctx, conn, database, opts) if xe != nil { return nil, xe } - // 获取每个表的详细信息 + // Get detailed information for each table for _, table := range tables { - // 获取列信息 + // Get column information columns, xe := d.getColumns(ctx, conn, database, table.Name) if xe != nil { return nil, xe } table.Columns = columns - // 获取索引信息 + // Get index information indexes, xe := d.getIndexes(ctx, conn, database, table.Name) if xe != nil { return nil, xe } table.Indexes = indexes - // 获取外键信息 + // Get foreign key information fks, xe := d.getForeignKeys(ctx, conn, database, table.Name) if xe != nil { return nil, xe @@ -55,7 +55,7 @@ func (d *Driver) DumpSchema(ctx context.Context, conn *sql.DB, opts db.SchemaOpt return info, nil } -// listTables 获取表列表 +// listTables retrieves the list of tables. func (d *Driver) listTables(ctx context.Context, conn *sql.DB, database string, opts db.SchemaOptions) ([]db.Table, *errors.XError) { query := ` SELECT table_name, table_comment @@ -64,9 +64,9 @@ func (d *Driver) listTables(ctx context.Context, conn *sql.DB, database string, ` args := []any{database} - // 表名过滤 + // Table name filter if opts.TablePattern != "" { - // 将通配符 * 和 ? 转换为 SQL LIKE 模式 + // Convert wildcards * and ? to SQL LIKE patterns likePattern := strings.ReplaceAll(opts.TablePattern, "*", "%") likePattern = strings.ReplaceAll(likePattern, "?", "_") query += " AND table_name LIKE ?" @@ -101,7 +101,7 @@ func (d *Driver) listTables(ctx context.Context, conn *sql.DB, database string, return tables, nil } -// getColumns 获取表的列信息 +// getColumns retrieves column information for a table. func (d *Driver) getColumns(ctx context.Context, conn *sql.DB, database, tableName string) ([]db.Column, *errors.XError) { query := ` SELECT @@ -152,7 +152,7 @@ func (d *Driver) getColumns(ctx context.Context, conn *sql.DB, database, tableNa return columns, nil } -// getIndexes 获取表的索引信息 +// getIndexes retrieves index information for a table. func (d *Driver) getIndexes(ctx context.Context, conn *sql.DB, database, tableName string) ([]db.Index, *errors.XError) { query := ` SELECT @@ -172,7 +172,7 @@ func (d *Driver) getIndexes(ctx context.Context, conn *sql.DB, database, tableNa } defer rows.Close() - // 按 index_name 分组 + // Group by index_name indexMap := make(map[string]*db.Index) for rows.Next() { var indexName, columnName string @@ -198,7 +198,7 @@ func (d *Driver) getIndexes(ctx context.Context, conn *sql.DB, database, tableNa return nil, errors.Wrap(errors.CodeDBExecFailed, "rows iteration error", nil, err) } - // 转换为切片 + // Convert to slice indexes := make([]db.Index, 0, len(indexMap)) for _, idx := range indexMap { indexes = append(indexes, *idx) @@ -207,7 +207,7 @@ func (d *Driver) getIndexes(ctx context.Context, conn *sql.DB, database, tableNa return indexes, nil } -// getForeignKeys 获取表的外键信息 +// getForeignKeys retrieves foreign key information for a table. func (d *Driver) getForeignKeys(ctx context.Context, conn *sql.DB, database, tableName string) ([]db.ForeignKey, *errors.XError) { query := ` SELECT @@ -229,7 +229,7 @@ func (d *Driver) getForeignKeys(ctx context.Context, conn *sql.DB, database, tab } defer rows.Close() - // 按 constraint_name 分组 + // Group by constraint_name fkMap := make(map[string]*db.ForeignKey) for rows.Next() { var constraintName, columnName, refTable, refColumn string @@ -255,7 +255,7 @@ func (d *Driver) getForeignKeys(ctx context.Context, conn *sql.DB, database, tab return nil, errors.Wrap(errors.CodeDBExecFailed, "rows iteration error", nil, err) } - // 转换为切片 + // Convert to slice fks := make([]db.ForeignKey, 0, len(fkMap)) for _, fk := range fkMap { fks = append(fks, *fk) diff --git a/internal/db/pg/driver.go b/internal/db/pg/driver.go index a960a92..763a7a4 100644 --- a/internal/db/pg/driver.go +++ b/internal/db/pg/driver.go @@ -1,3 +1,4 @@ +// Package pg implements the PostgreSQL database driver. package pg import ( @@ -27,7 +28,7 @@ func (d *Driver) Open(ctx context.Context, opts db.ConnOptions) (*sql.DB, *error dsn = buildDSN(opts) } - // 使用 pgx 自定义 dialer + // Use pgx custom dialer if opts.Dialer != nil { config, err := pgx.ParseConfig(dsn) if err != nil { diff --git a/internal/db/pg/schema.go b/internal/db/pg/schema.go index 5f01f3c..e9197b5 100644 --- a/internal/db/pg/schema.go +++ b/internal/db/pg/schema.go @@ -9,47 +9,47 @@ import ( "github.com/zx06/xsql/internal/errors" ) -// DumpSchema 导出 PostgreSQL 数据库结构 +// DumpSchema exports the PostgreSQL database schema. func (d *Driver) DumpSchema(ctx context.Context, conn *sql.DB, opts db.SchemaOptions) (*db.SchemaInfo, *errors.XError) { info := &db.SchemaInfo{} - // 获取当前数据库名 + // Get the current database name var database string if err := conn.QueryRowContext(ctx, "SELECT current_database()").Scan(&database); err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "failed to get database name", nil, err) } info.Database = database - // 获取 schema 列表(排除系统 schema) + // Get schema list (excluding system schemas) schemas, xe := d.listSchemas(ctx, conn, opts) if xe != nil { return nil, xe } - // 获取每个 schema 下的表 + // Get tables under each schema for _, schema := range schemas { tables, xe := d.listTables(ctx, conn, schema, opts) if xe != nil { return nil, xe } - // 获取每个表的详细信息 + // Get detailed information for each table for _, table := range tables { - // 获取列信息 + // Get column information columns, xe := d.getColumns(ctx, conn, schema, table.Name) if xe != nil { return nil, xe } table.Columns = columns - // 获取索引信息 + // Get index information indexes, xe := d.getIndexes(ctx, conn, schema, table.Name) if xe != nil { return nil, xe } table.Indexes = indexes - // 获取外键信息 + // Get foreign key information fks, xe := d.getForeignKeys(ctx, conn, schema, table.Name) if xe != nil { return nil, xe @@ -63,7 +63,7 @@ func (d *Driver) DumpSchema(ctx context.Context, conn *sql.DB, opts db.SchemaOpt return info, nil } -// listSchemas 获取 schema 列表 +// listSchemas retrieves the list of schemas. func (d *Driver) listSchemas(ctx context.Context, conn *sql.DB, opts db.SchemaOptions) ([]string, *errors.XError) { query := ` SELECT schema_name @@ -72,7 +72,7 @@ func (d *Driver) listSchemas(ctx context.Context, conn *sql.DB, opts db.SchemaOp ` if !opts.IncludeSystem { - // 排除更多系统 schema + // Exclude additional system schemas query += " AND schema_name NOT LIKE 'pg_%'" } @@ -100,7 +100,7 @@ func (d *Driver) listSchemas(ctx context.Context, conn *sql.DB, opts db.SchemaOp return schemas, nil } -// listTables 获取表列表 +// listTables retrieves the list of tables. func (d *Driver) listTables(ctx context.Context, conn *sql.DB, schema string, opts db.SchemaOptions) ([]db.Table, *errors.XError) { query := ` SELECT @@ -111,9 +111,9 @@ func (d *Driver) listTables(ctx context.Context, conn *sql.DB, schema string, op ` args := []any{schema} - // 表名过滤 + // Table name filter if opts.TablePattern != "" { - // 将通配符 * 和 ? 转换为 SQL LIKE 模式 + // Convert wildcards * and ? to SQL LIKE patterns likePattern := strings.ReplaceAll(opts.TablePattern, "*", "%") likePattern = strings.ReplaceAll(likePattern, "?", "_") query += " AND t.table_name LIKE $2" @@ -149,7 +149,7 @@ func (d *Driver) listTables(ctx context.Context, conn *sql.DB, schema string, op return tables, nil } -// getColumns 获取表的列信息 +// getColumns retrieves column information for a table. func (d *Driver) getColumns(ctx context.Context, conn *sql.DB, schema, tableName string) ([]db.Column, *errors.XError) { query := ` SELECT @@ -220,7 +220,7 @@ func (d *Driver) getColumns(ctx context.Context, conn *sql.DB, schema, tableName return columns, nil } -// getIndexes 获取表的索引信息 +// getIndexes retrieves index information for a table. func (d *Driver) getIndexes(ctx context.Context, conn *sql.DB, schema, tableName string) ([]db.Index, *errors.XError) { query := ` SELECT @@ -244,7 +244,7 @@ func (d *Driver) getIndexes(ctx context.Context, conn *sql.DB, schema, tableName } defer rows.Close() - // 按 index_name 分组 + // Group by index_name indexMap := make(map[string]*db.Index) for rows.Next() { var indexName, columnName string @@ -270,7 +270,7 @@ func (d *Driver) getIndexes(ctx context.Context, conn *sql.DB, schema, tableName return nil, errors.Wrap(errors.CodeDBExecFailed, "rows iteration error", nil, err) } - // 转换为切片 + // Convert to slice indexes := make([]db.Index, 0, len(indexMap)) for _, idx := range indexMap { indexes = append(indexes, *idx) @@ -279,7 +279,7 @@ func (d *Driver) getIndexes(ctx context.Context, conn *sql.DB, schema, tableName return indexes, nil } -// getForeignKeys 获取表的外键信息 +// getForeignKeys retrieves foreign key information for a table. func (d *Driver) getForeignKeys(ctx context.Context, conn *sql.DB, schema, tableName string) ([]db.ForeignKey, *errors.XError) { query := ` SELECT @@ -307,7 +307,7 @@ func (d *Driver) getForeignKeys(ctx context.Context, conn *sql.DB, schema, table } defer rows.Close() - // 按 constraint_name 分组 + // Group by constraint_name fkMap := make(map[string]*db.ForeignKey) for rows.Next() { var constraintName, columnName, refTable, refColumn string @@ -333,7 +333,7 @@ func (d *Driver) getForeignKeys(ctx context.Context, conn *sql.DB, schema, table return nil, errors.Wrap(errors.CodeDBExecFailed, "rows iteration error", nil, err) } - // 转换为切片 + // Convert to slice fks := make([]db.ForeignKey, 0, len(fkMap)) for _, fk := range fkMap { fks = append(fks, *fk) diff --git a/internal/db/query.go b/internal/db/query.go index 42ce337..299c2a7 100644 --- a/internal/db/query.go +++ b/internal/db/query.go @@ -7,13 +7,13 @@ import ( "github.com/zx06/xsql/internal/errors" ) -// QueryResult 是通用查询结果。 +// QueryResult represents a generic query result. type QueryResult struct { Columns []string `json:"columns" yaml:"columns"` Rows []map[string]any `json:"rows" yaml:"rows"` } -// ToTableData 实现 output.TableFormatter 接口,支持无 JSON 编解码的表格输出。 +// ToTableData implements the output.TableFormatter interface for table output without JSON encoding/decoding. func (r *QueryResult) ToTableData() (columns []string, rows []map[string]any, ok bool) { if r == nil { return nil, nil, false @@ -21,40 +21,40 @@ func (r *QueryResult) ToTableData() (columns []string, rows []map[string]any, ok return r.Columns, r.Rows, true } -// QueryOptions 包含查询执行的选项。 +// QueryOptions contains options for query execution. type QueryOptions struct { - UnsafeAllowWrite bool // 允许写操作(绕过只读保护) - DBType string // 数据库类型:mysql 或 pg + UnsafeAllowWrite bool // Allow write operations (bypass read-only protection) + DBType string // Database type: mysql or pg } -// Query 执行 SQL 查询并返回结果。 -// 当 opts.UnsafeAllowWrite=false 时,会启用双重只读保护: -// 1. SQL 语句静态分析(客户端) -// 2. 数据库事务级只读模式(服务端) -// 当 opts.UnsafeAllowWrite=true 时,绕过所有只读保护。 +// Query executes a SQL query and returns the result. +// When opts.UnsafeAllowWrite is false, dual read-only protection is enabled: +// 1. SQL statement static analysis (client-side) +// 2. Database transaction-level read-only mode (server-side) +// When opts.UnsafeAllowWrite is true, all read-only protections are bypassed. func Query(ctx context.Context, db *sql.DB, query string, opts QueryOptions) (*QueryResult, *errors.XError) { - // UnsafeAllowWrite 绕过所有只读保护 + // UnsafeAllowWrite bypasses all read-only protections if opts.UnsafeAllowWrite { return executeQuery(ctx, db, query) } - // 默认启用双重只读保护 - // 第一层保护:SQL 静态分析 + // Enable dual read-only protection by default + // First layer: SQL static analysis if xe := EnforceReadOnly(query, false); xe != nil { return nil, xe } - // 第二层保护:数据库事务级只读 + // Second layer: database transaction-level read-only return queryWithReadOnlyTx(ctx, db, query, opts.DBType) } -// queryWithReadOnlyTx 在只读事务中执行查询。 +// queryWithReadOnlyTx executes a query within a read-only transaction. func queryWithReadOnlyTx(ctx context.Context, db *sql.DB, query string, dbType string) (*QueryResult, *errors.XError) { tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) if err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "failed to begin read-only transaction", nil, err) } defer func() { - // 只读事务无需 commit,直接 rollback + // Read-only transaction needs no commit; just rollback _ = tx.Rollback() }() @@ -67,7 +67,7 @@ func queryWithReadOnlyTx(ctx context.Context, db *sql.DB, query string, dbType s return scanRows(rows) } -// executeQuery 直接执行查询(不使用事务)。 +// executeQuery executes a query directly (without a transaction). func executeQuery(ctx context.Context, db *sql.DB, query string) (*QueryResult, *errors.XError) { rows, err := db.QueryContext(ctx, query) if err != nil { @@ -78,7 +78,7 @@ func executeQuery(ctx context.Context, db *sql.DB, query string) (*QueryResult, return scanRows(rows) } -// scanRows 扫描查询结果。 +// scanRows scans query result rows. func scanRows(rows *sql.Rows) (*QueryResult, *errors.XError) { cols, err := rows.Columns() if err != nil { diff --git a/internal/db/readonly.go b/internal/db/readonly.go index e6e4357..78bf64d 100644 --- a/internal/db/readonly.go +++ b/internal/db/readonly.go @@ -28,7 +28,7 @@ const ( TokenEOF ) -// 允许的首关键字(allowlist) +// Allowed leading keywords (allowlist) var allowedFirstKeywords = map[string]bool{ "SELECT": true, "SHOW": true, @@ -40,7 +40,7 @@ var allowedFirstKeywords = map[string]bool{ "VALUES": true, // PostgreSQL VALUES } -// 禁止的关键字(denylist)- 任何位置出现都拒绝 +// Denied keywords (denylist) - rejected if found anywhere var forbiddenKeywords = map[string]bool{ "INSERT": true, "UPDATE": true, @@ -59,8 +59,8 @@ var forbiddenKeywords = map[string]bool{ "EXECUTE": true, "PREPARE": true, "DEALLOCATE": true, - "SET": true, // 可能解除只读: SET TRANSACTION READ WRITE - "BEGIN": true, // 用户可能 BEGIN READ WRITE + "SET": true, // may disable read-only: SET TRANSACTION READ WRITE + "BEGIN": true, // user may BEGIN READ WRITE "COMMIT": true, "ROLLBACK": true, "SAVEPOINT": true, @@ -71,17 +71,19 @@ var forbiddenKeywords = map[string]bool{ "REPLACE": true, // MySQL REPLACE } -// IsReadOnlySQL 做保守判定:默认拒绝;仅允许文档明确的只读语句。 -// 使用词法分析而非简单字符串匹配,正确处理字符串、注释。 -// 解析失败/多语句/包含禁止关键字时一律返回 false。 +// IsReadOnlySQL performs a conservative check: deny by default; only allow +// explicitly documented read-only statements. +// Uses lexical analysis instead of simple string matching to correctly handle +// strings and comments. Returns false on parse failure, multiple statements, +// or presence of forbidden keywords. func IsReadOnlySQL(sql string) (bool, string) { sql = strings.TrimSpace(sql) if sql == "" { return false, "empty" } - // 保守:首字符若不是字母,则视为无法解析(例如 "(select 1)") - // 这是为了防止子查询绕过检测 + // Conservative: if the first character is not a letter, treat as unparseable + // (e.g. "(select 1)") to prevent subquery bypass trimmed := stripLeadingCommentsAndSpace(sql) if trimmed == "" { return false, "empty_after_comment" @@ -90,18 +92,18 @@ func IsReadOnlySQL(sql string) (bool, string) { return false, "non_letter_start" } - // 词法分析并检查 + // Tokenize and validate tokens, err := tokenize(sql) if err != nil { return false, "tokenize_error: " + err.Error() } - // 检查是否包含多语句(多个分号分隔的有效语句) + // Check for multiple statements (multiple valid statements separated by semicolons) if hasMultipleValidStatements(tokens) { return false, "multiple_statements" } - // 找到第一个有效关键字(跳过注释) + // Find the first valid keyword (skipping comments) firstKeyword := "" for _, tok := range tokens { if tok.Type == TokenKeyword { @@ -114,25 +116,25 @@ func IsReadOnlySQL(sql string) (bool, string) { return false, "no_keyword" } - // 检查首关键字是否在 allowlist 中 + // Check if the first keyword is in the allowlist if !allowedFirstKeywords[firstKeyword] { return false, "forbidden_start: " + firstKeyword } - // 检查是否包含任何禁止的关键字 + // Check for any forbidden keywords for _, tok := range tokens { if tok.Type == TokenKeyword && forbiddenKeywords[tok.Value] { - // WITH 后面跟 INSERT/UPDATE/DELETE 的情况 + // e.g. WITH ... INSERT/UPDATE/DELETE return false, "forbidden_keyword: " + tok.Value } } - // 检查是否包含 SELECT ... FOR SHARE / FOR KEY SHARE + // Check for SELECT ... FOR SHARE / FOR KEY SHARE if hasSelectShareLock(tokens) { return false, "forbidden_share_lock" } - // 特殊处理 WITH (CTE) - 检查是否包含写入操作 + // Special handling for WITH (CTE) - check for write operations if firstKeyword == "WITH" { if hasWriteInCTE(tokens) { return false, "cte_write_operation" @@ -173,7 +175,7 @@ func hasKeywordSequence(keywords []string, seq []string) bool { return false } -// stripLeadingCommentsAndSpace 去除前导注释和空白 +// stripLeadingCommentsAndSpace strips leading comments and whitespace func stripLeadingCommentsAndSpace(s string) string { for { s = strings.TrimLeftFunc(s, unicode.IsSpace) @@ -207,7 +209,7 @@ func EnforceReadOnly(sql string, unsafeAllowWrite bool) *errors.XError { return errors.New(errors.CodeROBlocked, "write blocked by read-only policy", map[string]any{"reason": reason}) } -// tokenize 执行 SQL 词法分析,正确处理字符串、注释、标识符 +// tokenize performs SQL lexical analysis, correctly handling strings, comments, and identifiers func tokenize(sql string) ([]SQLToken, error) { var tokens []SQLToken i := 0 @@ -216,30 +218,30 @@ func tokenize(sql string) ([]SQLToken, error) { for i < sqlLen { r := rune(sql[i]) - // 跳过空白字符 + // Skip whitespace if unicode.IsSpace(r) { i++ continue } - // BOM 头 + // BOM header if r == '\ufeff' { i++ continue } - // 行注释 -- + // Line comment -- if r == '-' && i+1 < sqlLen && sql[i+1] == '-' { - // 跳过到行尾 + // Skip to end of line for i < sqlLen && sql[i] != '\n' { i++ } continue } - // 块注释 /* */ + // Block comment /* */ if r == '/' && i+1 < sqlLen && sql[i+1] == '*' { - // 跳过到 */ + // Skip to */ i += 2 for i < sqlLen-1 { if sql[i] == '*' && sql[i+1] == '/' { @@ -251,7 +253,7 @@ func tokenize(sql string) ([]SQLToken, error) { continue } - // 字符串 '...' (MySQL/PostgreSQL/PG 支持 escape) + // String literal '...' (MySQL/PostgreSQL with escape support) if r == '\'' { str, newIdx := parseString(sql, i, '\'') tokens = append(tokens, SQLToken{Type: TokenString, Value: str}) @@ -259,7 +261,7 @@ func tokenize(sql string) ([]SQLToken, error) { continue } - // 字符串 "..." (PostgreSQL/ANSI 标准) + // String literal "..." (PostgreSQL/ANSI standard) if r == '"' { str, newIdx := parseString(sql, i, '"') tokens = append(tokens, SQLToken{Type: TokenString, Value: str}) @@ -267,7 +269,7 @@ func tokenize(sql string) ([]SQLToken, error) { continue } - // 反引号 `...` (MySQL 标识符) + // Backtick `...` (MySQL identifier) if r == '`' { str, newIdx := parseString(sql, i, '`') tokens = append(tokens, SQLToken{Type: TokenIdentifier, Value: str}) @@ -275,7 +277,7 @@ func tokenize(sql string) ([]SQLToken, error) { continue } - // PostgreSQL 美元引号字符串 $tag$...$tag$ + // PostgreSQL dollar-quoted string $tag$...$tag$ if r == '$' { if str, newIdx, ok := parseDollarQuotedString(sql, i); ok { tokens = append(tokens, SQLToken{Type: TokenString, Value: str}) @@ -284,14 +286,14 @@ func tokenize(sql string) ([]SQLToken, error) { } } - // 分号 + // Semicolon if r == ';' { tokens = append(tokens, SQLToken{Type: TokenSemicolon, Value: ";"}) i++ continue } - // 数字 + // Number if unicode.IsDigit(r) || (r == '.' && i+1 < sqlLen && unicode.IsDigit(rune(sql[i+1]))) { start := i for i < sqlLen && (unicode.IsDigit(rune(sql[i])) || sql[i] == '.' || sql[i] == 'e' || sql[i] == 'E' || sql[i] == '+' || sql[i] == '-') { @@ -301,7 +303,7 @@ func tokenize(sql string) ([]SQLToken, error) { continue } - // 关键字或标识符 + // Keyword or identifier if unicode.IsLetter(r) || r == '_' { start := i for i < sqlLen && (unicode.IsLetter(rune(sql[i])) || unicode.IsDigit(rune(sql[i])) || sql[i] == '_' || sql[i] == '$') { @@ -309,7 +311,7 @@ func tokenize(sql string) ([]SQLToken, error) { } originalWord := sql[start:i] upperWord := strings.ToUpper(originalWord) - // 检查是否为关键字(关键字存大写,标识符保留原样) + // Check if it's a keyword (keywords stored uppercase, identifiers keep original case) if isKeyword(upperWord) { tokens = append(tokens, SQLToken{Type: TokenKeyword, Value: upperWord}) } else { @@ -318,7 +320,7 @@ func tokenize(sql string) ([]SQLToken, error) { continue } - // 运算符和其他字符 + // Operators and other characters if isOperatorChar(r) { start := i for i < sqlLen && isOperatorChar(rune(sql[i])) { @@ -328,7 +330,7 @@ func tokenize(sql string) ([]SQLToken, error) { continue } - // 其他字符(可能是不支持的字符) + // Other characters (possibly unsupported) tokens = append(tokens, SQLToken{Type: TokenUnknown, Value: string(r)}) i++ } @@ -337,25 +339,25 @@ func tokenize(sql string) ([]SQLToken, error) { return tokens, nil } -// parseString 解析引号包围的字符串,处理转义 +// parseString parses a quoted string, handling escape sequences func parseString(sql string, start int, quote rune) (string, int) { - i := start + 1 // 跳过开引号 + i := start + 1 // skip opening quote sqlLen := len(sql) var result strings.Builder for i < sqlLen { r := rune(sql[i]) if r == quote { - // 检查是否是转义('' 或 "" 在引号字符串中) + // Check for escape ('' or "" within quoted strings) if i+1 < sqlLen && rune(sql[i+1]) == quote { result.WriteRune(quote) i += 2 continue } - // 字符串结束 + // End of string return result.String(), i + 1 } - // 处理普通转义 \' 或 '' + // Handle standard escape \' or '' if r == '\\' && i+1 < sqlLen && rune(sql[i+1]) == quote { result.WriteRune(quote) i += 2 @@ -365,18 +367,18 @@ func parseString(sql string, start int, quote rune) (string, int) { i++ } - // 未闭合的字符串 + // Unclosed string return result.String(), i } -// parseDollarQuotedString 解析 PostgreSQL 美元引号字符串 $tag$...$tag$ +// parseDollarQuotedString parses PostgreSQL dollar-quoted strings $tag$...$tag$ func parseDollarQuotedString(sql string, start int) (string, int, bool) { sqlLen := len(sql) if sql[start] != '$' { return "", start, false } - // 解析标签名 + // Parse tag name tagStart := start + 1 tagEnd := tagStart for tagEnd < sqlLen && (unicode.IsLetter(rune(sql[tagEnd])) || unicode.IsDigit(rune(sql[tagEnd])) || sql[tagEnd] == '_') { @@ -384,14 +386,14 @@ func parseDollarQuotedString(sql string, start int) (string, int, bool) { } if tagEnd >= sqlLen || sql[tagEnd] != '$' { - return "", start, false // 不是有效的美元引号 + return "", start, false // not a valid dollar-quoted string } tag := sql[tagStart:tagEnd] closingTag := "$" + tag + "$" contentStart := tagEnd + 1 - // 查找结束标记 + // Find closing tag for i := contentStart; i < sqlLen; i++ { if i+len(closingTag) <= sqlLen && sql[i:i+len(closingTag)] == closingTag { content := sql[contentStart:i] @@ -399,17 +401,17 @@ func parseDollarQuotedString(sql string, start int) (string, int, bool) { } } - return "", start, false // 未找到闭合标记 + return "", start, false // closing tag not found } -// isKeyword 判断是否为 SQL 关键字 +// isKeyword checks whether a word is a SQL keyword func isKeyword(word string) bool { upper := strings.ToUpper(word) - // 合并允许的和禁止的关键字 + // Check allowed and forbidden keyword lists if allowedFirstKeywords[upper] || forbiddenKeywords[upper] { return true } - // 其他常见关键字 + // Other common keywords commonKeywords := []string{ "FROM", "WHERE", "AND", "OR", "NOT", "NULL", "IS", "IN", "EXISTS", "AS", "ON", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "CROSS", @@ -431,14 +433,14 @@ func isKeyword(word string) bool { return false } -// isOperatorChar 判断是否为运算符字符 +// isOperatorChar checks whether a rune is an operator character func isOperatorChar(r rune) bool { return r == '+' || r == '-' || r == '*' || r == '/' || r == '=' || r == '<' || r == '>' || r == '!' || r == '~' || r == '|' || r == '&' || r == '%' || r == '^' || r == '@' || r == '#' || r == '?' || r == ':' } -// hasMultipleValidStatements 检查是否包含多个有效语句 +// hasMultipleValidStatements checks if tokens contain multiple valid statements func hasMultipleValidStatements(tokens []SQLToken) bool { stmtCount := 0 hasNonTrivialContent := false @@ -455,7 +457,7 @@ func hasMultipleValidStatements(tokens []SQLToken) bool { } } - // 最后一段(没有以分号结尾的部分)如果有内容也算一个语句 + // The trailing segment (not terminated by semicolon) counts as a statement if non-empty if hasNonTrivialContent { stmtCount++ } @@ -463,9 +465,9 @@ func hasMultipleValidStatements(tokens []SQLToken) bool { return stmtCount > 1 } -// hasWriteInCTE 检查 WITH 语句是否包含写入操作(data-modifying CTE) +// hasWriteInCTE checks if a WITH statement contains write operations (data-modifying CTE) func hasWriteInCTE(tokens []SQLToken) bool { - // 简单的启发式检测:在 WITH 之后查找 INSERT/UPDATE/DELETE + // Simple heuristic: look for INSERT/UPDATE/DELETE after WITH inCTE := false parenDepth := 0 for i, tok := range tokens { @@ -477,13 +479,13 @@ func hasWriteInCTE(tokens []SQLToken) bool { continue } - // 跟踪括号深度以识别 CTE 边界 + // Track parenthesis depth to identify CTE boundaries switch tok.Value { case "(": parenDepth++ case ")": parenDepth-- - // CTE 定义结束后的 SELECT 是正常的 + // SELECT after CTE definition is allowed if parenDepth == 0 && i+1 < len(tokens) { nextTok := tokens[i+1] switch nextTok.Value { @@ -494,7 +496,7 @@ func hasWriteInCTE(tokens []SQLToken) bool { } } default: - // 在 CTE 体内检查写入关键字 + // Check for write keywords inside CTE body if parenDepth > 0 { switch tok.Value { case "INSERT", "UPDATE", "DELETE": diff --git a/internal/db/registry.go b/internal/db/registry.go index b737e93..50197d6 100644 --- a/internal/db/registry.go +++ b/internal/db/registry.go @@ -1,3 +1,4 @@ +// Package db provides the database driver registry and query execution engine. package db import ( @@ -9,27 +10,27 @@ import ( "github.com/zx06/xsql/internal/errors" ) -// Dialer 用于自定义网络连接(如 SSH tunnel)。 +// Dialer is used for custom network connections (e.g. SSH tunnel). type Dialer interface { DialContext(ctx context.Context, network, addr string) (net.Conn, error) } -// Driver 是数据库驱动的最小抽象。 +// Driver is the minimal abstraction for a database driver. type Driver interface { - // Open 返回 *sql.DB;由具体 driver 实现连接参数解析。 + // Open returns a *sql.DB; connection parameter parsing is handled by each driver. Open(ctx context.Context, opts ConnOptions) (*sql.DB, *errors.XError) } -// ConnOptions 是通用连接参数(由 config/CLI/ENV 合并而来)。 +// ConnOptions holds common connection parameters (merged from config/CLI/ENV). type ConnOptions struct { - DSN string // 原生 DSN(优先级最高) + DSN string // Raw DSN (highest priority) Host string Port int User string Password string Database string - Params map[string]string // 额外参数 - Dialer Dialer // 自定义 dialer(如 SSH tunnel) + Params map[string]string // Extra parameters + Dialer Dialer // Custom dialer (e.g. SSH tunnel) // RegisterCloseHook allows drivers to register cleanup callbacks that should run // when the owning connection is closed or setup fails. RegisterCloseHook func(fn func()) diff --git a/internal/db/schema.go b/internal/db/schema.go index 79d5a41..41626b8 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -8,13 +8,13 @@ import ( "github.com/zx06/xsql/internal/output" ) -// SchemaInfo 数据库 schema 信息 +// SchemaInfo represents database schema information. type SchemaInfo struct { Database string `json:"database" yaml:"database"` Tables []Table `json:"tables" yaml:"tables"` } -// ToSchemaData 实现 output.SchemaFormatter 接口 +// ToSchemaData implements the output.SchemaFormatter interface. func (s *SchemaInfo) ToSchemaData() (string, []output.SchemaTable, bool) { if s == nil || len(s.Tables) == 0 { return "", nil, false @@ -41,58 +41,58 @@ func (s *SchemaInfo) ToSchemaData() (string, []output.SchemaTable, bool) { return s.Database, tables, true } -// Table 表信息 +// Table represents table information. type Table struct { - Schema string `json:"schema" yaml:"schema"` // PostgreSQL schema,MySQL 为数据库名 - Name string `json:"name" yaml:"name"` // 表名 + Schema string `json:"schema" yaml:"schema"` // PostgreSQL schema; database name for MySQL + Name string `json:"name" yaml:"name"` // Table name Comment string `json:"comment,omitempty" yaml:"comment,omitempty"` Columns []Column `json:"columns" yaml:"columns"` Indexes []Index `json:"indexes,omitempty" yaml:"indexes,omitempty"` ForeignKeys []ForeignKey `json:"foreign_keys,omitempty" yaml:"foreign_keys,omitempty"` } -// Column 列信息 +// Column represents column information. type Column struct { Name string `json:"name" yaml:"name"` - Type string `json:"type" yaml:"type"` // 数据类型,如 varchar(255)、bigint - Nullable bool `json:"nullable" yaml:"nullable"` // 是否允许 NULL - Default string `json:"default,omitempty" yaml:"default,omitempty"` // 默认值 - Comment string `json:"comment,omitempty" yaml:"comment,omitempty"` // 列注释 - PrimaryKey bool `json:"primary_key" yaml:"primary_key"` // 是否为主键 + Type string `json:"type" yaml:"type"` // Data type, e.g. varchar(255), bigint + Nullable bool `json:"nullable" yaml:"nullable"` // Whether NULL is allowed + Default string `json:"default,omitempty" yaml:"default,omitempty"` // Default value + Comment string `json:"comment,omitempty" yaml:"comment,omitempty"` // Column comment + PrimaryKey bool `json:"primary_key" yaml:"primary_key"` // Whether this is a primary key } -// Index 索引信息 +// Index represents index information. type Index struct { - Name string `json:"name" yaml:"name"` // 索引名 - Columns []string `json:"columns" yaml:"columns"` // 索引列 - Unique bool `json:"unique" yaml:"unique"` // 是否唯一索引 - Primary bool `json:"primary" yaml:"primary"` // 是否主键索引 + Name string `json:"name" yaml:"name"` // Index name + Columns []string `json:"columns" yaml:"columns"` // Indexed columns + Unique bool `json:"unique" yaml:"unique"` // Whether this is a unique index + Primary bool `json:"primary" yaml:"primary"` // Whether this is a primary key index } -// ForeignKey 外键信息 +// ForeignKey represents foreign key information. type ForeignKey struct { - Name string `json:"name" yaml:"name"` // 外键名 - Columns []string `json:"columns" yaml:"columns"` // 本表列 - ReferencedTable string `json:"referenced_table" yaml:"referenced_table"` // 引用表 - ReferencedColumns []string `json:"referenced_columns" yaml:"referenced_columns"` // 引用列 + Name string `json:"name" yaml:"name"` // Foreign key name + Columns []string `json:"columns" yaml:"columns"` // Local columns + ReferencedTable string `json:"referenced_table" yaml:"referenced_table"` // Referenced table + ReferencedColumns []string `json:"referenced_columns" yaml:"referenced_columns"` // Referenced columns } -// SchemaOptions schema 导出选项 +// SchemaOptions holds options for schema export. type SchemaOptions struct { - TablePattern string // 表名过滤(支持通配符) - IncludeSystem bool // 是否包含系统表 + TablePattern string // Table name filter (supports wildcards) + IncludeSystem bool // Whether to include system tables } -// SchemaDriver schema 导出接口 -// Driver 可选择实现此接口以支持 schema 导出 +// SchemaDriver is the schema export interface. +// A Driver may optionally implement this interface to support schema export. type SchemaDriver interface { Driver - // DumpSchema 导出数据库结构 + // DumpSchema exports the database schema. DumpSchema(ctx context.Context, db *sql.DB, opts SchemaOptions) (*SchemaInfo, *errors.XError) } -// DumpSchema 导出数据库结构 -// 会检查 driver 是否实现了 SchemaDriver 接口 +// DumpSchema exports the database schema. +// It checks whether the driver implements the SchemaDriver interface. func DumpSchema(ctx context.Context, driverName string, db *sql.DB, opts SchemaOptions) (*SchemaInfo, *errors.XError) { d, ok := Get(driverName) if !ok { diff --git a/internal/errors/codes.go b/internal/errors/codes.go index f9224c3..6119faf 100644 --- a/internal/errors/codes.go +++ b/internal/errors/codes.go @@ -1,7 +1,7 @@ package errors -// Code 是稳定错误码(字符串),供 AI/agent 与程序判断。 -// 只增不改、不复用旧含义。 +// Code is a stable error code (string) for AI/agent and programmatic consumption. +// Codes are append-only; existing meanings must not be changed or reused. type Code string const ( diff --git a/internal/errors/error.go b/internal/errors/error.go index 3e55273..9068153 100644 --- a/internal/errors/error.go +++ b/internal/errors/error.go @@ -1,3 +1,4 @@ +// Package errors provides structured error types, error codes, and exit codes for xsql. package errors import ( @@ -5,7 +6,7 @@ import ( "fmt" ) -// XError 是结构化错误,满足 docs/error-contract.md。 +// XError is a structured error that conforms to docs/error-contract.md. type XError struct { Code Code `json:"code" yaml:"code"` Message string `json:"message" yaml:"message"` diff --git a/internal/errors/exitcode.go b/internal/errors/exitcode.go index 8fedf2d..c999b91 100644 --- a/internal/errors/exitcode.go +++ b/internal/errors/exitcode.go @@ -1,24 +1,24 @@ package errors -// ExitCode 是进程退出码(稳定契约);详见 docs/error-contract.md。 +// ExitCode represents process exit codes (stable contract); see docs/error-contract.md. type ExitCode int const ( ExitOK ExitCode = 0 - // 2: 参数/配置错误 + // 2: argument/configuration error ExitConfig ExitCode = 2 - // 3: 连接错误(DB/SSH) + // 3: connection error (DB/SSH) ExitConnect ExitCode = 3 - // 4: 只读策略拦截写入 + // 4: read-only policy blocked a write ExitReadOnly ExitCode = 4 - // 5: DB 执行错误 + // 5: DB execution error ExitDBExec ExitCode = 5 - // 10: 内部错误 + // 10: internal error ExitInternal ExitCode = 10 ) diff --git a/internal/log/log.go b/internal/log/log.go index a191c01..54ff9bc 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -1,3 +1,4 @@ +// Package log provides logging utilities for xsql. package log import ( @@ -5,8 +6,8 @@ import ( "log/slog" ) -// New 返回写入到 w 的 slog.Logger(默认 level=INFO)。 -// 注意:stdout=数据,日志应始终写 stderr(由调用方传入)。 +// New returns a slog.Logger that writes to w (default level=INFO). +// Note: stdout is for data; logs should always be written to stderr (passed by caller). func New(w io.Writer) *slog.Logger { h := slog.NewTextHandler(w, &slog.HandlerOptions{Level: slog.LevelInfo}) return slog.New(h) diff --git a/internal/output/writer.go b/internal/output/writer.go index 0093f4c..c2b88ab 100644 --- a/internal/output/writer.go +++ b/internal/output/writer.go @@ -61,17 +61,17 @@ func (w Writer) write(format Format, env Envelope) error { } } -// TableFormatter 接口:支持表格输出的数据结构实现此接口 +// TableFormatter is the interface for data structures that support table output. type TableFormatter interface { ToTableData() (columns []string, rows []map[string]any, ok bool) } -// SchemaFormatter 接口:支持 schema 输出的结构实现此接口 +// SchemaFormatter is the interface for data structures that support schema output. type SchemaFormatter interface { ToSchemaData() (database string, tables []SchemaTable, ok bool) } -// SchemaTable schema 表格输出的简化结构 +// SchemaTable is a simplified structure for schema table output. type SchemaTable struct { Schema string Name string @@ -79,51 +79,51 @@ type SchemaTable struct { Columns []SchemaColumn } -// ProfileListFormatter 接口:支持 profile list 输出的结构实现此接口 +// ProfileListFormatter is the interface for data structures that support profile list output. type ProfileListFormatter interface { ToProfileListData() (configPath string, profiles []profileListItem, ok bool) } func writeTable(out io.Writer, env Envelope) error { if !env.OK { - // 错误输出:简洁显示错误信息 + // Error output: display error message concisely if env.Error != nil { _, _ = fmt.Fprintf(out, "Error [%s]: %s\n", env.Error.Code, env.Error.Message) } return nil } - // 优先检查是否实现了 TableFormatter 接口(无 JSON 编解码) + // First check if the data implements the TableFormatter interface (no JSON encode/decode) if formatter, ok := env.Data.(TableFormatter); ok { if cols, rows, ok := formatter.ToTableData(); ok { return writeQueryResultTable(out, cols, rows) } } - // 优先检查是否实现了 ProfileListFormatter 接口 + // Check if the data implements the ProfileListFormatter interface if formatter, ok := env.Data.(ProfileListFormatter); ok { if cfgPath, profiles, ok := formatter.ToProfileListData(); ok { return writeProfileListTable(out, cfgPath, profiles) } } - // 检查是否实现了 SchemaFormatter 接口 + // Check if the data implements the SchemaFormatter interface if formatter, ok := env.Data.(SchemaFormatter); ok { if database, tables, ok := formatter.ToSchemaData(); ok { return writeSchemaTable(out, database, tables) } } - // 回退:使用类型断言从 map[string]any 提取 + // Fallback: use type assertion to extract from map[string]any if m, ok := env.Data.(map[string]any); ok { - // 尝试提取查询结果 + // Try to extract query result if cols, ok := extractStringSlice(m["columns"]); ok { if rows, ok := extractMapSlice(m["rows"]); ok { return writeQueryResultTable(out, cols, rows) } } - // 尝试提取 profile list + // Try to extract profile list if profilesRaw, hasProfiles := m["profiles"]; hasProfiles { if profileList, ok := tryAsProfileList(profilesRaw); ok { cfgPath, _ := m["config_path"].(string) @@ -131,7 +131,7 @@ func writeTable(out io.Writer, env Envelope) error { } } - // 默认:直接输出 key-value(按 key 排序以保证稳定性) + // Default: output key-value pairs (sorted by key for stability) tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0) for _, k := range sortedMapKeys(m) { _, _ = fmt.Fprintf(tw, "%s\t%v\n", k, m[k]) @@ -139,12 +139,12 @@ func writeTable(out io.Writer, env Envelope) error { return tw.Flush() } - // 最后尝试反射提取(无 JSON 编解码) + // Last resort: try reflection-based extraction (no JSON encode/decode) if result, ok := tryAsQueryResultReflect(env.Data); ok { return writeQueryResultTable(out, result.columns, result.rows) } - // 默认:直接输出数据的 key-value + // Default: output data as key-value pairs tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0) if env.Data != nil { if m, ok := env.Data.(map[string]any); ok { @@ -163,20 +163,20 @@ func writeTable(out io.Writer, env Envelope) error { return tw.Flush() } -// writeProfileListTable 输出 profile list 表格 +// writeProfileListTable writes the profile list as a table. func writeProfileListTable(out io.Writer, cfgPath string, profiles []profileListItem) error { tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0) - // 先输出 config_path + // Output config_path first if cfgPath != "" { _, _ = fmt.Fprintf(tw, "Config: %s\n\n", cfgPath) } - // 输出 profiles 表格 + // Output profiles table _, _ = fmt.Fprintln(tw, "NAME\tDESCRIPTION\tDB\tMODE") _, _ = fmt.Fprintln(tw, "----\t-----------\t--\t----") for _, p := range profiles { _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", p.Name, p.Description, p.DB, p.Mode) } - // 使用正确的单数/复数形式 + // Use correct singular/plural form suffix := "profiles" if len(profiles) == 1 { suffix = "profile" @@ -185,16 +185,16 @@ func writeProfileListTable(out io.Writer, cfgPath string, profiles []profileList return tw.Flush() } -// extractStringSlice 从 any 提取 []string +// extractStringSlice extracts a []string from any. func extractStringSlice(v any) ([]string, bool) { if v == nil { return nil, false } - // 已经是 []string + // Already a []string if ss, ok := v.([]string); ok { return ss, true } - // 尝试 []any + // Try []any if arr, ok := v.([]any); ok { result := make([]string, len(arr)) for i, item := range arr { @@ -209,7 +209,7 @@ func extractStringSlice(v any) ([]string, bool) { return nil, false } -// extractMapSlice 从 any 提取 []map[string]any +// extractMapSlice extracts a []map[string]any from any. func extractMapSlice(v any) ([]map[string]any, bool) { if v == nil { return nil, false @@ -239,12 +239,12 @@ type profileListItem struct { } func tryAsProfileList(data any) ([]profileListItem, bool) { - // 处理 []profileListItem + // Handle []profileListItem if arr, ok := data.([]profileListItem); ok { return arr, len(arr) > 0 } - // 处理 []map[string]any + // Handle []map[string]any if arr, ok := data.([]map[string]any); ok { result := make([]profileListItem, 0, len(arr)) for _, m := range arr { @@ -269,22 +269,22 @@ func tryAsProfileList(data any) ([]profileListItem, bool) { return result, len(result) > 0 } - // 使用反射处理任意结构体切片(如 cmd/xsql/profile.go 中的 []profileInfo) + // Use reflection to handle arbitrary struct slices (e.g., []profileInfo in cmd/xsql/profile.go) v := reflect.ValueOf(data) if v.IsValid() && v.Kind() == reflect.Slice { result := make([]profileListItem, 0, v.Len()) for i := 0; i < v.Len(); i++ { elem := v.Index(i) - // 解引用指针 + // Dereference pointer if elem.Kind() == reflect.Ptr { elem = elem.Elem() } - // 只处理结构体 + // Only handle structs if elem.Kind() != reflect.Struct { return nil, false } p := profileListItem{} - // 读取字段 + // Read fields if f := elem.FieldByName("Name"); f.IsValid() && f.Kind() == reflect.String { p.Name = f.String() } @@ -305,7 +305,7 @@ func tryAsProfileList(data any) ([]profileListItem, bool) { return result, len(result) > 0 } - // 处理 []any + // Handle []any arr, ok := data.([]any) if !ok { return nil, false @@ -343,7 +343,7 @@ type queryResultLike struct { rows []map[string]any } -// tryAsQueryResultReflect 使用反射检查字段 Columns 和 Rows(无 JSON 编解码) +// tryAsQueryResultReflect uses reflection to check for Columns and Rows fields (no JSON encode/decode). func tryAsQueryResultReflect(data any) (*queryResultLike, bool) { if data == nil { return nil, false @@ -357,7 +357,7 @@ func tryAsQueryResultReflect(data any) (*queryResultLike, bool) { return nil, false } - // 查找 Columns 和 Rows 字段 + // Find the Columns and Rows fields var colsValue, rowsValue reflect.Value t := v.Type() for i := 0; i < v.NumField(); i++ { @@ -374,7 +374,7 @@ func tryAsQueryResultReflect(data any) (*queryResultLike, bool) { return nil, false } - // 提取 columns + // Extract columns if colsValue.Kind() != reflect.Slice { return nil, false } @@ -394,7 +394,7 @@ func tryAsQueryResultReflect(data any) (*queryResultLike, bool) { } } - // 提取 rows + // Extract rows if rowsValue.Kind() != reflect.Slice { return nil, false } @@ -429,17 +429,17 @@ func tryAsQueryResultReflect(data any) (*queryResultLike, bool) { func writeQueryResultTable(out io.Writer, cols []string, rows []map[string]any) error { tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0) - // 表头 + // Header _, _ = fmt.Fprintln(tw, strings.Join(cols, "\t")) - // 分隔线 + // Separator line dashes := make([]string, len(cols)) for i, c := range cols { dashes[i] = strings.Repeat("-", len(c)) } _, _ = fmt.Fprintln(tw, strings.Join(dashes, "\t")) - // 数据行 + // Data rows for _, row := range rows { vals := make([]string, len(cols)) for i, c := range cols { @@ -448,7 +448,7 @@ func writeQueryResultTable(out io.Writer, cols []string, rows []map[string]any) _, _ = fmt.Fprintln(tw, strings.Join(vals, "\t")) } - // 行数统计 + // Row count _, _ = fmt.Fprintf(tw, "\n(%d rows)\n", len(rows)) return tw.Flush() @@ -462,7 +462,7 @@ func formatCellValue(v any, nullValue string) string { case string: return val case float64: - // JSON 数字都是 float64 + // JSON numbers are always float64 if val == float64(int64(val)) { return fmt.Sprintf("%d", int64(val)) } @@ -477,24 +477,24 @@ func writeCSV(out io.Writer, env Envelope) error { defer cw.Flush() if !env.OK { - // 错误输出 + // Error output if env.Error != nil { _ = cw.Write([]string{"error", string(env.Error.Code), env.Error.Message}) } return cw.Error() } - // 尝试解析为查询结果(优先使用接口,然后反射) + // Try to parse as query result (prefer interface, then reflection) var cols []string var rows []map[string]any var dataOK bool - // 先尝试 TableFormatter 接口 + // First try the TableFormatter interface if formatter, isFormatter := env.Data.(TableFormatter); isFormatter { cols, rows, dataOK = formatter.ToTableData() } - // 回退:使用反射 + // Fallback: use reflection if !dataOK { if result, ok2 := tryAsQueryResultReflect(env.Data); ok2 { cols = result.columns @@ -503,7 +503,7 @@ func writeCSV(out io.Writer, env Envelope) error { } } - // 回退:使用 map 提取 + // Fallback: extract from map if !dataOK { if m, ok2 := env.Data.(map[string]any); ok2 { var ok3 bool @@ -515,9 +515,9 @@ func writeCSV(out io.Writer, env Envelope) error { } if dataOK { - // 输出表头 + // Write header _ = cw.Write(cols) - // 输出数据行 + // Write data rows for _, row := range rows { vals := make([]string, len(cols)) for i, c := range cols { @@ -528,7 +528,7 @@ func writeCSV(out io.Writer, env Envelope) error { return cw.Error() } - // 默认:输出为 key,value 格式 + // Default: output as key,value format if m, ok := env.Data.(map[string]any); ok { for _, k := range sortedMapKeys(m) { _ = cw.Write([]string{k, fmt.Sprintf("%v", m[k])}) @@ -546,20 +546,20 @@ func sortedMapKeys(m map[string]any) []string { return keys } -// writeSchemaTable 输出 schema 表格 +// writeSchemaTable writes the schema as a table. func writeSchemaTable(out io.Writer, database string, tables []SchemaTable) error { - // 输出数据库名 + // Output database name if database != "" { _, _ = fmt.Fprintf(out, "Database: %s\n\n", database) } - // 遍历每个表 + // Iterate over each table for i, table := range tables { if i > 0 { - _, _ = fmt.Fprintln(out) // 表之间空一行 + _, _ = fmt.Fprintln(out) // Blank line between tables } - // 表头 + // Table header header := table.Name if table.Schema != "" && table.Schema != database { header = table.Schema + "." + table.Name @@ -569,7 +569,7 @@ func writeSchemaTable(out io.Writer, database string, tables []SchemaTable) erro } _, _ = fmt.Fprintf(out, "Table: %s\n", header) - // 列信息 + // Column information if len(table.Columns) > 0 { _, _ = fmt.Fprintln(out, " Columns:") tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0) @@ -595,7 +595,7 @@ func writeSchemaTable(out io.Writer, database string, tables []SchemaTable) erro } } - // 表数量统计 + // Table count suffix := "tables" if len(tables) == 1 { suffix = "table" @@ -604,7 +604,7 @@ func writeSchemaTable(out io.Writer, database string, tables []SchemaTable) erro return nil } -// SchemaColumn schema 列输出的简化结构 +// SchemaColumn is a simplified structure for schema column output. type SchemaColumn struct { Name string Type string diff --git a/internal/secret/keyring.go b/internal/secret/keyring.go index fef22bd..05b9f02 100644 --- a/internal/secret/keyring.go +++ b/internal/secret/keyring.go @@ -1,19 +1,20 @@ +// Package secret handles credential resolution including OS keyring integration. package secret -// KeyringAPI 是对 OS keyring 的最小抽象,便于测试与跨平台。 -// service 对应 keyring 的 service name,account 对应 user/account。 +// KeyringAPI is a minimal abstraction over the OS keyring for testability and cross-platform support. +// service corresponds to the keyring service name; account corresponds to the user/account. type KeyringAPI interface { Get(service, account string) (string, error) Set(service, account, value string) error Delete(service, account string) error } -// 默认 keyring 实现(使用 zalando/go-keyring) -// 本文件仅定义接口;实现见 keyring_*.go(按平台编译)。 +// defaultKeyring returns the default keyring implementation (using zalando/go-keyring). +// This file only defines the interface; implementations are in keyring_*.go (platform-specific builds). func defaultKeyring() KeyringAPI { return &osKeyring{} } type osKeyring struct{} -// Get/Set/Delete 见 keyring_default.go。 +// Get/Set/Delete are implemented in keyring_default.go. diff --git a/internal/secret/keyring_windows.go b/internal/secret/keyring_windows.go index cf81afd..8629de8 100644 --- a/internal/secret/keyring_windows.go +++ b/internal/secret/keyring_windows.go @@ -13,7 +13,7 @@ func (o *osKeyring) Get(service, account string) (string, error) { if err != nil { return "", err } - // Windows cmdkey 在字符间插入 null 字节(UTF-16 遗留问题) + // Windows cmdkey inserts null bytes between characters (UTF-16 legacy issue). val = strings.ReplaceAll(val, "\x00", "") return val, nil } diff --git a/internal/secret/resolve.go b/internal/secret/resolve.go index f7b7555..7731da1 100644 --- a/internal/secret/resolve.go +++ b/internal/secret/resolve.go @@ -8,17 +8,17 @@ import ( const keyringPrefix = "keyring:" -// Options 控制 secret 解析行为。 +// Options controls secret resolution behavior. type Options struct { - AllowPlaintext bool // 是否允许明文(默认 false) - Keyring KeyringAPI // 可注入的 keyring 实现(nil 则用默认) + AllowPlaintext bool // whether to allow plaintext secrets (default false) + Keyring KeyringAPI // injectable keyring implementation (nil uses default) } const defaultService = "xsql" -// parseKeyringRef 解析 keyring 引用。 -// 整个引用作为 account,service 固定为 "xsql"。 -// 例如 "prod/db_password" → service="xsql", account="prod/db_password" +// parseKeyringRef parses a keyring reference. +// The entire reference is used as the account; service is fixed to "xsql". +// For example, "prod/db_password" → service="xsql", account="prod/db_password". func parseKeyringRef(ref string) (service, account string, err *errors.XError) { if ref == "" { return "", "", errors.New(errors.CodeCfgInvalid, @@ -28,12 +28,12 @@ func parseKeyringRef(ref string) (service, account string, err *errors.XError) { return defaultService, ref, nil } -// Resolve 解析 secret 值,遵循 docs/config.md 的顺序: -// 1. keyring:/ → 从 keyring 读取 -// 2. 否则若为明文且允许明文 → 直接返回 -// 3. 否则报错 +// Resolve resolves a secret value following the order defined in docs/config.md: +// 1. keyring:/ → read from the keyring +// 2. otherwise, if plaintext and plaintext is allowed → return as-is +// 3. otherwise → return an error // -// 注意:TTY 交互输入本阶段不实现(留给 cmd 层处理)。 +// Note: TTY interactive input is not implemented at this layer (left to the cmd layer). func Resolve(raw string, opts Options) (string, *errors.XError) { if strings.HasPrefix(raw, keyringPrefix) { ref := strings.TrimPrefix(raw, keyringPrefix) @@ -52,14 +52,14 @@ func Resolve(raw string, opts Options) (string, *errors.XError) { } return val, nil } - // 明文 + // Plaintext if opts.AllowPlaintext { return raw, nil } return "", errors.New(errors.CodeCfgInvalid, "plaintext secret not allowed; use keyring: reference or enable --allow-plaintext", nil) } -// IsKeyringRef 判断值是否为 keyring 引用。 +// IsKeyringRef reports whether s is a keyring reference. func IsKeyringRef(s string) bool { return strings.HasPrefix(s, keyringPrefix) } diff --git a/internal/ssh/client.go b/internal/ssh/client.go index 66820dc..ae8052e 100644 --- a/internal/ssh/client.go +++ b/internal/ssh/client.go @@ -1,3 +1,4 @@ +// Package ssh provides SSH tunnel connectivity for database drivers. package ssh import ( @@ -14,12 +15,12 @@ import ( "github.com/zx06/xsql/internal/errors" ) -// Client 包装 ssh.Client,提供 DialContext 供 driver 使用。 +// Client wraps ssh.Client and provides DialContext for use by database drivers. type Client struct { client *ssh.Client } -// Connect 建立 SSH 连接。 +// Connect establishes an SSH connection. func Connect(ctx context.Context, opts Options) (*Client, *errors.XError) { if opts.Host == "" { return nil, errors.New(errors.CodeCfgInvalid, "ssh host is required", nil) @@ -61,12 +62,12 @@ func Connect(ctx context.Context, opts Options) (*Client, *errors.XError) { return &Client{client: client}, nil } -// DialContext 通过 SSH 通道建立到 target 的连接。 +// DialContext establishes a connection to the target through the SSH tunnel. func (c *Client) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { return c.client.Dial(network, addr) } -// Close 关闭 SSH 连接。 +// Close closes the SSH connection. func (c *Client) Close() error { if c.client != nil { return c.client.Close() @@ -77,7 +78,7 @@ func (c *Client) Close() error { func buildAuthMethods(opts Options) ([]ssh.AuthMethod, *errors.XError) { var methods []ssh.AuthMethod - // 私钥认证 + // Private key authentication if opts.IdentityFile != "" { keyPath := expandPath(opts.IdentityFile) keyData, err := os.ReadFile(keyPath) @@ -96,7 +97,7 @@ func buildAuthMethods(opts Options) ([]ssh.AuthMethod, *errors.XError) { methods = append(methods, ssh.PublicKeys(signer)) } - // 尝试默认私钥路径 + // Try default private key paths if len(methods) == 0 { for _, name := range []string{"id_ed25519", "id_rsa", "id_ecdsa"} { keyPath := expandPath("~/.ssh/" + name) diff --git a/internal/ssh/options.go b/internal/ssh/options.go index 1567e4a..3482f34 100644 --- a/internal/ssh/options.go +++ b/internal/ssh/options.go @@ -1,15 +1,15 @@ package ssh -// Options 包含 SSH 连接所需参数。 +// Options contains the parameters required for an SSH connection. type Options struct { Host string Port int User string - IdentityFile string // 私钥路径 - Passphrase string // 私钥 passphrase(若有) - KnownHostsFile string // 默认 ~/.ssh/known_hosts + IdentityFile string // path to the private key + Passphrase string // private key passphrase (if any) + KnownHostsFile string // defaults to ~/.ssh/known_hosts - // SkipKnownHostsCheck 跳过 known_hosts 校验(极不推荐!) + // SkipKnownHostsCheck disables known_hosts verification (strongly discouraged!). SkipKnownHostsCheck bool }