mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-11 09:13:36 +08:00
🐛 fix(sqlite): 修复表概览行数与大小统计
- 通过 driver-agent 为 SQLite 表列表补充精确行数与存储占用 - 使用 dbstat 区分数据页和索引页并支持相对大小展示 - 区分未知统计与真实零值并补充前后端回归测试
This commit is contained in:
@@ -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(<TableOverview tab={{
|
||||
id: 'tab-1',
|
||||
title: '表概览 - main',
|
||||
type: 'table-overview',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
} as any} />);
|
||||
});
|
||||
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;
|
||||
|
||||
@@ -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<string, any>[]): 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<TableOverviewProps> = ({ 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<TableOverviewProps> = ({ 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<TableOverviewProps> = ({ tab }) => {
|
||||
</div>
|
||||
{isV2Ui && (
|
||||
<div className="gn-v2-table-size-bar">
|
||||
<span style={{ width: `${Math.min(100, Math.max(4, maxCombinedSize > 0 ? Math.round(((table.dataSize + table.indexSize) / maxCombinedSize) * 100) : 4))}%` }} />
|
||||
<span style={{ width: `${Math.min(100, Math.max(4, maxCombinedSize > 0 && hasKnownTableSize(table) ? Math.round((getCombinedTableSize(table) / maxCombinedSize) * 100) : 4))}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1126,9 +1137,9 @@ const TableOverview: React.FC<TableOverviewProps> = ({ 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<TableOverviewProps> = ({ tab }) => {
|
||||
<div style={{ minWidth: 96, textAlign: 'right' }}>
|
||||
<div style={{ color: textMuted }}>{t('table_overview.metric.relative_size')}</div>
|
||||
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{maxCombinedSize > 0 ? `${Math.round(sizeRatio * 100)}%` : '—'}
|
||||
{maxCombinedSize > 0 && hasKnownTableSize(table) ? `${Math.round(sizeRatio * 100)}%` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user