Files
Enterprise-Knowledge-Base/internal/agent/tools/data_analysis_test.go
T
wizardchen 11811f9bf8 fix(agent): load all sheets from Excel files in data analysis (#1007)
DuckDB's st_read (spatial) only reads the first layer/sheet of a .xlsx
workbook, so every sheet beyond Sheet1 was silently dropped from the
DuckDB table the Data Analysis tool builds. Users trying to analyse
multi-sheet workbooks could only see the first sheet.

Switch to DuckDB's dedicated 'excel' extension (read_xlsx) for the
actual data load, enumerate sheets via the spatial extension's
st_read_meta, and UNION ALL BY NAME the rows of every sheet into one
table. A synthetic __sheet_name column records the source so the LLM
can still filter/aggregate per sheet; schema drift between sheets is
tolerated via UNION BY NAME. If enumeration fails (older DuckDB, local
filesystem errors, …) we fall back to reading the first sheet so the
tool stays usable.

- Install & LOAD the 'excel' extension alongside 'spatial' both at
  startup (internal/container) and in the offline prefetch binary
  (cmd/download/duckdb).
- Harden sheet/path handling against single quotes.
- Update the tool description so the agent knows about __sheet_name.
- Add unit tests for the CREATE TABLE SQL builder covering 0 / 1 / N
  sheets and quote escaping.

Refs: https://github.com/Tencent/WeKnora/issues/1007
2026-04-24 19:57:56 +08:00

91 lines
3.2 KiB
Go

package tools
import (
"strings"
"testing"
)
func TestBuildExcelCreateTableSQL_NoSheets(t *testing.T) {
got := buildExcelCreateTableSQL("tbl", "/tmp/data.xlsx", nil)
want := `CREATE TABLE "tbl" AS SELECT * FROM read_xlsx('/tmp/data.xlsx')`
if got != want {
t.Fatalf("mismatch.\n got: %s\nwant: %s", got, want)
}
}
func TestBuildExcelCreateTableSQL_SingleSheetTagsSource(t *testing.T) {
got := buildExcelCreateTableSQL("tbl", "/tmp/data.xlsx", []string{"Sheet1"})
// Must use read_xlsx (excel extension) with explicit sheet param.
if !strings.Contains(got, "FROM read_xlsx('/tmp/data.xlsx', sheet = 'Sheet1')") {
t.Fatalf("expected read_xlsx with sheet param, got: %s", got)
}
// Must tag the source sheet name via the synthetic column so downstream
// SQL behaves consistently between single- and multi-sheet workbooks.
if !strings.Contains(got, "'Sheet1' AS "+excelSheetNameColumn) {
t.Fatalf("expected sheet-name column, got: %s", got)
}
}
func TestBuildExcelCreateTableSQL_MultiSheetUsesUnionAllByName(t *testing.T) {
got := buildExcelCreateTableSQL("tbl", "/tmp/data.xlsx", []string{"Sheet1", "Sheet2", "报表"})
// Each sheet must appear as a SELECT reading that specific sheet, and
// the __sheet_name column must carry its name for per-sheet filtering.
for _, sheet := range []string{"Sheet1", "Sheet2", "报表"} {
needleRead := "FROM read_xlsx('/tmp/data.xlsx', sheet = '" + sheet + "')"
needleTag := "'" + sheet + "' AS " + excelSheetNameColumn
if !strings.Contains(got, needleRead) {
t.Fatalf("missing read_xlsx for sheet %q in:\n%s", sheet, got)
}
if !strings.Contains(got, needleTag) {
t.Fatalf("missing __sheet_name tag for sheet %q in:\n%s", sheet, got)
}
}
// Must combine with UNION ALL BY NAME so schema drift between sheets is
// tolerated.
if !strings.Contains(got, "UNION ALL BY NAME") {
t.Fatalf("expected UNION ALL BY NAME in multi-sheet SQL, got:\n%s", got)
}
// Exactly N-1 UNIONs for N sheets.
if strings.Count(got, "UNION ALL BY NAME") != 2 {
t.Fatalf("expected 2 UNION ALL BY NAME separators, got %d in:\n%s",
strings.Count(got, "UNION ALL BY NAME"), got)
}
}
func TestBuildExcelCreateTableSQL_EscapesSingleQuotes(t *testing.T) {
// Sheet name and file path both contain single quotes, which must be
// doubled to produce a valid SQL literal.
sheets := []string{"Jo's data"}
got := buildExcelCreateTableSQL("tbl", "/tmp/O'Brien/data.xlsx", sheets)
if !strings.Contains(got, "sheet = 'Jo''s data'") {
t.Fatalf("sheet name was not escaped, got:\n%s", got)
}
if !strings.Contains(got, "read_xlsx('/tmp/O''Brien/data.xlsx'") {
t.Fatalf("file path was not escaped, got:\n%s", got)
}
if !strings.Contains(got, "'Jo''s data' AS "+excelSheetNameColumn) {
t.Fatalf("sheet-name literal was not escaped, got:\n%s", got)
}
}
func TestSqlSingleQuoteEscape(t *testing.T) {
cases := map[string]string{
"": "",
"no_quote": "no_quote",
"a'b": "a''b",
"''": "''''",
"mix'ed'quote": "mix''ed''quote",
"中文 with 'quote": "中文 with ''quote",
}
for in, want := range cases {
if got := sqlSingleQuoteEscape(in); got != want {
t.Errorf("sqlSingleQuoteEscape(%q) = %q, want %q", in, got, want)
}
}
}