feat: 查询分页(limit/offset)+流式CSV导出+去复制按钮
后端: - ExecuteQuery 添加 limit/offset 参数,MySQL 自动注入 LIMIT/OFFSET - 首页自动 COUNT 查总数(total),翻页页不再重复 count - 新增 POST /query/export 流式 CSV 导出端点,游标分批写不占内存 - 默认 limit=100, max=1000,防止大数据量 OOM 前端: - 查询结果加分页控件(首页/上一页/下一页/末页 + 每页条数选择) - CSV 导出改走流式端点(全量数据),不限于当前页 - 去掉复制按钮(Copy/Check/coped state),Redis 单值也不显示复制 - 工具栏显示 总行数/行每页/列数
This commit is contained in:
1 parent
03ff845485
commit
5ea86fe334
4 files changed
+232
-61
No files matched your search
@@ -505,6 +505,8 @@ func ExecuteQuery(c *gin.Context) {
|
||||
InstanceID int `json:"instance_id"`
|
||||
Database string `json:"database"`
|
||||
SQL string `json:"sql"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
@@ -514,6 +516,16 @@ func ExecuteQuery(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "instance_id and sql are required"})
|
||||
return
|
||||
}
|
||||
// Default pagination
|
||||
if input.Limit <= 0 {
|
||||
input.Limit = 100
|
||||
}
|
||||
if input.Limit > 1000 {
|
||||
input.Limit = 1000
|
||||
}
|
||||
if input.Offset < 0 {
|
||||
input.Offset = 0
|
||||
}
|
||||
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, input.InstanceID).Error; err != nil {
|
||||
@@ -525,7 +537,7 @@ func ExecuteQuery(c *gin.Context) {
|
||||
|
||||
switch instance.DBType {
|
||||
case "mysql", "postgresql", "postgres", "pg":
|
||||
result, err := executeSQLQuery(&instance, input.Database, input.SQL)
|
||||
result, err := executeSQLQuery(&instance, input.Database, input.SQL, input.Limit, input.Offset)
|
||||
costMs := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()})
|
||||
@@ -551,13 +563,41 @@ func ExecuteQuery(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string) (gin.H, error) {
|
||||
func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, limit int, offset int) (gin.H, error) {
|
||||
db, err := getDBConnection(instance, dbName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connection failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
upperSQL := strings.ToUpper(strings.TrimSpace(sqlStr))
|
||||
|
||||
// Count total (only when no user LIMIT and offset=0)
|
||||
total := 0
|
||||
hasLimit := strings.Contains(upperSQL, " LIMIT ")
|
||||
|
||||
if !hasLimit && offset == 0 {
|
||||
if strings.HasPrefix(upperSQL, "SELECT") {
|
||||
fromIdx := strings.Index(upperSQL, " FROM ")
|
||||
if fromIdx > 0 {
|
||||
countSQL := "SELECT COUNT(*) " + sqlStr[fromIdx:]
|
||||
upperCount := strings.ToUpper(countSQL)
|
||||
if idx := strings.Index(upperCount, " ORDER BY "); idx > 0 {
|
||||
countSQL = countSQL[:idx]
|
||||
}
|
||||
if idx := strings.Index(upperCount, " GROUP BY "); idx > 0 {
|
||||
countSQL = countSQL[:idx]
|
||||
}
|
||||
db.QueryRow(countSQL).Scan(&total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inject LIMIT/OFFSET if not already present
|
||||
if !hasLimit {
|
||||
sqlStr = fmt.Sprintf("%s LIMIT %d OFFSET %d", sqlStr, limit, offset)
|
||||
}
|
||||
|
||||
rows, err := db.Query(sqlStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query failed: %v", err)
|
||||
@@ -594,7 +634,8 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string) (gi
|
||||
return gin.H{
|
||||
"columns": colNames,
|
||||
"rows": resultRows,
|
||||
"total": len(resultRows),
|
||||
"total": total,
|
||||
"count": len(resultRows),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -742,6 +783,121 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string
|
||||
}, nil
|
||||
}
|
||||
|
||||
// POST /api/v1/query/export — Streaming CSV export for large datasets
|
||||
func ExportQueryCSV(c *gin.Context) {
|
||||
var input struct {
|
||||
InstanceID int `json:"instance_id"`
|
||||
Database string `json:"database"`
|
||||
SQL string `json:"sql"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if input.InstanceID == 0 || input.SQL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "instance_id and sql are required"})
|
||||
return
|
||||
}
|
||||
if input.Limit <= 0 {
|
||||
input.Limit = 10000 // Export max 10k rows
|
||||
}
|
||||
if input.Limit > 50000 {
|
||||
input.Limit = 50000
|
||||
}
|
||||
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, input.InstanceID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if instance.DBType != "mysql" && instance.DBType != "postgresql" && instance.DBType != "postgres" && instance.DBType != "pg" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "CSV export only supports MySQL/PostgreSQL"})
|
||||
return
|
||||
}
|
||||
|
||||
db, err := getDBConnection(&instance, input.Database)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Connection failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Inject LIMIT if not present
|
||||
upperSQL := strings.ToUpper(strings.TrimSpace(input.SQL))
|
||||
if !strings.Contains(upperSQL, " LIMIT ") {
|
||||
input.SQL = fmt.Sprintf("%s LIMIT %d", input.SQL, input.Limit)
|
||||
}
|
||||
|
||||
rows, err := db.Query(input.SQL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Query failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cols, _ := rows.Columns()
|
||||
colNames := make([]string, len(cols))
|
||||
for i, col := range cols {
|
||||
colNames[i] = col
|
||||
}
|
||||
|
||||
// Stream CSV response
|
||||
filename := fmt.Sprintf("query_export_%s.csv", time.Now().Format("20060102_150405"))
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
// BOM for Excel UTF-8
|
||||
c.Writer.Write([]byte("\xEF\xBB\xBF"))
|
||||
|
||||
// Header row
|
||||
c.Writer.Write([]byte(strings.Join(colNames, ",") + "\n"))
|
||||
|
||||
// Data rows — write in chunks to avoid OOM
|
||||
batchSize := 500
|
||||
batch := make([]string, 0, batchSize)
|
||||
rowCount := 0
|
||||
|
||||
for rows.Next() {
|
||||
values := make([]interface{}, len(cols))
|
||||
ptrValues := make([]interface{}, len(cols))
|
||||
for i := range values {
|
||||
ptrValues[i] = &values[i]
|
||||
}
|
||||
rows.Scan(ptrValues...)
|
||||
|
||||
cells := make([]string, len(cols))
|
||||
for i, val := range values {
|
||||
s := ""
|
||||
if b, ok := val.([]byte); ok {
|
||||
s = string(b)
|
||||
} else if val != nil {
|
||||
s = fmt.Sprintf("%v", val)
|
||||
}
|
||||
// CSV escape: wrap in quotes if contains comma, quote, or newline
|
||||
if strings.ContainsAny(s, ",\"\n\r") {
|
||||
s = `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
||||
}
|
||||
cells[i] = s
|
||||
}
|
||||
batch = append(batch, strings.Join(cells, ","))
|
||||
rowCount++
|
||||
|
||||
if len(batch) >= batchSize {
|
||||
c.Writer.Write([]byte(strings.Join(batch, "\n") + "\n"))
|
||||
c.Writer.(http.Flusher).Flush()
|
||||
batch = batch[:0]
|
||||
}
|
||||
}
|
||||
// Flush remaining
|
||||
if len(batch) > 0 {
|
||||
c.Writer.Write([]byte(strings.Join(batch, "\n") + "\n"))
|
||||
}
|
||||
c.Writer.(http.Flusher).Flush()
|
||||
|
||||
saveQueryHistory(0, input.InstanceID, input.Database, input.SQL, 0, rowCount)
|
||||
}
|
||||
|
||||
func saveQueryHistory(userID int, instanceID int, database string, sqlStr string, costMs int64, rowCount int) {
|
||||
var user model.User
|
||||
var inst model.Instance
|
||||
|
||||
@@ -155,6 +155,7 @@ func Setup(r *gin.Engine) {
|
||||
// Query execution
|
||||
auth.POST("/query/execute", handler.ExecuteQuery)
|
||||
auth.POST("/query", handler.ExecuteQuery)
|
||||
auth.POST("/query/export", handler.ExportQueryCSV)
|
||||
|
||||
|
||||
// SQL Assistant
|
||||
|
||||
Reference in new issue
Block a user