Skip to content
Open
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
1 change: 1 addition & 0 deletions cmd/tools/gen-error-codes/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
// so the registry is populated before we read it.
_ "github.com/sqlrush/opendbx/internal/app/skills" // spec-2.1 D-6: SKILL.* codes
_ "github.com/sqlrush/opendbx/internal/app/skills/invoke" // spec-2.3 D-6: SKILL.* invoke codes
_ "github.com/sqlrush/opendbx/internal/app/tools/dbquery" // spec-2.3a D-5: DB.QUERY_INPUT_INVALID
_ "github.com/sqlrush/opendbx/internal/entrypoints"
"github.com/sqlrush/opendbx/internal/platform/errcode"
_ "github.com/sqlrush/opendbx/internal/platform/logger"
Expand Down
20 changes: 20 additions & 0 deletions internal/app/tools/dbquery/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright 2026 opendbx contributors. See LICENSE.
//
// Author: sqlrush

// Package dbquery provides the db_query diagnose ToolExecutor (spec-2.3a):
// a read-only SQL entry the LLM can call against the active PostgreSQL
// connection. It is the tool substrate the bundled DB diagnostic skills
// (spec-2.3b) drive via `allowed-tools: db_query`.
//
// Read-only is enforced SERVER-side (the db.QueryConn driver runs every
// statement inside a READ ONLY transaction); this package never inspects
// SQL text. Failures are two-track per the spec-1.21 ToolExecutor
// contract: ctx cancel/deadline → fatal Go error; semantic failures
// (bad input, query error, read-only violation) → recoverable ToolOutput
// with IsError so the model self-corrects.
//
// Layer hygiene: this package imports db / diagnose / llm / errcode and
// stdlib only. The connection factory is injected as a closure by
// bootstrap (openFn) so dbquery imports neither config nor bootstrap.
package dbquery
37 changes: 37 additions & 0 deletions internal/app/tools/dbquery/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright 2026 opendbx contributors. See LICENSE.
//
// Author: sqlrush

// File errors.go — db_query input-validation errcode (spec-2.3a D-5).
// Chinese message/hint to match the existing DB.* convention in
// domain/db/errors.go (spec-2.3a Q11 user decision — same DB package
// family, do not mix languages within it).
//
// This is the only NEW code owned by this package; the read/connect/
// timeout/readonly codes all live in domain/db (registered by spec-1.18
// and spec-2.3a D-5) and are reused via the sanitized classify path.

package dbquery

import "github.com/sqlrush/opendbx/internal/platform/errcode"

//nolint:gochecknoglobals // spec-0.6 contract: errcode sentinels are package-level.
var (
// ErrQueryInputInvalid — the db_query tool input is malformed (missing /
// non-string / blank "sql"). Feedback class: the composed Content is
// written into a recoverable ToolResult so the model self-corrects.
ErrQueryInputInvalid = errcode.Register(
"DB.QUERY_INPUT_INVALID",
"db_query 入参无效",
`用 {"sql": "<一条只读语句>"} 调用 db_query`,
)
)

// inputInvalidContent composes the recoverable ToolOutput.Content for a
// malformed db_query input (spec-2.3a D-5). detail is the fixed-variant
// reason (missing/non-string skill field etc.). Mirrors the spec-2.3
// invoke template shape: "[CODE] message: detail. Hint: hint."
func inputInvalidContent(detail string) string {
return "[" + ErrQueryInputInvalid.Code() + "] " + ErrQueryInputInvalid.Message() +
": " + detail + ". Hint: " + ErrQueryInputInvalid.Hint() + "."
}
38 changes: 38 additions & 0 deletions internal/app/tools/dbquery/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright 2026 opendbx contributors. See LICENSE.
//
// Author: sqlrush

package dbquery

import (
"strings"
"testing"
)

// TestErrQueryInputInvalid_Registered — the new code carries the Rule 7
// triple with the SKILL-less DB. prefix and Chinese text (spec-2.3a Q11).
func TestErrQueryInputInvalid_Registered(t *testing.T) {
t.Parallel()
if ErrQueryInputInvalid.Code() != "DB.QUERY_INPUT_INVALID" {
t.Errorf("code = %q", ErrQueryInputInvalid.Code())
}
if ErrQueryInputInvalid.Message() == "" || ErrQueryInputInvalid.Hint() == "" {
t.Error("missing message/hint (Rule 7 triple)")
}
}

// TestInputInvalidContent — template carries code, message, the detail
// variant, and the call-shape hint (spec-2.3a D-5).
func TestInputInvalidContent(t *testing.T) {
t.Parallel()
got := inputInvalidContent("缺少或非字符串 sql 字段")
for _, want := range []string{
"DB.QUERY_INPUT_INVALID",
"缺少或非字符串 sql 字段",
`{"sql":`,
} {
if !strings.Contains(got, want) {
t.Errorf("inputInvalidContent missing %q in %q", want, got)
}
}
}
116 changes: 116 additions & 0 deletions internal/app/tools/dbquery/render.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright 2026 opendbx contributors. See LICENSE.
//
// Author: sqlrush

// File render.go — aligned text-table rendering of a db.QueryResult for
// the tool result content (spec-2.3a D-3 / Q5).
//
// This is WIRE text for the LLM (and /report), NOT terminal UI — column
// alignment uses rune counts, not East-Asian display width (that is the
// § 3.9 UI table concern, explicitly out of scope here, ❌-9). The total
// output is capped at 16KiB with a UTF-8-safe cut so a multibyte rune at
// the boundary is never split (spec-2.3a codex MED-1; the 64KiB→16KiB cap
// also removes the conflict with spec-2.3 R-7's body warning).

package dbquery

import (
"fmt"
"strings"
"unicode/utf8"

"github.com/sqlrush/opendbx/internal/domain/db"
)

// maxOutputBytes caps the rendered table (spec-2.3a Q6, user decision
// 64KiB→16KiB; ≈4K tokens, within the 规则 19 context budget).
const maxOutputBytes = 16 << 10

// truncSuffix is appended when the rendered table exceeds maxOutputBytes.
const truncSuffix = "\n…(output truncated at 16KiB)"

// renderTable renders the result as an aligned text table plus a footer.
// Empty column set → "(0 rows)".
func renderTable(res db.QueryResult) string {
if len(res.Columns) == 0 {
return "(0 rows)"
}

widths := make([]int, len(res.Columns))
for i, c := range res.Columns {
widths[i] = runeLen(c)
}
for _, row := range res.Rows {
for i, cell := range row {
if w := runeLen(cell); i < len(widths) && w > widths[i] {
widths[i] = w
}
}
}

var b strings.Builder
writeRow(&b, res.Columns, widths)
seps := make([]string, len(widths))
for i, w := range widths {
seps[i] = strings.Repeat("-", w)
}
writeRow(&b, seps, widths)
for _, row := range res.Rows {
writeRow(&b, row, widths)
}
b.WriteString(footer(res))

return capUTF8(b.String(), maxOutputBytes)
}

// writeRow writes one left-aligned row. The trailing column is not padded
// (no dangling spaces — keeps goldens tight). Columns are separated by two
// spaces.
func writeRow(b *strings.Builder, cells []string, widths []int) {
for i, cell := range cells {
if i > 0 {
b.WriteString(" ")
}
b.WriteString(cell)
if i < len(cells)-1 {
if pad := widths[i] - runeLen(cell); pad > 0 {
b.WriteString(strings.Repeat(" ", pad))
}
}
}
b.WriteByte('\n')
}

// footer is the row-count line with truncation annotations (spec-2.3a Q5).
func footer(res db.QueryResult) string {
n := len(res.Rows)
var s string
if res.RowsTruncated {
s = fmt.Sprintf("(first %d rows; result truncated)", n)
} else {
s = fmt.Sprintf("(%d rows)", n)
}
if res.CellsTruncated {
s += " (some cells truncated)"
}
return s
}

// capUTF8 truncates s to at most max bytes, never splitting a rune, and
// appends a visible note when it cuts (spec-2.3a codex MED-1).
func capUTF8(s string, max int) string {
if len(s) <= max {
return s
}
budget := max - len(truncSuffix)
if budget < 0 {
budget = 0
}
cut := s[:budget]
for len(cut) > 0 && !utf8.ValidString(cut) {
cut = cut[:len(cut)-1]
}
return cut + truncSuffix
}

func runeLen(s string) int { return utf8.RuneCountInString(s) }
89 changes: 89 additions & 0 deletions internal/app/tools/dbquery/render_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright 2026 opendbx contributors. See LICENSE.
//
// Author: sqlrush

package dbquery

import (
"strings"
"testing"

"github.com/sqlrush/opendbx/internal/domain/db"
)

// TestRenderTable_Aligned — header + separator + rows + footer, columns
// left-aligned to max width, two-space gap, no trailing pad (spec-2.3a Q5).
func TestRenderTable_Aligned(t *testing.T) {
t.Parallel()
res := db.QueryResult{
Columns: []string{"id", "name"},
Rows: [][]string{{"1", "alice"}, {"20", "bo"}},
}
got := renderTable(res)
want := "id name\n" +
"-- -----\n" +
"1 alice\n" +
"20 bo\n" +
"(2 rows)"
if got != want {
t.Errorf("renderTable =\n%q\nwant\n%q", got, want)
}
}

// TestRenderTable_Empty — zero columns → "(0 rows)".
func TestRenderTable_Empty(t *testing.T) {
t.Parallel()
if got := renderTable(db.QueryResult{}); got != "(0 rows)" {
t.Errorf("empty = %q; want (0 rows)", got)
}
}

// TestRenderTable_TruncationFooters — rows/cells truncation annotations.
func TestRenderTable_TruncationFooters(t *testing.T) {
t.Parallel()
res := db.QueryResult{
Columns: []string{"c"},
Rows: [][]string{{"x"}},
RowsTruncated: true,
CellsTruncated: true,
}
got := renderTable(res)
if !strings.Contains(got, "(first 1 rows; result truncated) (some cells truncated)") {
t.Errorf("footer missing truncation notes:\n%s", got)
}
}

// TestCapUTF8_RuneSafe — a 16KiB cut never splits a multibyte rune
// (spec-2.3a codex MED-1; the 1.20.2 CJK mojibake class).
func TestCapUTF8_RuneSafe(t *testing.T) {
t.Parallel()
// Build a string well over the cap of all CJK runes (3 bytes each).
big := strings.Repeat("世", maxOutputBytes) // 3*maxOutputBytes bytes
got := capUTF8(big, maxOutputBytes)
if len(got) > maxOutputBytes {
t.Errorf("capUTF8 len %d > max %d", len(got), maxOutputBytes)
}
if !strings.HasSuffix(got, truncSuffix) {
t.Error("missing truncation suffix")
}
// The body (minus suffix) must be valid UTF-8 (no split rune).
body := strings.TrimSuffix(got, truncSuffix)
if strings.ContainsRune(body, '�') {
t.Error("replacement char — a rune was split")
}
for _, r := range body {
if r != '世' {
t.Errorf("unexpected rune %q — boundary split", r)
break
}
}
}

// TestCapUTF8_UnderCap — short output passes through unchanged.
func TestCapUTF8_UnderCap(t *testing.T) {
t.Parallel()
s := "small table\n(1 rows)"
if got := capUTF8(s, maxOutputBytes); got != s {
t.Errorf("under-cap mutated: %q", got)
}
}
Loading
Loading