mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-22 00:42:47 +08:00
✨ feat(table-copy): 新增整表复制与自动副本命名
- 在表概览及新旧侧栏右键菜单接入复制整表入口与确认、进度和刷新反馈 - 按 source_copyN 原子创建目标表并复制列、索引、默认值及全部数据 - 处理 PostgreSQL 生成列、identity/serial 序列校准与失败清理 - 限定安全数据源与连接保护策略,阻止分区表、RLS 及引用型存储引擎 - 加固 MySQL/PostgreSQL 元数据标识符处理并补齐六语种文案 - 增加后端、能力矩阵、菜单接线和国际化回归测试
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
691
internal/app/methods_table_copy.go
Normal file
691
internal/app/methods_table_copy.go
Normal 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")
|
||||
}
|
||||
791
internal/app/methods_table_copy_test.go
Normal file
791
internal/app/methods_table_copy_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user