From 2400823ca4dff2fbe450adc372cfd5e42d455167 Mon Sep 17 00:00:00 2001 From: AutumnNazi <104422820+AutumnNazi@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:44:18 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(sqlite):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=A1=A8=E6=A6=82=E8=A7=88=E8=A1=8C=E6=95=B0=E4=B8=8E?= =?UTF-8?q?=E5=A4=A7=E5=B0=8F=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 通过 driver-agent 为 SQLite 表列表补充精确行数与存储占用 - 使用 dbstat 区分数据页和索引页并支持相对大小展示 - 区分未知统计与真实零值并补充前后端回归测试 --- .../TableOverview.tdengine.test.tsx | 53 +++++++++++- frontend/src/components/TableOverview.tsx | 47 ++++++---- internal/app/methods_db.go | 12 +++ internal/db/database.go | 85 +++++++++++++++++++ internal/db/optional_driver_agent_impl.go | 14 +++ .../db/optional_driver_agent_impl_test.go | 41 +++++++++ 6 files changed, 233 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/TableOverview.tdengine.test.tsx b/frontend/src/components/TableOverview.tdengine.test.tsx index 3a8da13e..f888d119 100644 --- a/frontend/src/components/TableOverview.tdengine.test.tsx +++ b/frontend/src/components/TableOverview.tdengine.test.tsx @@ -138,7 +138,7 @@ const collectText = (node: any): string => { return collectText(node.children || []); }; -describe('TableOverview tdengine compatibility', () => { +describe('TableOverview metadata compatibility', () => { beforeEach(() => { vi.clearAllMocks(); storeState.appearance = { uiVersion: 'legacy', tableDoubleClickAction: 'open-data' }; @@ -191,6 +191,57 @@ describe('TableOverview tdengine compatibility', () => { expect(renderedText).toContain('d001'); }); + it('loads sqlite overview rows through DBGetTables instead of information_schema SQL', async () => { + storeState.connections = [ + { + id: 'conn-1', + config: { + type: 'sqlite', + host: '', + port: 0, + user: '', + password: '', + database: 'E:\\data\\app.db', + useSSH: false, + ssh: { host: '', port: 22, user: '', password: '', keyPath: '' }, + }, + }, + ]; + backendApp.DBGetTables.mockResolvedValue({ + success: true, + data: [ + { Table: 'users', Rows: '12', Data_length: '4096', Index_length: '8192' }, + { Table: 'orders', Rows: '34', Data_length: '2048', Index_length: '0' }, + ], + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushPromises(); + + expect(backendApp.DBGetTables).toHaveBeenCalledWith(expect.any(Object), 'main'); + expect(backendApp.DBQuery).not.toHaveBeenCalled(); + expect(messageApi.error).not.toHaveBeenCalled(); + const renderedText = collectText(renderer!.toJSON()); + expect(renderedText).toContain('users'); + expect(renderedText).toContain('12'); + expect(renderedText).toContain('orders'); + expect(renderedText).toContain('34'); + expect(renderedText).toContain('4.0 KB'); + expect(renderedText).toContain('8.0 KB'); + expect(renderedText).toContain('2.0 KB'); + expect(renderedText).toContain('0 B'); + expect(renderedText).toContain('100%'); + }); + it('uses the table default open behavior for v2 card double-clicks', async () => { storeState.appearance = { uiVersion: 'v2', tableDoubleClickAction: 'open-design' }; let renderer: ReactTestRenderer; diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index 13dc3479..94c86d84 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -94,7 +94,8 @@ const resolveOverviewContextMenuPosition = ( }; const formatSize = (bytes: number): string => { - if (!bytes || bytes <= 0) return '—'; + if (!Number.isFinite(bytes) || bytes < 0) return '—'; + if (bytes === 0) return '0 B'; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; @@ -102,7 +103,7 @@ const formatSize = (bytes: number): string => { }; const formatRows = (count: number): string => { - if (count === undefined || count === null || count < 0) return '—'; + if (count === undefined || count === null || !Number.isFinite(count) || count < 0) return '—'; if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; return String(count); @@ -227,19 +228,19 @@ const parseTableStats = (dialect: string, rows: Record[]): TableSta return undefined; }; const strVal = (keys: string[]) => String(get(keys) ?? '').trim(); - const numVal = (keys: string[]) => { + const numVal = (keys: string[], missingValue = 0) => { const v = get(keys); - if (v === null || v === undefined || v === '') return 0; + if (v === null || v === undefined || v === '') return missingValue; const n = Number(v); - return isNaN(n) ? 0 : Math.max(0, Math.round(n)); + return isNaN(n) ? missingValue : Math.max(0, Math.round(n)); }; return { name: strVal(['Name', 'name', 'table_name', 'tablename', 'TABLE_NAME', 'Table', 'table', 'Device', 'device']), comment: strVal(['Comment', 'table_comment', 'TABLE_COMMENT', 'comments']), - rows: numVal(['Rows', 'table_rows', 'TABLE_ROWS', 'num_rows', 'reltuples', 'total_rows']), - dataSize: numVal(['Data_length', 'data_length', 'DATA_LENGTH', 'total_bytes']), - indexSize: numVal(['Index_length', 'index_length', 'INDEX_LENGTH']), + rows: numVal(['Rows', 'table_rows', 'TABLE_ROWS', 'num_rows', 'reltuples', 'total_rows'], -1), + dataSize: numVal(['Data_length', 'data_length', 'DATA_LENGTH', 'total_bytes'], -1), + indexSize: numVal(['Index_length', 'index_length', 'INDEX_LENGTH'], -1), engine: strVal(['Engine', 'engine']), createTime: strVal(['Create_time', 'create_time']), updateTime: strVal(['Update_time', 'update_time']), @@ -296,7 +297,7 @@ const TableOverview: React.FC = ({ tab }) => { useSSH: connection.config.useSSH || false, ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }, }; - if (metadataDialect === 'tdengine') { + if (metadataDialect === 'tdengine' || metadataDialect === 'sqlite' || metadataDialect === 'sqlite3') { const res = await DBGetTables(buildRpcConnectionConfig(config) as any, tab.dbName || ''); if (res.success && Array.isArray(res.data)) { setTables(parseTableStats(metadataDialect, res.data)); @@ -815,11 +816,21 @@ const TableOverview: React.FC = ({ tab }) => { { key: 'dataSize', label: getSortMenuLabel('dataSize', 'table_overview.sort.size'), onClick: () => toggleSort('dataSize') }, ]; - const totalRows = useMemo(() => tables.reduce((s, t) => s + t.rows, 0), [tables]); - const totalSize = useMemo(() => tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0), [tables]); + const hasKnownTableSize = useCallback((table: TableStatRow) => table.dataSize >= 0 || table.indexSize >= 0, []); + const getCombinedTableSize = useCallback((table: TableStatRow) => ( + Math.max(0, table.dataSize) + Math.max(0, table.indexSize) + ), []); + const totalRows = useMemo(() => { + const knownRows = tables.filter(table => table.rows >= 0); + return knownRows.length > 0 ? knownRows.reduce((sum, table) => sum + table.rows, 0) : -1; + }, [tables]); + const totalSize = useMemo(() => { + const knownSizes = tables.filter(hasKnownTableSize); + return knownSizes.length > 0 ? knownSizes.reduce((sum, table) => sum + getCombinedTableSize(table), 0) : -1; + }, [getCombinedTableSize, hasKnownTableSize, tables]); const maxCombinedSize = useMemo(() => sortedFiltered.reduce((max, table) => { - return Math.max(max, table.dataSize + table.indexSize); - }, 0), [sortedFiltered]); + return Math.max(max, getCombinedTableSize(table)); + }, 0), [getCombinedTableSize, sortedFiltered]); const allowTruncate = supportsTableTruncateAction(connection?.config?.type || '', connection?.config?.driver); const renderToolbarSummary = () => { @@ -1104,7 +1115,7 @@ const TableOverview: React.FC = ({ tab }) => { {isV2Ui && (
- 0 ? Math.round(((table.dataSize + table.indexSize) / maxCombinedSize) * 100) : 4))}%` }} /> + 0 && hasKnownTableSize(table) ? Math.round((getCombinedTableSize(table) / maxCombinedSize) * 100) : 4))}%` }} />
)} @@ -1126,9 +1137,9 @@ const TableOverview: React.FC = ({ tab }) => { }; const renderListTable = (table: TableStatRow) => { - const combinedSize = table.dataSize + table.indexSize; - const sizeRatio = maxCombinedSize > 0 ? combinedSize / maxCombinedSize : 0; - const fillWidth = maxCombinedSize > 0 ? `${Math.max(10, Math.round(sizeRatio * 100))}%` : '0%'; + const combinedSize = getCombinedTableSize(table); + const sizeRatio = maxCombinedSize > 0 && hasKnownTableSize(table) ? combinedSize / maxCombinedSize : 0; + const fillWidth = maxCombinedSize > 0 && hasKnownTableSize(table) ? `${Math.max(10, Math.round(sizeRatio * 100))}%` : '0%'; const fillColor = darkMode ? 'rgba(22,119,255,0.18)' : 'rgba(22,119,255,0.12)'; const rowSecondary = table.comment || (table.engine ? t('table_overview.row.engine_table', { engine: table.engine }) @@ -1221,7 +1232,7 @@ const TableOverview: React.FC = ({ tab }) => {
{t('table_overview.metric.relative_size')}
- {maxCombinedSize > 0 ? `${Math.round(sizeRatio * 100)}%` : '—'} + {maxCombinedSize > 0 && hasKnownTableSize(table) ? `${Math.round(sizeRatio * 100)}%` : '—'}
diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index a98a174a..668ba174 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -1964,6 +1964,14 @@ func (a *App) DBGetTables(config connection.ConnectionConfig, dbName string) con logger.Warnf("DBGetTables 获取表行数失败(保留已获取的表列表):%s err=%v", formatConnSummary(runConfig), countErr) } } + tableStorageStats := map[string]db.TableStorageStats{} + if storageProvider, ok := dbInst.(db.TableStorageStatsProvider); ok { + var storageErr error + tableStorageStats, storageErr = storageProvider.GetTableStorageStats(dbName, tables) + if storageErr != nil { + logger.Warnf("DBGetTables 获取表存储大小失败(保留已获取的表列表):%s err=%v", formatConnSummary(runConfig), storageErr) + } + } resData := make([]map[string]string, 0, len(tables)) for _, name := range tables { @@ -1971,6 +1979,10 @@ func (a *App) DBGetTables(config connection.ConnectionConfig, dbName string) con if rowCount, ok := tableRowCounts[name]; ok { item["Rows"] = strconv.FormatInt(rowCount, 10) } + if storageStats, ok := tableStorageStats[name]; ok { + item["Data_length"] = strconv.FormatInt(storageStats.DataLength, 10) + item["Index_length"] = strconv.FormatInt(storageStats.IndexLength, 10) + } resData = append(resData, item) } diff --git a/internal/db/database.go b/internal/db/database.go index 0e08f885..c6f51d97 100644 --- a/internal/db/database.go +++ b/internal/db/database.go @@ -50,6 +50,17 @@ type TableRowCounter interface { GetTableRowCounts(dbName string, tables []string) (map[string]int64, error) } +// TableStorageStatsProvider is an optional metadata interface for drivers that +// can report per-table data and index storage usage in bytes. +type TableStorageStatsProvider interface { + GetTableStorageStats(dbName string, tables []string) (map[string]TableStorageStats, error) +} + +type TableStorageStats struct { + DataLength int64 + IndexLength int64 +} + func getSQLiteTableRowCounts(query func(string) ([]map[string]interface{}, []string, error), tables []string) (map[string]int64, error) { counts := make(map[string]int64, len(tables)) var firstErr error @@ -88,6 +99,80 @@ func getSQLiteTableRowCounts(query func(string) ([]map[string]interface{}, []str return counts, firstErr } +func getSQLiteTableStorageStats(query func(string) ([]map[string]interface{}, []string, error), tables []string) (map[string]TableStorageStats, error) { + requestedTables := make(map[string]struct{}, len(tables)) + for _, rawTableName := range tables { + tableName := strings.TrimSpace(rawTableName) + if tableName != "" { + requestedTables[tableName] = struct{}{} + } + } + if len(requestedTables) == 0 { + return map[string]TableStorageStats{}, nil + } + + data, _, err := query(` +WITH object_sizes AS ( + SELECT name, SUM(pgsize) AS bytes + FROM dbstat + GROUP BY name +), index_sizes AS ( + SELECT idx.tbl_name AS table_name, SUM(object_sizes.bytes) AS bytes + FROM sqlite_master AS idx + JOIN object_sizes ON object_sizes.name = idx.name + WHERE idx.type = 'index' + GROUP BY idx.tbl_name +) +SELECT + tbl.name AS table_name, + COALESCE(table_sizes.bytes, 0) AS data_length, + COALESCE(index_sizes.bytes, 0) AS index_length +FROM sqlite_master AS tbl +LEFT JOIN object_sizes AS table_sizes ON table_sizes.name = tbl.name +LEFT JOIN index_sizes ON index_sizes.table_name = tbl.name +WHERE tbl.type = 'table'`) + if err != nil { + return map[string]TableStorageStats{}, fmt.Errorf("读取 SQLite 表存储大小失败: %w", err) + } + + stats := make(map[string]TableStorageStats, len(data)) + for _, row := range data { + tableName := strings.TrimSpace(fmt.Sprint(metadataRowValue(row, "table_name"))) + if tableName == "" { + continue + } + if len(requestedTables) > 0 { + if _, ok := requestedTables[tableName]; !ok { + continue + } + } + dataLength, dataErr := metadataInt64(row, "data_length") + indexLength, indexErr := metadataInt64(row, "index_length") + if dataErr != nil || indexErr != nil || dataLength < 0 || indexLength < 0 { + return map[string]TableStorageStats{}, fmt.Errorf("读取 SQLite 表 %q 存储大小失败: data_length=%v index_length=%v", tableName, metadataRowValue(row, "data_length"), metadataRowValue(row, "index_length")) + } + stats[tableName] = TableStorageStats{DataLength: dataLength, IndexLength: indexLength} + } + return stats, nil +} + +func metadataRowValue(row map[string]interface{}, key string) interface{} { + for rowKey, value := range row { + if strings.EqualFold(strings.TrimSpace(rowKey), key) { + return value + } + } + return nil +} + +func metadataInt64(row map[string]interface{}, key string) (int64, error) { + value := metadataRowValue(row, key) + if value == nil { + return 0, fmt.Errorf("查询结果缺少 %s", key) + } + return strconv.ParseInt(strings.TrimSpace(fmt.Sprint(value)), 10, 64) +} + // MultiResultQuerier 是可选接口,支持多结果集的驱动实现此接口。 // 执行可能包含多条 SQL 语句的查询,返回所有结果集。 type MultiResultQuerier interface { diff --git a/internal/db/optional_driver_agent_impl.go b/internal/db/optional_driver_agent_impl.go index 07b07628..a95873f0 100644 --- a/internal/db/optional_driver_agent_impl.go +++ b/internal/db/optional_driver_agent_impl.go @@ -861,6 +861,20 @@ func (d *OptionalDriverAgentDB) GetTables(dbName string) ([]string, error) { return tables, nil } +func (d *OptionalDriverAgentDB) GetTableRowCounts(_ string, tables []string) (map[string]int64, error) { + if normalizeRuntimeDriverType(d.driverType) != "sqlite" { + return map[string]int64{}, nil + } + return getSQLiteTableRowCounts(d.Query, tables) +} + +func (d *OptionalDriverAgentDB) GetTableStorageStats(_ string, tables []string) (map[string]TableStorageStats, error) { + if normalizeRuntimeDriverType(d.driverType) != "sqlite" { + return map[string]TableStorageStats{}, nil + } + return getSQLiteTableStorageStats(d.Query, tables) +} + func (d *OptionalDriverAgentDB) GetCreateStatement(dbName, tableName string) (string, error) { client, err := d.requireClient() if err != nil { diff --git a/internal/db/optional_driver_agent_impl_test.go b/internal/db/optional_driver_agent_impl_test.go index e1612ac5..d4a5b04c 100644 --- a/internal/db/optional_driver_agent_impl_test.go +++ b/internal/db/optional_driver_agent_impl_test.go @@ -168,6 +168,47 @@ func TestOptionalDriverAgentDBQueryWithMessagesParsesAgentMessages(t *testing.T) } } +func TestOptionalDriverAgentDBProvidesSQLiteTableStats(t *testing.T) { + var stdin optionalAgentTestWriteCloser + stdout := strings.Join([]string{ + `{"id":1,"success":true,"data":[{"table_rows":2}],"fields":["table_rows"]}`, + `{"id":2,"success":true,"data":[{"table_name":"orders","data_length":4096,"index_length":8192}],"fields":["table_name","data_length","index_length"]}`, + }, "\n") + "\n" + + dbInst := &OptionalDriverAgentDB{ + driverType: "sqlite", + client: &optionalDriverAgentClient{ + stdin: &stdin, + reader: bufio.NewReader(strings.NewReader(stdout)), + driver: "sqlite", + }, + } + + rowCounts, err := dbInst.GetTableRowCounts("main", []string{"orders"}) + if err != nil { + t.Fatalf("GetTableRowCounts 返回错误: %v", err) + } + if rowCounts["orders"] != 2 { + t.Fatalf("SQLite driver-agent 行数异常: %#v", rowCounts) + } + + storageStats, err := dbInst.GetTableStorageStats("main", []string{"orders"}) + if err != nil { + t.Fatalf("GetTableStorageStats 返回错误: %v", err) + } + if storageStats["orders"].DataLength != 4096 || storageStats["orders"].IndexLength != 8192 { + t.Fatalf("SQLite driver-agent 存储统计异常: %#v", storageStats) + } + + requests := stdin.String() + if !strings.Contains(requests, `SELECT COUNT(*) AS table_rows FROM \"orders\"`) { + t.Fatalf("driver-agent 未执行 SQLite 行数查询: %s", requests) + } + if !strings.Contains(requests, "FROM dbstat") { + t.Fatalf("driver-agent 未执行 SQLite dbstat 查询: %s", requests) + } +} + func TestOptionalDriverAgentDBQueryMultiWithMessagesParsesResultSets(t *testing.T) { var stdin optionalAgentTestWriteCloser stdout := `{"id":1,"success":true,"data":[{"statementIndex":1,"rows":[{"name":"master"}],"columns":["name"]},{"statementIndex":1,"rows":[],"columns":[],"messages":["PRINT generated sql"]}],"messages":["batch top-level message"]}` + "\n"