feat(table-copy): 新增整表复制与自动副本命名

- 在表概览及新旧侧栏右键菜单接入复制整表入口与确认、进度和刷新反馈
- 按 source_copyN 原子创建目标表并复制列、索引、默认值及全部数据
- 处理 PostgreSQL 生成列、identity/serial 序列校准与失败清理
- 限定安全数据源与连接保护策略,阻止分区表、RLS 及引用型存储引擎
- 加固 MySQL/PostgreSQL 元数据标识符处理并补齐六语种文案
- 增加后端、能力矩阵、菜单接线和国际化回归测试
This commit is contained in:
Syngnat
2026-07-21 13:27:24 +08:00
parent 49e566e01e
commit 446707698f
27 changed files with 2061 additions and 13 deletions

View File

@@ -139,6 +139,8 @@ var readOnlyConnectionActionTextKeys = map[string]string{
"connection.backend.action.drop_database": "connection.backend.action.drop_database",
"重命名表": "connection.backend.action.rename_table",
"connection.backend.action.rename_table": "connection.backend.action.rename_table",
"复制整表": "connection.backend.action.copy_table",
"connection.backend.action.copy_table": "connection.backend.action.copy_table",
"删除表": "connection.backend.action.drop_table",
"connection.backend.action.drop_table": "connection.backend.action.drop_table",
"删除视图": "connection.backend.action.drop_view",

View File

@@ -59,6 +59,7 @@ func TestConnectionReadOnlyCatalogKeysExist(t *testing.T) {
"connection.backend.action.rename_database",
"connection.backend.action.drop_database",
"connection.backend.action.rename_table",
"connection.backend.action.copy_table",
"connection.backend.action.drop_table",
"connection.backend.action.drop_view",
"connection.backend.action.drop_function_or_procedure",

View File

@@ -0,0 +1,691 @@
package app
import (
"crypto/sha256"
"errors"
"fmt"
"strconv"
"strings"
"unicode/utf8"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"GoNavi-Wails/internal/logger"
)
const maxCopyTableCreateAttempts = 1000
type copyTablePlan struct {
createSQL string
insertSQL string
dropSQL string
postStatements []copyTablePostStatement
}
type copyTablePostStatement struct {
sql string
createdSequenceDropSQL string
}
type copyTableColumnMetadata struct {
writableColumns []string
serialColumns []string
identityColumns []string
sequenceOptions map[string]postgresCopyTableSequenceOptions
}
type postgresCopyTableSequenceOptions struct {
dataType string
start int64
increment int64
min int64
max int64
cache int64
cycle bool
}
var errCopyTableColumnsMissing = errors.New("copy table column metadata is empty")
// CopyTable creates a same-schema table copy and fills it with all source rows.
// The target name starts at <source>_copy1 and advances when that name exists.
func (a *App) CopyTable(config connection.ConnectionConfig, dbName string, sourceSchemaName string, sourceTableName string) (result connection.QueryResult) {
auditSQL := fmt.Sprintf("COPY TABLE %s", strings.TrimSpace(sourceTableName))
defer a.beginSQLAuditUserAction(config, dbName, "object_editor", &auditSQL, &result)()
sourceTableName = strings.TrimSpace(sourceTableName)
if sourceTableName == "" {
return connection.QueryResult{Success: false, Message: a.appText("db.backend.error.table_name_required", nil)}
}
if err := ensureConnectionAllowsStructureEdit(config, "connection.backend.action.copy_table"); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if err := ensureConnectionAllowsDataImport(config, "connection.backend.action.copy_table"); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
dbType := resolveDDLDBType(config)
if strings.EqualFold(strings.TrimSpace(config.Type), "custom") {
dbType = "custom"
}
if !supportsCopyTableDBType(dbType) {
return connection.QueryResult{
Success: false,
Message: a.appText("db.backend.error.table_copy_unsupported", map[string]any{
"source": sourceTableName,
"dbType": dbType,
}),
}
}
schemaName, sourceName := normalizeCopyTableSource(dbType, dbName, sourceSchemaName, sourceTableName)
if sourceName == "" {
return connection.QueryResult{Success: false, Message: a.appText("db.backend.error.table_name_required", nil)}
}
runConfig := buildRunConfigForDDL(config, dbType, dbName)
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if safetyErr := ensureCopyTableSourceIsIndependent(dbInst, dbType, schemaName, sourceName); safetyErr != nil {
return connection.QueryResult{
Success: false,
Message: a.appText("db.backend.error.table_copy_unsafe_storage", map[string]any{
"source": sourceTableName,
"detail": safetyErr.Error(),
}),
}
}
columnMetadata, columnsErr := resolveCopyTableColumnMetadata(dbInst, dbType, schemaName, sourceName)
if columnsErr != nil {
targetName := buildCopyTableTargetName(dbType, sourceName, 1)
errorMessage := columnsErr.Error()
if errors.Is(columnsErr, errCopyTableColumnsMissing) {
errorMessage = a.appText("db.backend.error.table_columns_missing_for_ddl", nil)
}
return connection.QueryResult{
Success: false,
Message: a.appText("db.backend.error.table_copy_create_failed", map[string]any{
"source": sourceTableName,
"target": targetName,
"error": errorMessage,
}),
}
}
var (
targetName string
plan copyTablePlan
)
for attempt := 0; attempt < maxCopyTableCreateAttempts; attempt++ {
targetName = buildCopyTableTargetName(dbType, sourceName, attempt+1)
plan = buildCopyTablePlan(dbType, schemaName, sourceName, targetName, columnMetadata)
auditStatements := []string{plan.createSQL, plan.insertSQL}
for _, statement := range plan.postStatements {
auditStatements = append(auditStatements, statement.sql)
}
auditSQL = strings.Join(auditStatements, ";\n")
if _, createErr := dbInst.Exec(plan.createSQL); createErr != nil {
if isCopyTableAlreadyExistsError(createErr) {
continue
}
return connection.QueryResult{
Success: false,
Message: a.appText("db.backend.error.table_copy_create_failed", map[string]any{
"source": sourceTableName,
"target": targetName,
"error": createErr.Error(),
}),
}
}
if _, insertErr := dbInst.Exec(plan.insertSQL); insertErr != nil {
if cleanupErr := cleanupFailedCopyTable(dbInst, plan.dropSQL, nil); cleanupErr != nil {
logger.Warnf("CopyTable 数据复制失败且清理目标表失败source=%s target=%s copyErr=%v cleanupErr=%v", sourceTableName, targetName, insertErr, cleanupErr)
return connection.QueryResult{
Success: false,
Message: a.appText("db.backend.error.table_copy_cleanup_failed", map[string]any{
"source": sourceTableName,
"target": targetName,
"error": fmt.Sprintf("%v; %v", insertErr, cleanupErr),
}),
}
}
return connection.QueryResult{
Success: false,
Message: a.appText("db.backend.error.table_copy_data_failed", map[string]any{
"source": sourceTableName,
"target": targetName,
"error": insertErr.Error(),
}),
}
}
createdSequenceDropSQLs := make([]string, 0, len(columnMetadata.serialColumns))
for _, statement := range plan.postStatements {
if _, postErr := dbInst.Exec(statement.sql); postErr != nil {
cleanupErr := cleanupFailedCopyTable(dbInst, plan.dropSQL, createdSequenceDropSQLs)
if cleanupErr != nil {
logger.Warnf("CopyTable 完成复制状态失败且清理目标表失败source=%s target=%s copyErr=%v cleanupErr=%v", sourceTableName, targetName, postErr, cleanupErr)
return connection.QueryResult{
Success: false,
Message: a.appText("db.backend.error.table_copy_cleanup_failed", map[string]any{
"source": sourceTableName,
"target": targetName,
"error": fmt.Sprintf("%v; %v", postErr, cleanupErr),
}),
}
}
return connection.QueryResult{
Success: false,
Message: a.appText("db.backend.error.table_copy_data_failed", map[string]any{
"source": sourceTableName,
"target": targetName,
"error": postErr.Error(),
}),
}
}
if statement.createdSequenceDropSQL != "" {
createdSequenceDropSQLs = append(createdSequenceDropSQLs, statement.createdSequenceDropSQL)
}
}
return connection.QueryResult{
Success: true,
Message: a.appText("db.backend.message.table_copied", map[string]any{
"source": sourceTableName,
"target": targetName,
}),
Data: targetName,
}
}
return connection.QueryResult{
Success: false,
Message: a.appText("db.backend.error.table_copy_create_failed", map[string]any{
"source": sourceTableName,
"target": targetName,
"error": "too many concurrent target-name conflicts",
}),
}
}
func supportsCopyTableDBType(dbType string) bool {
switch dbType {
case "mysql", "mariadb", "oceanbase", "postgres":
return true
default:
return false
}
}
func normalizeCopyTableSource(dbType string, dbName string, sourceSchemaName string, sourceTableName string) (string, string) {
databaseName := strings.TrimSpace(dbName)
schemaName := strings.TrimSpace(sourceSchemaName)
sourceName := strings.TrimSpace(sourceTableName)
switch dbType {
case "mysql", "mariadb", "oceanbase":
return databaseName, sourceName
case "postgres":
if schemaName == "" {
return normalizeSchemaAndTableByType(dbType, databaseName, sourceName)
}
if parsedSchema, parsedTable := db.SplitSQLQualifiedName(sourceName); parsedSchema == schemaName && parsedTable != "" {
return schemaName, parsedTable
}
if prefix := schemaName + "."; strings.HasPrefix(sourceName, prefix) {
return schemaName, strings.TrimPrefix(sourceName, prefix)
}
return schemaName, sourceName
default:
return normalizeSchemaAndTableByType(dbType, databaseName, sourceName)
}
}
func buildCopyTablePlan(dbType string, schemaName string, sourceName string, targetName string, metadata copyTableColumnMetadata) copyTablePlan {
sourceTable := quoteTableIdentByType(dbType, schemaName, sourceName)
targetTable := quoteTableIdentByType(dbType, schemaName, targetName)
columnClause, selectClause := buildCopyTableColumnClauses(dbType, metadata.writableColumns)
plan := copyTablePlan{
insertSQL: fmt.Sprintf("INSERT INTO %s%s SELECT %s FROM %s", targetTable, columnClause, selectClause, sourceTable),
dropSQL: fmt.Sprintf("DROP TABLE %s", targetTable),
}
switch dbType {
case "postgres":
plan.createSQL = fmt.Sprintf("CREATE TABLE %s (LIKE %s INCLUDING ALL)", targetTable, sourceTable)
plan.insertSQL = fmt.Sprintf("INSERT INTO %s%s OVERRIDING SYSTEM VALUE SELECT %s FROM %s", targetTable, columnClause, selectClause, sourceTable)
plan.postStatements = buildPostgresCopyTablePostStatements(schemaName, targetName, metadata)
default:
plan.createSQL = fmt.Sprintf("CREATE TABLE %s LIKE %s", targetTable, sourceTable)
}
return plan
}
func buildCopyTableColumnClauses(dbType string, columns []string) (string, string) {
if len(columns) == 0 {
return "", "*"
}
quoted := make([]string, 0, len(columns))
for _, column := range columns {
if name := strings.TrimSpace(column); name != "" {
quoted = append(quoted, quoteIdentByType(dbType, name))
}
}
if len(quoted) == 0 {
return "", "*"
}
joined := strings.Join(quoted, ", ")
return " (" + joined + ")", joined
}
func resolveCopyTableColumnMetadata(dbInst db.Database, dbType string, schemaName string, sourceName string) (copyTableColumnMetadata, error) {
var metadata copyTableColumnMetadata
metadataTableName := sourceName
if dbType == "mysql" || dbType == "mariadb" || dbType == "oceanbase" {
metadataTableName = quoteIdentByType(dbType, sourceName)
}
columns, err := dbInst.GetColumns(schemaName, metadataTableName)
if err != nil {
return metadata, err
}
if len(columns) == 0 {
return metadata, errCopyTableColumnsMissing
}
traits, traitsErr := resolvePostgresCopyTableColumnTraits(dbInst, dbType, schemaName, sourceName)
if traitsErr != nil {
return metadata, traitsErr
}
metadata.writableColumns = make([]string, 0, len(columns))
for _, column := range columns {
name := strings.TrimSpace(column.Name)
if name == "" || isGeneratedCopyTableColumn(column.Extra) {
continue
}
trait := traits[name]
if trait.generated {
continue
}
metadata.writableColumns = append(metadata.writableColumns, name)
if dbType != "postgres" {
continue
}
if trait.identity {
metadata.identityColumns = append(metadata.identityColumns, name)
continue
}
if column.Default != nil && strings.HasPrefix(strings.ToLower(strings.TrimSpace(*column.Default)), "nextval(") {
metadata.serialColumns = append(metadata.serialColumns, name)
}
}
if len(metadata.writableColumns) == 0 {
return copyTableColumnMetadata{}, errCopyTableColumnsMissing
}
if dbType == "postgres" {
metadata.sequenceOptions = make(map[string]postgresCopyTableSequenceOptions, len(metadata.serialColumns)+len(metadata.identityColumns))
sequenceColumns := append(append([]string{}, metadata.serialColumns...), metadata.identityColumns...)
for _, columnName := range sequenceColumns {
options, sequenceErr := resolvePostgresCopyTableSequenceOptions(dbInst, schemaName, sourceName, columnName)
if sequenceErr != nil {
return copyTableColumnMetadata{}, sequenceErr
}
metadata.sequenceOptions[columnName] = options
}
}
return metadata, nil
}
type postgresCopyTableColumnTrait struct {
generated bool
identity bool
}
func resolvePostgresCopyTableColumnTraits(dbInst db.Database, dbType string, schemaName string, sourceName string) (map[string]postgresCopyTableColumnTrait, error) {
if dbType != "postgres" {
return nil, nil
}
traits := map[string]postgresCopyTableColumnTrait{}
query := fmt.Sprintf(`
SELECT a.attname AS column_name,
COALESCE(pg_catalog.to_jsonb(a)->>'attgenerated', '') AS generated_kind,
COALESCE(pg_catalog.to_jsonb(a)->>'attidentity', '') AS identity_kind
FROM pg_catalog.pg_attribute a
JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = %s
AND c.relname = %s
AND a.attnum > 0
AND NOT a.attisdropped`, postgresCopyTableSQLLiteral(schemaName), postgresCopyTableSQLLiteral(sourceName))
rows, _, err := dbInst.Query(query)
if err != nil {
return nil, err
}
for _, row := range rows {
name := copyTableColumnNameFromRow(row)
if name == "" {
continue
}
trait := postgresCopyTableColumnTrait{
generated: copyTableRowString(row, "generated_kind") != "",
identity: copyTableRowString(row, "identity_kind") != "",
}
if trait.generated || trait.identity {
traits[name] = trait
}
}
return traits, nil
}
func resolvePostgresCopyTableSequenceOptions(dbInst db.Database, schemaName string, sourceName string, columnName string) (postgresCopyTableSequenceOptions, error) {
var options postgresCopyTableSequenceOptions
sourceTable := quoteTableIdentByType("postgres", schemaName, sourceName)
query := fmt.Sprintf(`
SELECT pg_catalog.format_type(s.seqtypid, NULL) AS data_type,
s.seqstart,
s.seqincrement,
s.seqmin,
s.seqmax,
s.seqcache,
s.seqcycle
FROM pg_catalog.pg_sequence s
WHERE s.seqrelid = pg_catalog.pg_get_serial_sequence(%s, %s)::regclass`,
postgresCopyTableSQLLiteral(sourceTable),
postgresCopyTableSQLLiteral(columnName),
)
rows, _, err := dbInst.Query(query)
if err != nil {
return options, err
}
if len(rows) != 1 {
return options, fmt.Errorf("sequence metadata not found for column %s", columnName)
}
options.dataType = strings.ToLower(copyTableRowString(rows[0], "data_type"))
switch options.dataType {
case "smallint", "integer", "bigint":
default:
return postgresCopyTableSequenceOptions{}, fmt.Errorf("unsupported sequence data type %q for column %s", options.dataType, columnName)
}
var parseErr error
if options.start, parseErr = copyTableRowInt64(rows[0], "seqstart"); parseErr != nil {
return postgresCopyTableSequenceOptions{}, parseErr
}
if options.increment, parseErr = copyTableRowInt64(rows[0], "seqincrement"); parseErr != nil || options.increment == 0 {
if parseErr == nil {
parseErr = errors.New("sequence increment cannot be zero")
}
return postgresCopyTableSequenceOptions{}, parseErr
}
if options.min, parseErr = copyTableRowInt64(rows[0], "seqmin"); parseErr != nil {
return postgresCopyTableSequenceOptions{}, parseErr
}
if options.max, parseErr = copyTableRowInt64(rows[0], "seqmax"); parseErr != nil {
return postgresCopyTableSequenceOptions{}, parseErr
}
if options.cache, parseErr = copyTableRowInt64(rows[0], "seqcache"); parseErr != nil || options.cache <= 0 {
if parseErr == nil {
parseErr = errors.New("sequence cache must be positive")
}
return postgresCopyTableSequenceOptions{}, parseErr
}
options.cycle = copyTableRowBool(rows[0], "seqcycle")
return options, nil
}
func isGeneratedCopyTableColumn(extra string) bool {
normalized := strings.ToLower(strings.Join(strings.Fields(extra), " "))
return strings.Contains(normalized, "virtual generated") ||
strings.Contains(normalized, "stored generated") ||
normalized == "generated" ||
normalized == "materialized" ||
normalized == "alias"
}
func ensureCopyTableSourceIsIndependent(dbInst db.Database, dbType string, schemaName string, sourceName string) error {
switch dbType {
case "mysql", "mariadb", "oceanbase":
query := fmt.Sprintf(
"SELECT ENGINE AS engine FROM information_schema.tables WHERE HEX(TABLE_SCHEMA) = '%s' AND HEX(TABLE_NAME) = '%s' AND TABLE_TYPE = 'BASE TABLE' LIMIT 1",
mysqlCopyTableMetadataHex(schemaName),
mysqlCopyTableMetadataHex(sourceName),
)
rows, _, err := dbInst.Query(query)
if err != nil {
return fmt.Errorf("storage metadata: %w", err)
}
engine := ""
if len(rows) > 0 {
engine = strings.ToUpper(copyTableRowString(rows[0], "engine"))
}
if !isIndependentMySQLCopyTableEngine(engine) {
if engine == "" {
engine = "<unknown>"
}
return fmt.Errorf("ENGINE=%s", engine)
}
return nil
case "postgres":
query := fmt.Sprintf(`
SELECT c.relkind AS relation_kind,
c.relpersistence AS persistence,
c.relrowsecurity AS row_security
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = %s
AND c.relname = %s
LIMIT 1`, postgresCopyTableSQLLiteral(schemaName), postgresCopyTableSQLLiteral(sourceName))
rows, _, err := dbInst.Query(query)
if err != nil {
return fmt.Errorf("storage metadata: %w", err)
}
if len(rows) == 0 {
return errors.New("relation metadata not found")
}
relationKind := copyTableRowString(rows[0], "relation_kind")
if relationKind != "r" {
return fmt.Errorf("relation_kind=%s", relationKind)
}
persistence := copyTableRowString(rows[0], "persistence")
if persistence != "" && persistence != "p" {
return fmt.Errorf("persistence=%s", persistence)
}
if copyTableRowBool(rows[0], "row_security") {
return errors.New("row_level_security=enabled")
}
return nil
default:
return nil
}
}
func isIndependentMySQLCopyTableEngine(engine string) bool {
switch strings.ToUpper(strings.TrimSpace(engine)) {
case "INNODB", "MYISAM", "MEMORY", "ARCHIVE", "CSV", "NDB", "NDBCLUSTER", "ARIA", "ROCKSDB", "TOKUDB", "COLUMNSTORE":
return true
default:
return false
}
}
func copyTableRowString(row map[string]interface{}, expectedKey string) string {
for key, value := range row {
if strings.EqualFold(strings.TrimSpace(key), expectedKey) && value != nil {
return strings.TrimSpace(fmt.Sprintf("%v", value))
}
}
return ""
}
func copyTableRowBool(row map[string]interface{}, expectedKey string) bool {
switch strings.ToLower(copyTableRowString(row, expectedKey)) {
case "1", "t", "true", "yes", "on":
return true
default:
return false
}
}
func copyTableRowInt64(row map[string]interface{}, expectedKey string) (int64, error) {
value := copyTableRowString(row, expectedKey)
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid %s value %q: %w", expectedKey, value, err)
}
return parsed, nil
}
func buildPostgresCopyTablePostStatements(schemaName string, targetName string, metadata copyTableColumnMetadata) []copyTablePostStatement {
targetTable := quoteTableIdentByType("postgres", schemaName, targetName)
statements := make([]copyTablePostStatement, 0, len(metadata.serialColumns)*4+len(metadata.identityColumns))
for _, columnName := range metadata.serialColumns {
quotedColumn := quoteIdentByType("postgres", columnName)
options := metadata.sequenceOptions[columnName]
sequenceName := buildPostgresCopyTableSequenceName(targetName, columnName)
qualifiedSequence := quoteTableIdentByType("postgres", schemaName, sequenceName)
sequenceRegclass := fmt.Sprintf("%s::regclass", postgresCopyTableSQLLiteral(qualifiedSequence))
cycleClause := "NO CYCLE"
if options.cycle {
cycleClause = "CYCLE"
}
statements = append(statements,
copyTablePostStatement{
sql: fmt.Sprintf(
"CREATE SEQUENCE %s AS %s INCREMENT BY %d MINVALUE %d MAXVALUE %d START WITH %d CACHE %d %s",
qualifiedSequence,
options.dataType,
options.increment,
options.min,
options.max,
options.start,
options.cache,
cycleClause,
),
createdSequenceDropSQL: fmt.Sprintf("DROP SEQUENCE IF EXISTS %s", qualifiedSequence),
},
copyTablePostStatement{sql: fmt.Sprintf("ALTER SEQUENCE %s OWNED BY %s.%s", qualifiedSequence, targetTable, quotedColumn)},
copyTablePostStatement{sql: fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s SET DEFAULT pg_catalog.nextval(%s)", targetTable, quotedColumn, sequenceRegclass)},
copyTablePostStatement{sql: buildPostgresCopyTableSetvalSQL(sequenceRegclass, targetTable, quotedColumn, options)},
)
}
for _, columnName := range metadata.identityColumns {
quotedColumn := quoteIdentByType("postgres", columnName)
options := metadata.sequenceOptions[columnName]
sequenceRegclass := fmt.Sprintf(
"COALESCE(pg_catalog.pg_get_serial_sequence(%s, %s), '')::regclass",
postgresCopyTableSQLLiteral(targetTable),
postgresCopyTableSQLLiteral(columnName),
)
statements = append(statements, copyTablePostStatement{
sql: buildPostgresCopyTableSetvalSQL(sequenceRegclass, targetTable, quotedColumn, options),
})
}
return statements
}
func buildPostgresCopyTableSetvalSQL(sequenceRegclass string, targetTable string, quotedColumn string, options postgresCopyTableSequenceOptions) string {
aggregate := "pg_catalog.max"
if options.increment < 0 {
aggregate = "pg_catalog.min"
}
return fmt.Sprintf(
"SELECT pg_catalog.setval(%s, COALESCE((SELECT %s(%s) FROM %s), %d), EXISTS (SELECT 1 FROM %s))",
sequenceRegclass,
aggregate,
quotedColumn,
targetTable,
options.start,
targetTable,
)
}
func buildPostgresCopyTableSequenceName(targetName string, columnName string) string {
hash := sha256.Sum256([]byte(targetName + "\x00" + columnName))
suffix := fmt.Sprintf("_copyseq_%x", hash[:5])
prefix := strings.Trim(strings.TrimSpace(targetName)+"_"+strings.TrimSpace(columnName), "_")
return truncateUTF8Bytes(prefix, 63-len(suffix)) + suffix
}
func copyTableColumnNameFromRow(row map[string]interface{}) string {
for key, value := range row {
if strings.EqualFold(strings.TrimSpace(key), "column_name") && value != nil {
return strings.TrimSpace(fmt.Sprintf("%v", value))
}
}
return ""
}
func mysqlCopyTableMetadataHex(value string) string {
return fmt.Sprintf("%X", []byte(value))
}
func postgresCopyTableSQLLiteral(value string) string {
for suffix := 0; ; suffix++ {
tag := fmt.Sprintf("$gonavi_copy_%d$", suffix)
if !strings.Contains(value, tag) {
return tag + value + tag
}
}
}
func cleanupFailedCopyTable(dbInst db.Database, dropTableSQL string, createdSequenceDropSQLs []string) error {
cleanupErrors := make([]error, 0, len(createdSequenceDropSQLs)+1)
if _, err := dbInst.Exec(dropTableSQL); err != nil {
cleanupErrors = append(cleanupErrors, err)
}
for index := len(createdSequenceDropSQLs) - 1; index >= 0; index-- {
if _, err := dbInst.Exec(createdSequenceDropSQLs[index]); err != nil {
cleanupErrors = append(cleanupErrors, err)
}
}
return errors.Join(cleanupErrors...)
}
func buildCopyTableTargetName(dbType string, sourceName string, suffix int) string {
suffixText := "_copy" + strconv.Itoa(suffix)
switch dbType {
case "postgres":
return truncateUTF8Bytes(sourceName, 63-len(suffixText)) + suffixText
case "mysql", "mariadb", "oceanbase":
return truncateUTF8Runes(sourceName, 64-utf8.RuneCountInString(suffixText)) + suffixText
default:
return sourceName + suffixText
}
}
func truncateUTF8Bytes(value string, maxBytes int) string {
if maxBytes <= 0 {
return ""
}
if len(value) <= maxBytes {
return value
}
end := maxBytes
for end > 0 && !utf8.ValidString(value[:end]) {
end--
}
return value[:end]
}
func truncateUTF8Runes(value string, maxRunes int) string {
if maxRunes <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= maxRunes {
return value
}
return string(runes[:maxRunes])
}
func isCopyTableAlreadyExistsError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "already exists") ||
strings.Contains(message, "duplicate table") ||
strings.Contains(message, "sqlstate 42p07") ||
strings.Contains(message, "error 1050")
}

View File

@@ -0,0 +1,791 @@
package app
import (
"errors"
"strings"
"testing"
"unicode/utf8"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"GoNavi-Wails/internal/secretstore"
"GoNavi-Wails/internal/sqlaudit"
)
type fakeCopyTableDB struct {
columns []connection.ColumnDefinition
columnsErr error
queryRows []map[string]interface{}
queryErr error
queryFunc func(string) ([]map[string]interface{}, error)
queryQueries []string
sourceEngine *string
pgSafetyRows []map[string]interface{}
sequenceRows []map[string]interface{}
execQueries []string
execFailures map[int]error
}
func (f *fakeCopyTableDB) Connect(connection.ConnectionConfig) error { return nil }
func (f *fakeCopyTableDB) Close() error { return nil }
func (f *fakeCopyTableDB) Ping() error { return nil }
func (f *fakeCopyTableDB) Query(query string) ([]map[string]interface{}, []string, error) {
f.queryQueries = append(f.queryQueries, query)
if strings.Contains(query, "information_schema.tables") && strings.Contains(query, "ENGINE AS engine") {
engine := "InnoDB"
if f.sourceEngine != nil {
engine = *f.sourceEngine
}
return []map[string]interface{}{{"engine": engine}}, nil, nil
}
if strings.Contains(query, "c.relkind AS relation_kind") {
if f.pgSafetyRows != nil {
return f.pgSafetyRows, nil, nil
}
return []map[string]interface{}{{
"relation_kind": "r",
"persistence": "p",
"row_security": false,
}}, nil, nil
}
if strings.Contains(query, "FROM pg_catalog.pg_sequence") {
if f.sequenceRows != nil {
return f.sequenceRows, nil, nil
}
return []map[string]interface{}{{
"data_type": "bigint",
"seqstart": int64(1),
"seqincrement": int64(1),
"seqmin": int64(1),
"seqmax": int64(9223372036854775807),
"seqcache": int64(1),
"seqcycle": false,
}}, nil, nil
}
if f.queryFunc != nil {
rows, err := f.queryFunc(query)
return rows, nil, err
}
return f.queryRows, nil, f.queryErr
}
func (f *fakeCopyTableDB) Exec(query string) (int64, error) {
f.execQueries = append(f.execQueries, query)
if err := f.execFailures[len(f.execQueries)]; err != nil {
return 0, err
}
return 0, nil
}
func (f *fakeCopyTableDB) GetDatabases() ([]string, error) { return nil, nil }
func (f *fakeCopyTableDB) GetTables(string) ([]string, error) {
return nil, nil
}
func (f *fakeCopyTableDB) GetCreateStatement(string, string) (string, error) {
return "", nil
}
func (f *fakeCopyTableDB) GetColumns(string, string) ([]connection.ColumnDefinition, error) {
return f.columns, f.columnsErr
}
func (f *fakeCopyTableDB) GetAllColumns(string) ([]connection.ColumnDefinitionWithTable, error) {
return nil, nil
}
func (f *fakeCopyTableDB) GetIndexes(string, string) ([]connection.IndexDefinition, error) {
return nil, nil
}
func (f *fakeCopyTableDB) GetForeignKeys(string, string) ([]connection.ForeignKeyDefinition, error) {
return nil, nil
}
func (f *fakeCopyTableDB) GetTriggers(string, string) ([]connection.TriggerDefinition, error) {
return nil, nil
}
var _ db.Database = (*fakeCopyTableDB)(nil)
func installCopyTableTestDatabase(t *testing.T, database db.Database) *App {
t.Helper()
originalNewDatabaseFunc := newDatabaseFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
t.Cleanup(func() {
newDatabaseFunc = originalNewDatabaseFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
})
newDatabaseFunc = func(string) (db.Database, error) { return database, nil }
resolveDialConfigWithProxyFunc = func(config connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return config, nil
}
return NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
}
func TestCopyTableMySQLChoosesNextSuffixAndCopiesWritableColumns(t *testing.T) {
database := &fakeCopyTableDB{
columns: []connection.ColumnDefinition{
{Name: "id", Extra: "auto_increment"},
{Name: "name"},
{Name: "search_text", Extra: "STORED GENERATED"},
},
execFailures: map[int]error{1: errors.New("table already exists (Error 1050)")},
}
app := installCopyTableTestDatabase(t, database)
result := app.CopyTable(connection.ConnectionConfig{Type: "mysql", Database: "app"}, "app", "app", "users")
if !result.Success {
t.Fatalf("CopyTable returned failure: %s", result.Message)
}
if result.Data != "users_copy2" {
t.Fatalf("CopyTable target = %#v, want users_copy2", result.Data)
}
want := []string{
"CREATE TABLE `app`.`users_copy1` LIKE `app`.`users`",
"CREATE TABLE `app`.`users_copy2` LIKE `app`.`users`",
"INSERT INTO `app`.`users_copy2` (`id`, `name`) SELECT `id`, `name` FROM `app`.`users`",
}
if len(database.execQueries) != len(want) {
t.Fatalf("Exec count = %d, want %d: %#v", len(database.execQueries), len(want), database.execQueries)
}
for index := range want {
if database.execQueries[index] != want[index] {
t.Fatalf("Exec[%d] = %q, want %q", index, database.execQueries[index], want[index])
}
}
}
func TestCopyTableMySQLKeepsDotsInsideTableName(t *testing.T) {
database := &fakeCopyTableDB{
columns: []connection.ColumnDefinition{{Name: "id"}},
execFailures: map[int]error{1: errors.New("table already exists (Error 1050)")},
}
app := installCopyTableTestDatabase(t, database)
result := app.CopyTable(connection.ConnectionConfig{Type: "mysql", Database: "app"}, "app", "app", "audit.logs")
if !result.Success {
t.Fatalf("CopyTable returned failure: %s", result.Message)
}
if result.Data != "audit.logs_copy2" {
t.Fatalf("CopyTable target = %#v, want audit.logs_copy2", result.Data)
}
want := []string{
"CREATE TABLE `app`.`audit.logs_copy1` LIKE `app`.`audit.logs`",
"CREATE TABLE `app`.`audit.logs_copy2` LIKE `app`.`audit.logs`",
"INSERT INTO `app`.`audit.logs_copy2` (`id`) SELECT `id` FROM `app`.`audit.logs`",
}
if len(database.execQueries) != len(want) {
t.Fatalf("Exec count = %d, want %d: %#v", len(database.execQueries), len(want), database.execQueries)
}
for index := range want {
if database.execQueries[index] != want[index] {
t.Fatalf("Exec[%d] = %q, want %q", index, database.execQueries[index], want[index])
}
}
}
func TestCopyTableStopsWhenColumnMetadataIsUnavailable(t *testing.T) {
tests := []struct {
name string
columns []connection.ColumnDefinition
columnsErr error
wantDetail string
}{
{
name: "query failed",
columnsErr: errors.New("column metadata unavailable"),
wantDetail: "column metadata unavailable",
},
{
name: "empty metadata",
columns: []connection.ColumnDefinition{},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
database := &fakeCopyTableDB{
columns: test.columns,
columnsErr: test.columnsErr,
}
app := installCopyTableTestDatabase(t, database)
wantDetail := test.wantDetail
if test.columnsErr == nil {
wantDetail = app.appText("db.backend.error.table_columns_missing_for_ddl", nil)
}
result := app.CopyTable(connection.ConnectionConfig{Type: "mysql"}, "app", "app", "users")
if result.Success {
t.Fatal("CopyTable unexpectedly succeeded")
}
if wantDetail != "" && !strings.Contains(result.Message, wantDetail) {
t.Fatalf("failure message = %q, want detail %q", result.Message, wantDetail)
}
if len(database.execQueries) != 0 {
t.Fatalf("CopyTable executed SQL without column metadata: %#v", database.execQueries)
}
})
}
}
func TestCopyTableRejectsTableWithoutWritableColumns(t *testing.T) {
database := &fakeCopyTableDB{
columns: []connection.ColumnDefinition{
{Name: "computed_value", Extra: "VIRTUAL GENERATED"},
},
}
app := installCopyTableTestDatabase(t, database)
result := app.CopyTable(connection.ConnectionConfig{Type: "mysql"}, "app", "app", "computed_values")
if result.Success {
t.Fatal("CopyTable unexpectedly succeeded")
}
if len(database.execQueries) != 0 {
t.Fatalf("CopyTable executed SQL without writable columns: %#v", database.execQueries)
}
}
func TestCopyTableWritesOneObjectEditorAuditEvent(t *testing.T) {
originalNewDatabaseFunc := newDatabaseFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
t.Cleanup(func() {
newDatabaseFunc = originalNewDatabaseFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
})
database := &fakeCopyTableDB{columns: []connection.ColumnDefinition{{Name: "id"}, {Name: "name"}}}
newDatabaseFunc = func(string) (db.Database, error) { return database, nil }
resolveDialConfigWithProxyFunc = func(config connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return config, nil
}
app := newSQLAuditTestApp(t)
result := app.CopyTable(connection.ConnectionConfig{Type: "mysql", Database: "app"}, "app", "app", "users")
if !result.Success {
t.Fatalf("CopyTable result = %#v, want audited success", result)
}
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{})
if len(events) != 1 {
t.Fatalf("audit event count = %d, want 1: %#v", len(events), events)
}
event := events[0]
if event.Source != "object_editor" || event.Status != "success" || event.StatementCount != 2 {
t.Fatalf("unexpected CopyTable audit event: %#v", event)
}
if !strings.Contains(event.SQLText, "CREATE TABLE") || !strings.Contains(event.SQLText, "INSERT INTO") {
t.Fatalf("CopyTable audit SQL missing executed statements: %#v", event)
}
}
func TestCopyTableRejectsReferenceStorageEngines(t *testing.T) {
engine := "FEDERATED"
database := &fakeCopyTableDB{sourceEngine: &engine}
app := installCopyTableTestDatabase(t, database)
result := app.CopyTable(connection.ConnectionConfig{Type: "mysql"}, "app", "app", "remote_users")
if result.Success {
t.Fatal("CopyTable unexpectedly succeeded for FEDERATED")
}
if !strings.Contains(result.Message, "ENGINE=FEDERATED") {
t.Fatalf("failure message = %q, want engine detail", result.Message)
}
if len(database.execQueries) != 0 {
t.Fatalf("unsafe engine executed SQL: %#v", database.execQueries)
}
}
func TestCopyTableRejectsPostgresPartitionedAndRLSSources(t *testing.T) {
tests := []struct {
name string
row map[string]interface{}
}{
{name: "partitioned", row: map[string]interface{}{"relation_kind": "p", "persistence": "p", "row_security": false}},
{name: "row security", row: map[string]interface{}{"relation_kind": "r", "persistence": "p", "row_security": true}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
database := &fakeCopyTableDB{pgSafetyRows: []map[string]interface{}{test.row}}
app := installCopyTableTestDatabase(t, database)
result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "app", "public", "orders")
if result.Success {
t.Fatal("CopyTable unexpectedly succeeded")
}
if len(database.execQueries) != 0 {
t.Fatalf("unsafe PostgreSQL source executed SQL: %#v", database.execQueries)
}
})
}
}
func TestBuildCopyTablePlanUsesNativeDialectSyntax(t *testing.T) {
tests := []struct {
name string
dbType string
schema string
source string
target string
columns []string
wantCreate string
wantInsert string
}{
{
name: "postgres",
dbType: "postgres",
schema: "sales",
source: "orders",
target: "orders_copy1",
columns: []string{"id", "total"},
wantCreate: `CREATE TABLE "sales"."orders_copy1" (LIKE "sales"."orders" INCLUDING ALL)`,
wantInsert: `INSERT INTO "sales"."orders_copy1" ("id", "total") OVERRIDING SYSTEM VALUE SELECT "id", "total" FROM "sales"."orders"`,
},
{
name: "mysql",
dbType: "mysql",
schema: "warehouse",
source: "facts",
target: "facts_copy1",
columns: []string{"id", "value"},
wantCreate: "CREATE TABLE `warehouse`.`facts_copy1` LIKE `warehouse`.`facts`",
wantInsert: "INSERT INTO `warehouse`.`facts_copy1` (`id`, `value`) SELECT `id`, `value` FROM `warehouse`.`facts`",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
plan := buildCopyTablePlan(test.dbType, test.schema, test.source, test.target, copyTableColumnMetadata{
writableColumns: test.columns,
})
if plan.createSQL != test.wantCreate {
t.Fatalf("create SQL = %q, want %q", plan.createSQL, test.wantCreate)
}
if plan.insertSQL != test.wantInsert {
t.Fatalf("insert SQL = %q, want %q", plan.insertSQL, test.wantInsert)
}
})
}
}
func TestCopyTablePostgresOmitsGeneratedColumns(t *testing.T) {
database := &fakeCopyTableDB{
columns: []connection.ColumnDefinition{
{Name: "id"},
{Name: "subtotal"},
{Name: "tax"},
{Name: "Amount"},
{Name: "amount"},
},
queryFunc: func(query string) ([]map[string]interface{}, error) {
if strings.Contains(query, "generated_kind") {
return []map[string]interface{}{{"column_name": "Amount", "generated_kind": "s"}}, nil
}
return nil, nil
},
}
app := installCopyTableTestDatabase(t, database)
result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "shop", "sales", "orders")
if !result.Success {
t.Fatalf("CopyTable returned failure: %s", result.Message)
}
if len(database.execQueries) != 2 {
t.Fatalf("Exec count = %d, want 2: %#v", len(database.execQueries), database.execQueries)
}
wantInsert := `INSERT INTO "sales"."orders_copy1" ("id", "subtotal", "tax", "amount") OVERRIDING SYSTEM VALUE SELECT "id", "subtotal", "tax", "amount" FROM "sales"."orders"`
if database.execQueries[1] != wantInsert {
t.Fatalf("insert SQL = %q, want %q", database.execQueries[1], wantInsert)
}
}
func TestCopyTablePostgresRebuildsSerialAndAdvancesIdentitySequences(t *testing.T) {
serialDefault := "nextval('sales.orders_id_seq'::regclass)"
database := &fakeCopyTableDB{
columns: []connection.ColumnDefinition{
{Name: "id", Default: &serialDefault},
{Name: "external_id"},
{Name: "name"},
},
queryFunc: func(query string) ([]map[string]interface{}, error) {
if strings.Contains(query, "identity_kind") {
return []map[string]interface{}{{"column_name": "external_id", "identity_kind": "a"}}, nil
}
return nil, nil
},
sequenceRows: []map[string]interface{}{{
"data_type": "integer",
"seqstart": int64(100),
"seqincrement": int64(-1),
"seqmin": int64(-2147483648),
"seqmax": int64(100),
"seqcache": int64(5),
"seqcycle": true,
}},
}
app := installCopyTableTestDatabase(t, database)
result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "shop", "sales", "orders")
if !result.Success {
t.Fatalf("CopyTable returned failure: %s", result.Message)
}
if len(database.execQueries) != 7 {
t.Fatalf("Exec count = %d, want 7: %#v", len(database.execQueries), database.execQueries)
}
sequenceName := buildPostgresCopyTableSequenceName("orders_copy1", "id")
wantCreateSequence := `CREATE SEQUENCE "sales"."` + sequenceName + `" AS integer INCREMENT BY -1 MINVALUE -2147483648 MAXVALUE 100 START WITH 100 CACHE 5 CYCLE`
if database.execQueries[2] != wantCreateSequence {
t.Fatalf("serial sequence create SQL = %q", database.execQueries[2])
}
if !strings.Contains(database.execQueries[4], `SET DEFAULT pg_catalog.nextval($gonavi_copy_0$"sales"."`+sequenceName+`"$gonavi_copy_0$::regclass)`) {
t.Fatalf("serial default was not rewired: %q", database.execQueries[4])
}
if !strings.Contains(database.execQueries[6], `pg_catalog.pg_get_serial_sequence($gonavi_copy_0$"sales"."orders_copy1"$gonavi_copy_0$, $gonavi_copy_0$external_id$gonavi_copy_0$)`) {
t.Fatalf("identity sequence was not advanced: %q", database.execQueries[6])
}
if !strings.Contains(database.execQueries[5], `pg_catalog.min("id")`) || !strings.Contains(database.execQueries[6], `pg_catalog.min("external_id")`) {
t.Fatalf("descending sequences were not calibrated with MIN: %#v", database.execQueries[5:])
}
}
func TestCopyTableMetadataQueriesEncodeAdversarialIdentifiers(t *testing.T) {
t.Run("mysql hex predicates", func(t *testing.T) {
database := &fakeCopyTableDB{}
schemaName := `app'; DROP TABLE audit_log; --`
tableName := "users` WHERE 1=1; --"
if err := ensureCopyTableSourceIsIndependent(database, "mysql", schemaName, tableName); err != nil {
t.Fatalf("metadata query failed: %v", err)
}
if len(database.queryQueries) != 1 {
t.Fatalf("query count = %d, want 1", len(database.queryQueries))
}
query := database.queryQueries[0]
if strings.Contains(query, schemaName) || strings.Contains(query, tableName) {
t.Fatalf("MySQL metadata query contains a raw identifier: %s", query)
}
for _, identifier := range []string{schemaName, tableName} {
if encoded := mysqlCopyTableMetadataHex(identifier); !strings.Contains(query, "'"+encoded+"'") {
t.Fatalf("MySQL metadata query does not contain HEX(%q): %s", identifier, query)
}
}
})
t.Run("postgres dollar quoted predicates", func(t *testing.T) {
database := &fakeCopyTableDB{}
schemaName := `sales$gonavi_copy_0$'; DROP SCHEMA public CASCADE; --`
tableName := `orders'; DROP TABLE audit_log; --`
if err := ensureCopyTableSourceIsIndependent(database, "postgres", schemaName, tableName); err != nil {
t.Fatalf("metadata query failed: %v", err)
}
if len(database.queryQueries) != 1 {
t.Fatalf("query count = %d, want 1", len(database.queryQueries))
}
query := database.queryQueries[0]
for _, identifier := range []string{schemaName, tableName} {
literal := postgresCopyTableSQLLiteral(identifier)
if !strings.Contains(query, literal) {
t.Fatalf("PostgreSQL metadata query does not contain protected literal %q: %s", literal, query)
}
tagEnd := strings.Index(literal[1:], "$") + 1
if tagEnd <= 0 {
t.Fatalf("invalid dollar-quoted literal: %q", literal)
}
tag := literal[:tagEnd+1]
if strings.Count(literal, tag) != 2 {
t.Fatalf("dollar quote tag %q can be closed by identifier %q", tag, identifier)
}
}
})
}
func TestCopyTablePostgresStopsWhenColumnTraitsCannotBeVerified(t *testing.T) {
database := &fakeCopyTableDB{
columns: []connection.ColumnDefinition{{Name: "id"}},
queryFunc: func(query string) ([]map[string]interface{}, error) {
if strings.Contains(query, "identity_kind") {
return nil, errors.New("catalog unavailable")
}
return nil, nil
},
}
app := installCopyTableTestDatabase(t, database)
result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "shop", "sales", "orders")
if result.Success {
t.Fatal("CopyTable unexpectedly succeeded")
}
if !strings.Contains(result.Message, "catalog unavailable") {
t.Fatalf("failure message = %q, want catalog error", result.Message)
}
if len(database.execQueries) != 0 {
t.Fatalf("CopyTable executed SQL without verified traits: %#v", database.execQueries)
}
}
func TestCopyTablePostgresCleansCreatedSequenceWhenFinalizationFails(t *testing.T) {
serialDefault := "nextval('sales.orders_id_seq'::regclass)"
database := &fakeCopyTableDB{
columns: []connection.ColumnDefinition{{Name: "id", Default: &serialDefault}},
execFailures: map[int]error{
4: errors.New("sequence ownership failed"),
},
}
app := installCopyTableTestDatabase(t, database)
result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "shop", "sales", "orders")
if result.Success {
t.Fatal("CopyTable unexpectedly succeeded")
}
if len(database.execQueries) != 6 {
t.Fatalf("Exec count = %d, want 6: %#v", len(database.execQueries), database.execQueries)
}
if database.execQueries[4] != `DROP TABLE "sales"."orders_copy1"` {
t.Fatalf("target table cleanup SQL = %q", database.execQueries[4])
}
if !strings.HasPrefix(database.execQueries[5], `DROP SEQUENCE IF EXISTS "sales".`) {
t.Fatalf("orphan sequence cleanup SQL = %q", database.execQueries[5])
}
}
func TestBuildCopyTableTargetNameReservesSuffixWithinIdentifierLimit(t *testing.T) {
postgresSource := strings.Repeat("表", 21)
postgresTarget := buildCopyTableTargetName("postgres", postgresSource, 1)
if len(postgresTarget) > 63 || !utf8.ValidString(postgresTarget) || !strings.HasSuffix(postgresTarget, "_copy1") {
t.Fatalf("invalid PostgreSQL copy name %q (%d bytes)", postgresTarget, len(postgresTarget))
}
mysqlSource := strings.Repeat("表", 64)
mysqlTarget := buildCopyTableTargetName("mysql", mysqlSource, 1)
if utf8.RuneCountInString(mysqlTarget) > 64 || !strings.HasSuffix(mysqlTarget, "_copy1") {
t.Fatalf("invalid MySQL copy name %q (%d chars)", mysqlTarget, utf8.RuneCountInString(mysqlTarget))
}
longPostgresSource := strings.Repeat("x", 63)
tenthTarget := buildCopyTableTargetName("postgres", longPostgresSource, 10)
if len(tenthTarget) > 63 || !strings.HasSuffix(tenthTarget, "_copy10") {
t.Fatalf("invalid two-digit PostgreSQL copy name %q", tenthTarget)
}
}
func TestCopyTableAlreadyExistsErrorMatchesSupportedDrivers(t *testing.T) {
tests := []struct {
message string
want bool
}{
{message: `ERROR: relation "orders_copy1" already exists (SQLSTATE 42P07)`, want: true},
{message: "Error 1050 (42S01): Table 'orders_copy1' already exists", want: true},
{message: "Code: 57, table already exists", want: true},
{message: "Code: 57, unrelated ClickHouse error", want: false},
{message: "permission denied", want: false},
}
for _, test := range tests {
if got := isCopyTableAlreadyExistsError(errors.New(test.message)); got != test.want {
t.Fatalf("isCopyTableAlreadyExistsError(%q) = %v, want %v", test.message, got, test.want)
}
}
}
func TestCopyTableRetriesCreateTimeNameConflict(t *testing.T) {
database := &fakeCopyTableDB{
columns: []connection.ColumnDefinition{{Name: "id"}},
execFailures: map[int]error{1: errors.New("relation already exists (SQLSTATE 42P07)")},
}
app := installCopyTableTestDatabase(t, database)
result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "app", "public", "orders")
if !result.Success {
t.Fatalf("CopyTable returned failure: %s", result.Message)
}
if result.Data != "orders_copy2" {
t.Fatalf("CopyTable target = %#v, want orders_copy2", result.Data)
}
if len(database.execQueries) != 3 {
t.Fatalf("Exec count = %d, want 3: %#v", len(database.execQueries), database.execQueries)
}
if !strings.Contains(database.execQueries[1], `"orders_copy2"`) || !strings.Contains(database.execQueries[2], `"orders_copy2"`) {
t.Fatalf("retry did not use orders_copy2: %#v", database.execQueries)
}
}
func TestCopyTableRetriesConflictWhenPostgresSchemaContainsDot(t *testing.T) {
database := &fakeCopyTableDB{
columns: []connection.ColumnDefinition{{Name: "id"}},
execFailures: map[int]error{1: errors.New("relation already exists (SQLSTATE 42P07)")},
}
app := installCopyTableTestDatabase(t, database)
result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "app", "sales.region", "orders")
if !result.Success {
t.Fatalf("CopyTable returned failure: %s", result.Message)
}
if result.Data != "orders_copy2" {
t.Fatalf("CopyTable target = %#v, want orders_copy2", result.Data)
}
if len(database.execQueries) != 3 {
t.Fatalf("Exec count = %d, want 3: %#v", len(database.execQueries), database.execQueries)
}
if !strings.Contains(database.execQueries[1], `"sales.region"."orders_copy2"`) {
t.Fatalf("retry did not advance inside dotted schema: %#v", database.execQueries)
}
}
func TestCopyTableRetriesConflictWhenPostgresTableContainsDot(t *testing.T) {
database := &fakeCopyTableDB{
columns: []connection.ColumnDefinition{{Name: "id"}},
execFailures: map[int]error{1: errors.New("relation already exists (SQLSTATE 42P07)")},
}
app := installCopyTableTestDatabase(t, database)
result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "app", "public", "orders.archive")
if !result.Success {
t.Fatalf("CopyTable returned failure: %s", result.Message)
}
if result.Data != "orders.archive_copy2" {
t.Fatalf("CopyTable target = %#v, want orders.archive_copy2", result.Data)
}
if len(database.execQueries) != 3 {
t.Fatalf("Exec count = %d, want 3: %#v", len(database.execQueries), database.execQueries)
}
if !strings.Contains(database.execQueries[1], `"public"."orders.archive_copy2"`) {
t.Fatalf("retry did not advance dotted table as one identifier: %#v", database.execQueries)
}
}
func TestNormalizeCopyTableSourceUsesExplicitPostgresSchema(t *testing.T) {
tests := []struct {
name string
schema string
source string
wantSource string
}{
{name: "dotted schema", schema: "sales.region", source: "sales.region.orders", wantSource: "orders"},
{name: "qualified dotted table", schema: "public", source: "public.orders.archive", wantSource: "orders.archive"},
{name: "unqualified dotted table", schema: "public", source: "orders.archive", wantSource: "orders.archive"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
gotSchema, gotSource := normalizeCopyTableSource("postgres", "app", test.schema, test.source)
if gotSchema != test.schema || gotSource != test.wantSource {
t.Fatalf("normalizeCopyTableSource = (%q, %q), want (%q, %q)", gotSchema, gotSource, test.schema, test.wantSource)
}
})
}
}
func TestCopyTableDropsPartialTargetWhenInsertFails(t *testing.T) {
database := &fakeCopyTableDB{
columns: []connection.ColumnDefinition{{Name: "id"}},
execFailures: map[int]error{2: errors.New("copy rows failed")},
}
app := installCopyTableTestDatabase(t, database)
result := app.CopyTable(connection.ConnectionConfig{Type: "mysql"}, "app", "app", "users")
if result.Success {
t.Fatal("CopyTable unexpectedly succeeded")
}
if !strings.Contains(result.Message, "copy rows failed") {
t.Fatalf("failure message does not retain insert error: %q", result.Message)
}
if len(database.execQueries) != 3 || database.execQueries[2] != "DROP TABLE `app`.`users_copy1`" {
t.Fatalf("partial target cleanup = %#v, want DROP TABLE users_copy1", database.execQueries)
}
}
func TestCopyTableReportsInsertAndCleanupFailures(t *testing.T) {
database := &fakeCopyTableDB{
columns: []connection.ColumnDefinition{{Name: "id"}},
execFailures: map[int]error{
2: errors.New("copy rows failed"),
3: errors.New("cleanup failed"),
},
}
app := installCopyTableTestDatabase(t, database)
result := app.CopyTable(connection.ConnectionConfig{Type: "mysql"}, "app", "app", "users")
if result.Success {
t.Fatal("CopyTable unexpectedly succeeded")
}
if !strings.Contains(result.Message, "copy rows failed") || !strings.Contains(result.Message, "cleanup failed") {
t.Fatalf("failure message = %q, want both errors", result.Message)
}
}
func TestCopyTableProtectionBlocksBeforeOpeningDatabase(t *testing.T) {
tests := []struct {
name string
protection connection.ConnectionProtectionConfig
}{
{name: "structure", protection: connection.ConnectionProtectionConfig{RestrictStructureEdit: true}},
{name: "import", protection: connection.ConnectionProtectionConfig{RestrictDataImport: true}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
opened := false
originalNewDatabaseFunc := newDatabaseFunc
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
newDatabaseFunc = func(string) (db.Database, error) {
opened = true
return &fakeCopyTableDB{}, nil
}
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
result := app.CopyTable(connection.ConnectionConfig{Type: "mysql", Protection: test.protection}, "app", "app", "users")
if result.Success {
t.Fatal("CopyTable unexpectedly succeeded")
}
if opened {
t.Fatal("CopyTable opened a database despite connection protection")
}
})
}
}
func TestCopyTableRejectsUnsupportedDatabaseWithoutOpeningConnection(t *testing.T) {
for _, dbType := range []string{"oracle", "clickhouse", "diros", "starrocks", "kingbase", "highgo", "vastbase", "opengauss", "gaussdb"} {
t.Run(dbType, func(t *testing.T) {
opened := false
originalNewDatabaseFunc := newDatabaseFunc
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
newDatabaseFunc = func(string) (db.Database, error) {
opened = true
return &fakeCopyTableDB{}, nil
}
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
result := app.CopyTable(connection.ConnectionConfig{Type: dbType}, "SYSTEM", "", "USERS")
if result.Success {
t.Fatal("CopyTable unexpectedly succeeded")
}
if opened {
t.Fatal("CopyTable opened a database for an unsupported dialect")
}
})
}
t.Run("custom OceanBase Oracle", func(t *testing.T) {
opened := false
originalNewDatabaseFunc := newDatabaseFunc
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
newDatabaseFunc = func(string) (db.Database, error) {
opened = true
return &fakeCopyTableDB{}, nil
}
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
result := app.CopyTable(connection.ConnectionConfig{
Type: "custom",
Driver: "oceanbase",
OceanBaseProtocol: "oracle",
}, "SYSTEM", "", "USERS")
if result.Success || opened {
t.Fatalf("custom OceanBase Oracle result=%#v opened=%v, want unsupported without connection", result, opened)
}
})
}

View File

@@ -231,12 +231,7 @@ func (m *MariaDB) GetCreateStatement(dbName, tableName string) (string, error) {
}
func (m *MariaDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
query := fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`.`%s`", dbName, tableName)
if dbName == "" {
query = fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`", tableName)
}
data, _, err := m.Query(query)
data, _, err := m.Query(buildMySQLShowFullColumnsQuery(dbName, tableName))
if err != nil {
return nil, err
}

View File

@@ -1071,6 +1071,10 @@ func buildMySQLShowCreateTableQuery(dbName, tableName string) string {
return "SHOW CREATE TABLE " + mysqlQualifiedTableIdentifier(dbName, tableName)
}
func buildMySQLShowFullColumnsQuery(dbName, tableName string) string {
return "SHOW FULL COLUMNS FROM " + mysqlQualifiedTableIdentifier(dbName, tableName)
}
func (m *MySQLDB) GetCreateStatement(dbName, tableName string) (string, error) {
data, _, err := m.Query(buildMySQLShowCreateTableQuery(dbName, tableName))
if err != nil {
@@ -1086,12 +1090,7 @@ func (m *MySQLDB) GetCreateStatement(dbName, tableName string) (string, error) {
}
func (m *MySQLDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
query := fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`.`%s`", dbName, tableName)
if dbName == "" {
query = fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`", tableName)
}
data, _, err := m.Query(query)
data, _, err := m.Query(buildMySQLShowFullColumnsQuery(dbName, tableName))
if err != nil {
return nil, err
}

View File

@@ -193,3 +193,52 @@ func TestBuildMySQLShowCreateTableQueryNormalizesQuotedIdentifiers(t *testing.T)
})
}
}
func TestBuildMySQLShowFullColumnsQueryEscapesIdentifiers(t *testing.T) {
t.Parallel()
tests := []struct {
name string
dbName string
tableName string
want string
}{
{
name: "plain qualified table",
dbName: "app",
tableName: "users",
want: "SHOW FULL COLUMNS FROM `app`.`users`",
},
{
name: "backticks cannot terminate identifiers",
dbName: "app`prod",
tableName: "audit`log",
want: "SHOW FULL COLUMNS FROM `app``prod`.`audit``log`",
},
{
name: "quoted qualified table overrides database",
dbName: "ignored",
tableName: `"sales.region"."daily.order"`,
want: "SHOW FULL COLUMNS FROM `sales.region`.`daily.order`",
},
{
name: "quoted dotted table remains one identifier",
dbName: "app",
tableName: "`audit.logs`",
want: "SHOW FULL COLUMNS FROM `app`.`audit.logs`",
},
{
name: "table without database",
tableName: "standalone",
want: "SHOW FULL COLUMNS FROM `standalone`",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := buildMySQLShowFullColumnsQuery(tt.dbName, tt.tableName); got != tt.want {
t.Fatalf("buildMySQLShowFullColumnsQuery(%q,%q)=%q,want=%q", tt.dbName, tt.tableName, got, tt.want)
}
})
}
}