feat: 新增数据库导出功能&其他小优化

This commit is contained in:
meilin.huang
2022-06-30 16:42:25 +08:00
parent 64b49dae2e
commit fe8cd93c78
11 changed files with 234 additions and 31 deletions

View File

@@ -30,7 +30,7 @@ import { useStore } from '@/store/index.ts';
export default defineComponent({
name: 'layoutBreadcrumbSearch',
setup() {
const layoutMenuAutocompleteRef = ref();
const layoutMenuAutocompleteRef: any = ref(null);
const store = useStore();
const router = useRouter();
const state: any = reactive({
@@ -68,7 +68,6 @@ export default defineComponent({
// 初始化菜单数据
const initTageView = () => {
if (state.tagsViewList.length > 0) return false;
console.log(getRoutes(store.state.routesList.routesList));
getRoutes(store.state.routesList.routesList).map((v: any) => {
if (!v.meta.isHide) {
state.tagsViewList.push({ ...v });

View File

@@ -72,6 +72,32 @@
<el-dialog width="75%" :title="`${db} 表信息`" :before-close="closeTableInfo" v-model="tableInfoDialog.visible">
<el-row class="mb10">
<el-popover v-model:visible="showDumpInfo" :width="470" placement="right">
<template #reference>
<el-button class="ml5" type="success" size="small" @click="showDumpInfo = !showDumpInfo">导出</el-button>
</template>
<el-form-item label="导出内容: ">
<el-radio-group v-model="dumpInfo.type">
<el-radio :label="1" size="small">结构</el-radio>
<el-radio :label="2" size="small">数据</el-radio>
<el-radio :label="3" size="small">结构数据</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="导出表: ">
<el-table @selection-change="handleDumpTableSelectionChange" max-height="300" size="small" :data="tableInfoDialog.infos">
<el-table-column type="selection" width="45" />
<el-table-column property="tableName" label="表名" min-width="150" show-overflow-tooltip> </el-table-column>
<el-table-column property="tableComment" label="备注" min-width="150" show-overflow-tooltip></el-table-column>
</el-table>
</el-form-item>
<div style="text-align: right">
<el-button @click="showDumpInfo = false" size="small">取消</el-button>
<el-button @click="dump(db)" type="success" size="small">确定</el-button>
</div>
</el-popover>
<el-button type="primary" size="small" @click="tableCreateDialog.visible = true">创建表</el-button>
</el-row>
<el-table v-loading="tableInfoDialog.loading" border stripe :data="tableInfoDialog.infos" size="small">
@@ -226,6 +252,10 @@ import { dbApi } from './api';
import enums from './enums';
import { projectApi } from '../project/api.ts';
import SqlExecBox from './component/SqlExecBox.ts';
import config from '@/common/config';
import { getSession } from '@/common/utils/storage';
import { isTrue } from '@/common/assert';
export default defineComponent({
name: 'DbList',
components: {
@@ -255,6 +285,13 @@ export default defineComponent({
},
datas: [],
total: 0,
showDumpInfo: false,
dumpInfo: {
id: 0,
db: '',
type: 3,
tables: [],
},
// sql执行记录弹框
sqlExecLogDialog: {
title: '',
@@ -392,6 +429,29 @@ export default defineComponent({
searchSqlExecLog();
};
/**
* 选择导出数据库表
*/
const handleDumpTableSelectionChange = (vals: any) => {
state.dumpInfo.tables = vals.map((x: any) => x.tableName);
};
/**
* 数据库信息导出
*/
const dump = (db: string) => {
isTrue(state.dumpInfo.tables.length > 0, '请选择要导出的表');
const a = document.createElement('a');
a.setAttribute(
'href',
`${config.baseApiUrl}/dbs/${state.dbId}/dump?db=${db}&type=${state.dumpInfo.type}&tables=${state.dumpInfo.tables.join(
','
)}&token=${getSession('token')}`
);
a.click();
state.showDumpInfo = false;
};
const onShowRollbackSql = async (sqlExecLog: any) => {
const columns = await dbApi.columnMetadata.request({ id: sqlExecLog.dbId, db: sqlExecLog.db, tableName: sqlExecLog.table });
const primaryKey = columns[0].columnName;
@@ -447,6 +507,7 @@ export default defineComponent({
};
const closeTableInfo = () => {
state.showDumpInfo = false;
state.tableInfoDialog.visible = false;
state.tableInfoDialog.infos = [];
};
@@ -516,6 +577,8 @@ export default defineComponent({
valChange,
deleteDb,
onShowSqlExec,
handleDumpTableSelectionChange,
dump,
onBeforeCloseSqlExecDialog,
handleSqlExecPageChange,
searchSqlExecLog,

View File

@@ -927,7 +927,7 @@ export default defineComponent({
return;
}
// 转为字符串比较,可能存在数字等
let text = row[property] + '';
let text = (row[property] ? row[property] : '') + '';
let div = cell.children[0];
if (div) {
let input = document.createElement('input');

View File

@@ -5,6 +5,7 @@ export const dbApi = {
dbs: Api.create("/dbs", 'get'),
saveDb: Api.create("/dbs", 'post'),
deleteDb: Api.create("/dbs/{id}", 'delete'),
dumpDb: Api.create("/dbs/{id}/dump", 'post'),
tableInfos: Api.create("/dbs/{id}/t-infos", 'get'),
tableIndex: Api.create("/dbs/{id}/t-index", 'get'),
tableDdl: Api.create("/dbs/{id}/t-create-ddl", 'get'),

View File

@@ -2,7 +2,7 @@
<div>
<el-dialog title="待执行SQL" v-model="dialogVisible" :show-close="false" width="600px">
<codemirror height="350px" class="codesql" ref="cmEditor" language="sql" v-model="sqlValue" :options="cmOptions" />
<el-input v-model="remark" placeholder="请输入执行备注" class="mt5" />
<el-input ref="remarkInputRef" v-model="remark" placeholder="请输入执行备注" class="mt5" />
<template #footer>
<span class="dialog-footer">
<el-button @click="cancel"> </el-button>
@@ -14,9 +14,9 @@
</template>
<script lang="ts">
import { toRefs, reactive, defineComponent } from 'vue';
import { toRefs, ref, nextTick, reactive, defineComponent } from 'vue';
import { dbApi } from '../api';
import { ElDialog, ElButton, ElInput, ElMessage } from 'element-plus';
import { ElDialog, ElButton, ElInput, ElMessage, InputInstance } from 'element-plus';
// import base style
import 'codemirror/lib/codemirror.css';
// 引入主题后还需要在 options 中指定主题才会生效
@@ -50,6 +50,7 @@ export default defineComponent({
},
},
setup(props: any) {
const remarkInputRef = ref<InputInstance>();
const state = reactive({
dialogVisible: false,
sqlValue: '',
@@ -133,10 +134,14 @@ export default defineComponent({
state.dbId = props.dbId;
state.db = props.db;
state.dialogVisible = true;
nextTick(() => {
remarkInputRef.value?.focus();
});
};
return {
...toRefs(state),
remarkInputRef,
open,
runSql,
cancel,

View File

@@ -1,10 +1,10 @@
module mayfly-go
go 1.17
go 1.18
require (
github.com/dgrijalva/jwt-go v3.2.0+incompatible // jwt
github.com/gin-gonic/gin v1.7.7
github.com/gin-gonic/gin v1.8.1
github.com/go-redis/redis v6.15.9+incompatible
github.com/gorilla/websocket v1.5.0
github.com/mojocn/base64Captcha v1.3.5 //
@@ -27,8 +27,8 @@ require (
github.com/go-playground/validator/v10 v10.10.1 // indirect
github.com/go-sql-driver/mysql v1.6.0 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/goccy/go-json v0.9.7 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.4 // indirect
@@ -41,6 +41,7 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/onsi/ginkgo v1.16.5 // indirect
github.com/onsi/gomega v1.18.1 // indirect
github.com/pelletier/go-toml/v2 v2.0.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/ugorji/go/codec v1.2.7 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
@@ -48,9 +49,10 @@ require (
github.com/xdg-go/stringprep v1.0.2 // indirect
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
golang.org/x/image v0.0.0-20220302094943-723b81ca9867 // indirect
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 // indirect
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/protobuf v1.27.1 // indirect
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

View File

@@ -2,7 +2,7 @@ package api
import (
"fmt"
"io/ioutil"
"io"
"mayfly-go/internal/devops/api/form"
"mayfly-go/internal/devops/api/vo"
"mayfly-go/internal/devops/application"
@@ -16,8 +16,10 @@ import (
"mayfly-go/pkg/ws"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/xwb1989/sqlparser"
)
type Db struct {
@@ -27,6 +29,8 @@ type Db struct {
ProjectApp application.Project
}
const DEFAULT_COLUMN_SIZE = 500
// @router /api/dbs [get]
func (d *Db) Dbs(rc *ctx.ReqCtx) {
g := rc.GinCtx
@@ -91,7 +95,7 @@ func (d *Db) ExecSql(rc *ctx.ReqCtx) {
rc.ReqParam = fmt.Sprintf("db: %d:%s | sql: %s", id, db, sql)
biz.NotEmpty(sql, "sql不能为空")
if strings.HasPrefix(sql, "SELECT") || strings.HasPrefix(sql, "select") || strings.HasPrefix(sql, "show") {
if strings.HasPrefix(sql, "SELECT") || strings.HasPrefix(sql, "select") || strings.HasPrefix(sql, "show") || strings.HasPrefix(sql, "explain") {
colNames, res, err := dbInstance.SelectData(sql)
biz.ErrIsNilAppendErr(err, "查询失败: %s")
colAndRes := make(map[string]interface{})
@@ -128,12 +132,8 @@ func (d *Db) ExecSqlFile(rc *ctx.ReqCtx) {
fileheader, err := g.FormFile("file")
biz.ErrIsNilAppendErr(err, "读取sql文件失败: %s")
// 读取sql文件并根据;切割sql语句
file, _ := fileheader.Open()
filename := fileheader.Filename
bytes, _ := ioutil.ReadAll(file)
sqlContent := string(bytes)
sqls := strings.Split(sqlContent, ";")
dbId, db := GetIdAndDb(g)
go func() {
@@ -153,12 +153,14 @@ func (d *Db) ExecSqlFile(rc *ctx.ReqCtx) {
biz.ErrIsNilAppendErr(d.ProjectApp.CanAccess(rc.LoginAccount.Id, db.ProjectId), "%s")
for _, sql := range sqls {
sql = strings.Trim(sql, " ")
if sql == "" || sql == "\n" {
continue
tokens := sqlparser.NewTokenizer(file)
for {
stmt, err := sqlparser.ParseNext(tokens)
if err == io.EOF {
break
}
_, err := db.Exec(sql)
sql := sqlparser.String(stmt)
_, err = db.Exec(sql)
if err != nil {
d.MsgApp.CreateAndSend(rc.LoginAccount, ws.ErrMsg("sql脚本执行失败", fmt.Sprintf("[%s]%s执行失败: [%s]", filename, dbInfo, err.Error())))
return
@@ -168,6 +170,88 @@ func (d *Db) ExecSqlFile(rc *ctx.ReqCtx) {
}()
}
// 数据库dump
func (d *Db) DumpSql(rc *ctx.ReqCtx) {
g := rc.GinCtx
dbId, db := GetIdAndDb(g)
dumpType := g.Query("type")
tablesStr := g.Query("tables")
biz.NotEmpty(tablesStr, "请选择要导出的表")
tables := strings.Split(tablesStr, ",")
// 是否需要导出表结构
needStruct := dumpType == "1" || dumpType == "3"
// 是否需要导出数据
needData := dumpType == "2" || dumpType == "3"
now := time.Now()
filename := fmt.Sprintf("%s.%s.sql", db, now.Format("200601021504"))
g.Header("Content-Type", "application/octet-stream")
g.Header("Content-Disposition", "attachment; filename="+filename)
rc.ReqParam = fmt.Sprintf("数据库id: %d -- %s", dbId, db)
dbInstance := d.DbApp.GetDbInstance(dbId, db)
writer := g.Writer
writer.WriteString("-- ----------------------------")
writer.WriteString("\n-- 导出平台: mayfly-go")
writer.WriteString(fmt.Sprintf("\n-- 导出时间: %s ", now.Format("2006-01-02 15:04:05")))
writer.WriteString(fmt.Sprintf("\n-- 导出数据库: %s ", db))
writer.WriteString("\n-- ----------------------------\n")
for _, table := range tables {
if needStruct {
writer.WriteString(fmt.Sprintf("\n-- ----------------------------\n-- 表结构: %s \n-- ----------------------------\n", table))
writer.WriteString(fmt.Sprintf("DROP TABLE IF EXISTS `%s`;\n", table))
writer.WriteString(dbInstance.GetCreateTableDdl(table)[0]["Create Table"].(string) + ";\n")
}
if !needData {
continue
}
writer.WriteString(fmt.Sprintf("\n-- ----------------------------\n-- 表记录: %s \n-- ----------------------------\n", table))
writer.WriteString("BEGIN;\n")
countSql := fmt.Sprintf("SELECT COUNT(*) count FROM %s", table)
_, countRes, _ := dbInstance.SelectData(countSql)
// 查询出所有列信息总数,手动分页获取所有数据
maCount := int(countRes[0]["count"].(int64))
// 计算需要查询的页数
pageNum := maCount / DEFAULT_COLUMN_SIZE
if maCount%DEFAULT_COLUMN_SIZE > 0 {
pageNum++
}
sqlTmp := "SELECT * FROM %s LIMIT %d, %d"
for index := 0; index < pageNum; index++ {
sql := fmt.Sprintf(sqlTmp, table, index*DEFAULT_COLUMN_SIZE, DEFAULT_COLUMN_SIZE)
columns, result, _ := dbInstance.SelectData(sql)
insertSql := "INSERT INTO `%s` VALUES (%s);\n"
for _, res := range result {
var values []string
for _, column := range columns {
value := res[column]
if value == nil {
values = append(values, "NULL")
continue
}
strValue, ok := value.(string)
if ok {
values = append(values, fmt.Sprintf("%#v", strValue))
} else {
values = append(values, utils.ToString(value))
}
}
writer.WriteString(fmt.Sprintf(insertSql, table, strings.Join(values, ", ")))
}
}
writer.WriteString("COMMIT;\n")
}
rc.NoRes = true
}
// @router /api/db/:dbId/t-metadata [get]
func (d *Db) TableMA(rc *ctx.ReqCtx) {
dbi := d.DbApp.GetDbInstance(GetIdAndDb(rc.GinCtx))

View File

@@ -223,8 +223,9 @@ func (d *DbInstance) SelectData(execSql string) ([]string, []map[string]interfac
execSql = strings.Trim(execSql, " ")
isSelect := strings.HasPrefix(execSql, "SELECT") || strings.HasPrefix(execSql, "select")
isShow := strings.HasPrefix(execSql, "show")
isExplain := strings.HasPrefix(execSql, "explain")
if !isSelect && !isShow {
if !isSelect && !isShow && !isExplain {
return nil, nil, errors.New("该sql非查询语句")
}
// 没加limit则默认限制50条
@@ -272,14 +273,13 @@ func (d *DbInstance) SelectData(execSql string) ([]string, []map[string]interfac
colName := colType.Name()
// 字段类型名
colScanType := colType.ScanType().Name()
// 如果是密码字段,则脱敏显示
if colName == "password" {
v = []byte("******")
}
if isFirst {
colNames = append(colNames, colName)
}
if v == nil {
rowData[colName] = nil
continue
}
// 这里把[]byte数据转成string
stringV := string(v)
if stringV == "" {

View File

@@ -61,6 +61,12 @@ func InitDbRouter(router *gin.RouterGroup) {
rc.Handle(d.ExecSqlFile)
})
db.GET(":dbId/dump", func(g *gin.Context) {
ctx.NewReqCtxWithGin(g).
WithLog(ctx.NewLogInfo("Sql文件dump")).
Handle(d.DumpSql)
})
db.GET(":dbId/t-metadata", func(c *gin.Context) {
ctx.NewReqCtxWithGin(c).Handle(d.TableMA)
})

View File

@@ -16,7 +16,6 @@ type HandlerFunc func(*ReqCtx)
type ReqCtx struct {
GinCtx *gin.Context // gin context
// NeedToken bool // 是否需要token
RequiredPermission *Permission // 需要的权限信息默认为nil需要校验token
LoginAccount *model.LoginAccount // 登录账号信息只有校验token后才会有值
@@ -26,7 +25,7 @@ type ReqCtx struct {
Err interface{} // 请求错误
timed int64 // 执行时间
noRes bool // 无需返回结果,即文件下载等
NoRes bool // 无需返回结果,即文件下载等
}
func (rc *ReqCtx) Handle(handler HandlerFunc) {
@@ -55,13 +54,13 @@ func (rc *ReqCtx) Handle(handler HandlerFunc) {
begin := time.Now()
handler(rc)
rc.timed = time.Now().Sub(begin).Milliseconds()
if !rc.noRes {
if !rc.NoRes {
ginx.SuccessRes(ginCtx, rc.ResData)
}
}
func (rc *ReqCtx) Download(reader io.Reader, filename string) {
rc.noRes = true
rc.NoRes = true
ginx.Download(rc.GinCtx, reader, filename)
}

View File

@@ -2,6 +2,8 @@ package utils
import (
"bytes"
"encoding/json"
"strconv"
"strings"
"text/template"
)
@@ -107,3 +109,45 @@ func ReverStrTemplate(temp, str string, res map[string]interface{}) {
ReverStrTemplate(next, StrTrim(SubString(str, UnicodeIndex(str, value)+StrLen(value), StrLen(str))), res)
}
}
func ToString(value interface{}) string {
// interface 转 string
var key string
if value == nil {
return key
}
switch it := value.(type) {
case float64:
return strconv.FormatFloat(it, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(it), 'f', -1, 64)
case int:
return strconv.Itoa(it)
case uint:
return strconv.Itoa(int(it))
case int8:
return strconv.Itoa(int(it))
case uint8:
return strconv.Itoa(int(it))
case int16:
return strconv.Itoa(int(it))
case uint16:
return strconv.Itoa(int(it))
case int32:
return strconv.Itoa(int(it))
case uint32:
return strconv.Itoa(int(it))
case int64:
return strconv.FormatInt(it, 10)
case uint64:
return strconv.FormatUint(it, 10)
case string:
return it
case []byte:
return string(value.([]byte))
default:
newValue, _ := json.Marshal(value)
return string(newValue)
}
}