mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-29 12:11:23 +08:00
🐛 fix(duckdb): 修复唯一索引识别与多库对象解析
- 合并 DuckDB 约束与索引元数据,恢复唯一索引表的可编辑判定 - 修复 attach 多库场景下 catalog/schema/table 定位混乱问题 - 统一前后端 qualified name 解析,支持带点和带引号对象名 - 补充 DuckDB 元数据与编辑链路回归测试
This commit is contained in:
@@ -58,6 +58,10 @@ func normalizeSchemaAndTable(config connection.ConnectionConfig, dbName string,
|
||||
return targetDB, rawTable
|
||||
}
|
||||
|
||||
if dbType == "duckdb" {
|
||||
return rawDB, rawTable
|
||||
}
|
||||
|
||||
if dbType == "kingbase" {
|
||||
schema, table := db.SplitKingbaseQualifiedName(rawTable)
|
||||
if schema != "" && table != "" {
|
||||
|
||||
@@ -140,6 +140,21 @@ func TestNormalizeSchemaAndTable_OceanBaseOracleUsesSchemaFromDatabaseTree(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSchemaAndTable_DuckDBPreservesQuotedQualifiedName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
schemaOrDb, table := normalizeSchemaAndTable(connection.ConnectionConfig{
|
||||
Type: "duckdb",
|
||||
}, `"analytics.catalog"."main.schema"`, `"daily.events"."2026.06"`)
|
||||
|
||||
if schemaOrDb != `"analytics.catalog"."main.schema"` {
|
||||
t.Fatalf("expected duckdb dbName/catalog path preserved, got %q", schemaOrDb)
|
||||
}
|
||||
if table != `"daily.events"."2026.06"` {
|
||||
t.Fatalf("expected duckdb qualified table preserved, got %q", table)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteTableIdentByType_KingbaseNormalizesQuotedQualifiedTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -320,6 +320,10 @@ func normalizeSchemaAndTableByType(dbType string, dbName string, tableName strin
|
||||
}
|
||||
}
|
||||
|
||||
if dbType == "duckdb" {
|
||||
return rawDB, rawTable
|
||||
}
|
||||
|
||||
if parts := strings.SplitN(rawTable, ".", 2); len(parts) == 2 {
|
||||
schema := strings.TrimSpace(parts[0])
|
||||
table := strings.TrimSpace(parts[1])
|
||||
|
||||
@@ -160,12 +160,22 @@ func (d *DuckDB) GetDatabases() ([]string, error) {
|
||||
}
|
||||
|
||||
func (d *DuckDB) GetTables(dbName string) ([]string, error) {
|
||||
path := normalizeDuckDBObjectPath(dbName, "")
|
||||
query := `
|
||||
SELECT table_schema, table_name
|
||||
SELECT table_catalog, table_schema, table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_type = 'BASE TABLE'
|
||||
AND table_schema NOT IN ('information_schema', 'pg_catalog')
|
||||
ORDER BY table_schema, table_name`
|
||||
ORDER BY table_catalog, table_schema, table_name`
|
||||
if path.Catalog != "" {
|
||||
query = fmt.Sprintf(`
|
||||
SELECT table_catalog, table_schema, table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_type = 'BASE TABLE'
|
||||
AND table_schema NOT IN ('information_schema', 'pg_catalog')
|
||||
AND table_catalog = '%s'
|
||||
ORDER BY table_catalog, table_schema, table_name`, escapeDuckDBLiteral(path.Catalog))
|
||||
}
|
||||
|
||||
data, _, err := d.Query(query)
|
||||
if err != nil {
|
||||
@@ -175,15 +185,19 @@ ORDER BY table_schema, table_name`
|
||||
seen := map[string]struct{}{}
|
||||
var tables []string
|
||||
for _, row := range data {
|
||||
catalog := strings.TrimSpace(duckDBRowString(row, "table_catalog", "database_name"))
|
||||
schema := strings.TrimSpace(duckDBRowString(row, "table_schema"))
|
||||
name := strings.TrimSpace(duckDBRowString(row, "table_name"))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
qualified := name
|
||||
if schema != "" && !strings.EqualFold(schema, "main") {
|
||||
if schema != "" {
|
||||
qualified = schema + "." + name
|
||||
}
|
||||
if catalog != "" && !strings.EqualFold(catalog, "memory") && !strings.EqualFold(catalog, "main") {
|
||||
qualified = catalog + "." + qualified
|
||||
}
|
||||
if _, exists := seen[qualified]; exists {
|
||||
continue
|
||||
}
|
||||
@@ -194,18 +208,29 @@ ORDER BY table_schema, table_name`
|
||||
}
|
||||
|
||||
func (d *DuckDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
schema, pureTable := normalizeDuckDBSchemaAndTable(dbName, tableName)
|
||||
if pureTable == "" {
|
||||
path := normalizeDuckDBObjectPath(dbName, tableName)
|
||||
if path.Object == "" {
|
||||
return "", fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
escapedTable := escapeDuckDBLiteral(pureTable)
|
||||
escapedSchema := escapeDuckDBLiteral(schema)
|
||||
escapedTable := escapeDuckDBLiteral(path.Object)
|
||||
escapedSchema := escapeDuckDBLiteral(path.Schema)
|
||||
escapedCatalog := escapeDuckDBLiteral(path.Catalog)
|
||||
|
||||
queryCandidates := []string{
|
||||
queryCandidates := make([]string, 0, 4)
|
||||
if path.Catalog != "" {
|
||||
queryCandidates = append(queryCandidates, fmt.Sprintf("SELECT sql FROM duckdb_tables() WHERE table_name = '%s' AND schema_name = '%s' AND database_name = '%s' LIMIT 1", escapedTable, escapedSchema, escapedCatalog))
|
||||
}
|
||||
queryCandidates = append(queryCandidates,
|
||||
fmt.Sprintf("SELECT sql FROM duckdb_tables() WHERE table_name = '%s' AND schema_name = '%s' LIMIT 1", escapedTable, escapedSchema),
|
||||
fmt.Sprintf("SELECT sql FROM duckdb_tables() WHERE table_name = '%s' LIMIT 1", escapedTable),
|
||||
fmt.Sprintf("SHOW CREATE TABLE %s", quoteDuckDBQualifiedTable(schema, pureTable)),
|
||||
fmt.Sprintf("SHOW CREATE TABLE %s", quoteDuckDBQualifiedTable(path.Schema, path.Object)),
|
||||
)
|
||||
|
||||
if path.Catalog != "" {
|
||||
queryCandidates = append([]string{
|
||||
fmt.Sprintf("SHOW CREATE TABLE %s.%s", quoteDuckDBIdentifier(path.Catalog), quoteDuckDBQualifiedTable(path.Schema, path.Object)),
|
||||
}, queryCandidates...)
|
||||
}
|
||||
|
||||
for _, query := range queryCandidates {
|
||||
@@ -230,8 +255,8 @@ func (d *DuckDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
}
|
||||
|
||||
func (d *DuckDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
schema, pureTable := normalizeDuckDBSchemaAndTable(dbName, tableName)
|
||||
if pureTable == "" {
|
||||
path := normalizeDuckDBObjectPath(dbName, tableName)
|
||||
if path.Object == "" {
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
@@ -239,52 +264,62 @@ func (d *DuckDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefini
|
||||
SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = '%s' AND table_schema = '%s'
|
||||
ORDER BY ordinal_position`, escapeDuckDBLiteral(pureTable), escapeDuckDBLiteral(schema))
|
||||
ORDER BY ordinal_position`, escapeDuckDBLiteral(path.Object), escapeDuckDBLiteral(path.Schema))
|
||||
if path.Catalog != "" {
|
||||
query = fmt.Sprintf(`
|
||||
SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = '%s' AND table_schema = '%s' AND table_catalog = '%s'
|
||||
ORDER BY ordinal_position`, escapeDuckDBLiteral(path.Object), escapeDuckDBLiteral(path.Schema), escapeDuckDBLiteral(path.Catalog))
|
||||
}
|
||||
|
||||
data, _, err := d.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 && schema != "main" {
|
||||
if len(data) == 0 && path.Schema != "main" {
|
||||
fallbackQuery := fmt.Sprintf(`
|
||||
SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = '%s'
|
||||
ORDER BY ordinal_position`, escapeDuckDBLiteral(pureTable))
|
||||
ORDER BY ordinal_position`, escapeDuckDBLiteral(path.Object))
|
||||
data, _, err = d.Query(fallbackQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var columns []connection.ColumnDefinition
|
||||
for _, row := range data {
|
||||
column := connection.ColumnDefinition{
|
||||
Name: duckDBRowString(row, "column_name"),
|
||||
Type: duckDBRowString(row, "data_type"),
|
||||
Nullable: strings.ToUpper(strings.TrimSpace(duckDBRowString(row, "is_nullable"))),
|
||||
Key: "",
|
||||
Extra: "",
|
||||
Comment: "",
|
||||
}
|
||||
if column.Nullable == "" {
|
||||
column.Nullable = "YES"
|
||||
}
|
||||
if defaultVal := strings.TrimSpace(duckDBRowString(row, "column_default")); defaultVal != "" && defaultVal != "<nil>" {
|
||||
def := defaultVal
|
||||
column.Default = &def
|
||||
}
|
||||
columns = append(columns, column)
|
||||
constraintQuery := buildDuckDBConstraintMetadataQuery(path, true)
|
||||
constraintRows, _, constraintErr := d.Query(constraintQuery)
|
||||
if constraintErr != nil {
|
||||
return nil, constraintErr
|
||||
}
|
||||
return columns, nil
|
||||
if len(constraintRows) == 0 && path.Schema != "main" {
|
||||
fallbackConstraintQuery := buildDuckDBConstraintMetadataQuery(path, false)
|
||||
constraintRows, _, constraintErr = d.Query(fallbackConstraintQuery)
|
||||
if constraintErr != nil {
|
||||
return nil, constraintErr
|
||||
}
|
||||
}
|
||||
|
||||
return buildDuckDBColumnDefinitions(data, constraintRows), nil
|
||||
}
|
||||
|
||||
func (d *DuckDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
path := normalizeDuckDBObjectPath(dbName, "")
|
||||
query := `
|
||||
SELECT table_schema, table_name, column_name, data_type
|
||||
SELECT table_catalog, table_schema, table_name, column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
|
||||
ORDER BY table_schema, table_name, ordinal_position`
|
||||
ORDER BY table_catalog, table_schema, table_name, ordinal_position`
|
||||
if path.Catalog != "" {
|
||||
query = fmt.Sprintf(`
|
||||
SELECT table_catalog, table_schema, table_name, column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
|
||||
AND table_catalog = '%s'
|
||||
ORDER BY table_catalog, table_schema, table_name, ordinal_position`, escapeDuckDBLiteral(path.Catalog))
|
||||
}
|
||||
|
||||
data, _, err := d.Query(query)
|
||||
if err != nil {
|
||||
@@ -293,14 +328,18 @@ ORDER BY table_schema, table_name, ordinal_position`
|
||||
|
||||
columns := make([]connection.ColumnDefinitionWithTable, 0, len(data))
|
||||
for _, row := range data {
|
||||
catalog := strings.TrimSpace(duckDBRowString(row, "table_catalog", "database_name"))
|
||||
schema := strings.TrimSpace(duckDBRowString(row, "table_schema"))
|
||||
tableName := strings.TrimSpace(duckDBRowString(row, "table_name"))
|
||||
if tableName == "" {
|
||||
continue
|
||||
}
|
||||
if schema != "" && !strings.EqualFold(schema, "main") {
|
||||
if schema != "" {
|
||||
tableName = schema + "." + tableName
|
||||
}
|
||||
if catalog != "" && !strings.EqualFold(catalog, "memory") && !strings.EqualFold(catalog, "main") {
|
||||
tableName = catalog + "." + tableName
|
||||
}
|
||||
|
||||
columns = append(columns, connection.ColumnDefinitionWithTable{
|
||||
TableName: tableName,
|
||||
@@ -312,7 +351,38 @@ ORDER BY table_schema, table_name, ordinal_position`
|
||||
}
|
||||
|
||||
func (d *DuckDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||
return []connection.IndexDefinition{}, nil
|
||||
path := normalizeDuckDBObjectPath(dbName, tableName)
|
||||
if path.Object == "" {
|
||||
return nil, fmt.Errorf("表名不能为空")
|
||||
}
|
||||
|
||||
constraintQuery := buildDuckDBConstraintMetadataQuery(path, true)
|
||||
constraintRows, _, err := d.Query(constraintQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(constraintRows) == 0 && path.Schema != "main" {
|
||||
fallbackQuery := buildDuckDBConstraintMetadataQuery(path, false)
|
||||
constraintRows, _, err = d.Query(fallbackQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
indexQuery := buildDuckDBIndexMetadataQuery(path, true)
|
||||
indexRows, _, indexErr := d.Query(indexQuery)
|
||||
if indexErr != nil {
|
||||
return nil, indexErr
|
||||
}
|
||||
if len(indexRows) == 0 && path.Schema != "main" {
|
||||
fallbackIndexQuery := buildDuckDBIndexMetadataQuery(path, false)
|
||||
indexRows, _, indexErr = d.Query(fallbackIndexQuery)
|
||||
if indexErr != nil {
|
||||
return nil, indexErr
|
||||
}
|
||||
}
|
||||
|
||||
return buildDuckDBIndexDefinitions(constraintRows, indexRows), nil
|
||||
}
|
||||
|
||||
func (d *DuckDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||
@@ -344,12 +414,9 @@ func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) er
|
||||
return `"` + n + `"`
|
||||
}
|
||||
|
||||
schema := ""
|
||||
table := strings.TrimSpace(tableName)
|
||||
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||
schema = strings.TrimSpace(parts[0])
|
||||
table = strings.TrimSpace(parts[1])
|
||||
}
|
||||
path := normalizeDuckDBObjectPath("", tableName)
|
||||
schema := path.Schema
|
||||
table := path.Object
|
||||
|
||||
qualifiedTable := quoteIdent(table)
|
||||
if schema != "" {
|
||||
@@ -413,69 +480,3 @@ func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) er
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func normalizeDuckDBSchemaAndTable(dbName string, tableName string) (string, string) {
|
||||
schema := strings.TrimSpace(dbName)
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
if schema == "" {
|
||||
schema = "main"
|
||||
}
|
||||
return schema, table
|
||||
}
|
||||
|
||||
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||
left := strings.TrimSpace(parts[0])
|
||||
right := strings.TrimSpace(parts[1])
|
||||
if left != "" && right != "" {
|
||||
return normalizeDuckDBIdentifier(left), normalizeDuckDBIdentifier(right)
|
||||
}
|
||||
}
|
||||
|
||||
if schema == "" {
|
||||
schema = "main"
|
||||
}
|
||||
return normalizeDuckDBIdentifier(schema), normalizeDuckDBIdentifier(table)
|
||||
}
|
||||
|
||||
func normalizeDuckDBIdentifier(raw string) string {
|
||||
text := strings.TrimSpace(raw)
|
||||
if len(text) >= 2 {
|
||||
first := text[0]
|
||||
last := text[len(text)-1]
|
||||
if (first == '"' && last == '"') || (first == '`' && last == '`') {
|
||||
text = strings.TrimSpace(text[1 : len(text)-1])
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func quoteDuckDBIdentifier(raw string) string {
|
||||
text := normalizeDuckDBIdentifier(raw)
|
||||
return `"` + strings.ReplaceAll(text, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
func quoteDuckDBQualifiedTable(schema string, table string) string {
|
||||
s := strings.TrimSpace(schema)
|
||||
t := strings.TrimSpace(table)
|
||||
if s == "" {
|
||||
return quoteDuckDBIdentifier(t)
|
||||
}
|
||||
return quoteDuckDBIdentifier(s) + "." + quoteDuckDBIdentifier(t)
|
||||
}
|
||||
|
||||
func duckDBRowString(row map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
for rowKey, value := range row {
|
||||
if !strings.EqualFold(rowKey, key) || value == nil {
|
||||
continue
|
||||
}
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func escapeDuckDBLiteral(raw string) string {
|
||||
return strings.ReplaceAll(raw, "'", "''")
|
||||
}
|
||||
|
||||
483
internal/db/duckdb_metadata.go
Normal file
483
internal/db/duckdb_metadata.go
Normal file
@@ -0,0 +1,483 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
type duckDBObjectPath struct {
|
||||
Catalog string
|
||||
Schema string
|
||||
Object string
|
||||
}
|
||||
|
||||
func buildDuckDBConstraintMetadataQuery(path duckDBObjectPath, exact bool) string {
|
||||
base := `
|
||||
SELECT
|
||||
database_name,
|
||||
schema_name,
|
||||
table_name,
|
||||
constraint_name,
|
||||
constraint_type,
|
||||
constraint_column_names
|
||||
FROM duckdb_constraints()
|
||||
WHERE table_name = '%s'
|
||||
AND constraint_type IN ('PRIMARY KEY', 'UNIQUE')`
|
||||
args := []any{escapeDuckDBLiteral(path.Object)}
|
||||
if exact && path.Schema != "" {
|
||||
base += "\n AND schema_name = '%s'"
|
||||
args = append(args, escapeDuckDBLiteral(path.Schema))
|
||||
}
|
||||
if exact && path.Catalog != "" {
|
||||
base += "\n AND database_name = '%s'"
|
||||
args = append(args, escapeDuckDBLiteral(path.Catalog))
|
||||
}
|
||||
base += "\nORDER BY database_name, schema_name, table_name, constraint_type, constraint_name"
|
||||
return fmt.Sprintf(base, args...)
|
||||
}
|
||||
|
||||
func buildDuckDBIndexMetadataQuery(path duckDBObjectPath, exact bool) string {
|
||||
base := `
|
||||
SELECT
|
||||
database_name,
|
||||
schema_name,
|
||||
table_name,
|
||||
index_name,
|
||||
is_unique,
|
||||
expressions
|
||||
FROM duckdb_indexes()
|
||||
WHERE table_name = '%s'`
|
||||
args := []any{escapeDuckDBLiteral(path.Object)}
|
||||
if exact && path.Schema != "" {
|
||||
base += "\n AND schema_name = '%s'"
|
||||
args = append(args, escapeDuckDBLiteral(path.Schema))
|
||||
}
|
||||
if exact && path.Catalog != "" {
|
||||
base += "\n AND database_name = '%s'"
|
||||
args = append(args, escapeDuckDBLiteral(path.Catalog))
|
||||
}
|
||||
base += "\nORDER BY database_name, schema_name, table_name, index_name"
|
||||
return fmt.Sprintf(base, args...)
|
||||
}
|
||||
|
||||
func buildDuckDBColumnDefinitions(rows []map[string]interface{}, constraintRows []map[string]interface{}) []connection.ColumnDefinition {
|
||||
primaryKeyColumns := make(map[string]struct{})
|
||||
uniqueColumns := make(map[string]struct{})
|
||||
|
||||
for _, row := range constraintRows {
|
||||
columnNames := parseDuckDBIdentifierList(duckDBRowString(row, "constraint_column_names"))
|
||||
switch strings.ToUpper(strings.TrimSpace(duckDBRowString(row, "constraint_type"))) {
|
||||
case "PRIMARY KEY":
|
||||
for _, columnName := range columnNames {
|
||||
primaryKeyColumns[strings.ToLower(columnName)] = struct{}{}
|
||||
}
|
||||
case "UNIQUE":
|
||||
for _, columnName := range columnNames {
|
||||
uniqueColumns[strings.ToLower(columnName)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
columns := make([]connection.ColumnDefinition, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
columnName := strings.TrimSpace(duckDBRowString(row, "column_name"))
|
||||
column := connection.ColumnDefinition{
|
||||
Name: columnName,
|
||||
Type: duckDBRowString(row, "data_type"),
|
||||
Nullable: strings.ToUpper(strings.TrimSpace(duckDBRowString(row, "is_nullable"))),
|
||||
Key: "",
|
||||
Extra: "",
|
||||
Comment: "",
|
||||
}
|
||||
if column.Nullable == "" {
|
||||
column.Nullable = "YES"
|
||||
}
|
||||
if _, ok := primaryKeyColumns[strings.ToLower(columnName)]; ok {
|
||||
column.Key = "PRI"
|
||||
} else if _, ok := uniqueColumns[strings.ToLower(columnName)]; ok {
|
||||
column.Key = "UNI"
|
||||
}
|
||||
if defaultVal := strings.TrimSpace(duckDBRowString(row, "column_default")); defaultVal != "" && defaultVal != "<nil>" {
|
||||
def := defaultVal
|
||||
column.Default = &def
|
||||
}
|
||||
columns = append(columns, column)
|
||||
}
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
func buildDuckDBIndexDefinitions(constraintRows []map[string]interface{}, indexRows []map[string]interface{}) []connection.IndexDefinition {
|
||||
indexes := make([]connection.IndexDefinition, 0, len(constraintRows)+len(indexRows))
|
||||
|
||||
for _, row := range constraintRows {
|
||||
name := strings.TrimSpace(duckDBRowString(row, "constraint_name"))
|
||||
constraintType := strings.ToUpper(strings.TrimSpace(duckDBRowString(row, "constraint_type")))
|
||||
columnNames := parseDuckDBIdentifierList(duckDBRowString(row, "constraint_column_names"))
|
||||
if name == "" || len(columnNames) == 0 {
|
||||
continue
|
||||
}
|
||||
for idx, columnName := range columnNames {
|
||||
indexes = append(indexes, connection.IndexDefinition{
|
||||
Name: name,
|
||||
ColumnName: columnName,
|
||||
NonUnique: 0,
|
||||
SeqInIndex: idx + 1,
|
||||
IndexType: constraintType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range indexRows {
|
||||
name := strings.TrimSpace(duckDBRowString(row, "index_name"))
|
||||
columnNames := parseDuckDBExpressionList(duckDBRowString(row, "expressions"))
|
||||
if name == "" || len(columnNames) == 0 {
|
||||
continue
|
||||
}
|
||||
nonUnique := 1
|
||||
if duckDBRowBool(row, "is_unique") {
|
||||
nonUnique = 0
|
||||
}
|
||||
for idx, columnName := range columnNames {
|
||||
indexes = append(indexes, connection.IndexDefinition{
|
||||
Name: name,
|
||||
ColumnName: columnName,
|
||||
NonUnique: nonUnique,
|
||||
SeqInIndex: idx + 1,
|
||||
IndexType: "INDEX",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return indexes
|
||||
}
|
||||
|
||||
func normalizeDuckDBObjectPath(dbName string, tableName string) duckDBObjectPath {
|
||||
rawDB := strings.TrimSpace(dbName)
|
||||
rawTable := strings.TrimSpace(tableName)
|
||||
if rawTable == "" {
|
||||
if rawDB == "" {
|
||||
return duckDBObjectPath{Schema: "main"}
|
||||
}
|
||||
dbParts := splitDuckDBQualifiedName(rawDB)
|
||||
switch len(dbParts) {
|
||||
case 0:
|
||||
return duckDBObjectPath{Schema: "main"}
|
||||
case 1:
|
||||
return duckDBObjectPath{Catalog: normalizeDuckDBIdentifier(dbParts[0])}
|
||||
default:
|
||||
return duckDBObjectPath{
|
||||
Catalog: normalizeDuckDBIdentifier(dbParts[0]),
|
||||
Schema: normalizeDuckDBIdentifier(dbParts[len(dbParts)-1]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parts := splitDuckDBQualifiedName(rawTable)
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
return duckDBObjectPath{Schema: "main"}
|
||||
case 1:
|
||||
schema := "main"
|
||||
if rawDB != "" {
|
||||
dbParts := splitDuckDBQualifiedName(rawDB)
|
||||
if len(dbParts) >= 2 {
|
||||
return duckDBObjectPath{
|
||||
Catalog: normalizeDuckDBIdentifier(dbParts[0]),
|
||||
Schema: normalizeDuckDBIdentifier(dbParts[len(dbParts)-1]),
|
||||
Object: normalizeDuckDBIdentifier(parts[0]),
|
||||
}
|
||||
}
|
||||
schema = normalizeDuckDBIdentifier(rawDB)
|
||||
}
|
||||
return duckDBObjectPath{
|
||||
Schema: schema,
|
||||
Object: normalizeDuckDBIdentifier(parts[0]),
|
||||
}
|
||||
case 2:
|
||||
if rawDB != "" {
|
||||
dbParts := splitDuckDBQualifiedName(rawDB)
|
||||
if len(dbParts) == 1 {
|
||||
return duckDBObjectPath{
|
||||
Catalog: normalizeDuckDBIdentifier(dbParts[0]),
|
||||
Schema: normalizeDuckDBIdentifier(parts[0]),
|
||||
Object: normalizeDuckDBIdentifier(parts[1]),
|
||||
}
|
||||
}
|
||||
if len(dbParts) >= 2 {
|
||||
return duckDBObjectPath{
|
||||
Catalog: normalizeDuckDBIdentifier(dbParts[0]),
|
||||
Schema: normalizeDuckDBIdentifier(parts[0]),
|
||||
Object: normalizeDuckDBIdentifier(parts[1]),
|
||||
}
|
||||
}
|
||||
}
|
||||
return duckDBObjectPath{
|
||||
Schema: normalizeDuckDBIdentifier(parts[0]),
|
||||
Object: normalizeDuckDBIdentifier(parts[1]),
|
||||
}
|
||||
default:
|
||||
return duckDBObjectPath{
|
||||
Catalog: normalizeDuckDBIdentifier(parts[len(parts)-3]),
|
||||
Schema: normalizeDuckDBIdentifier(parts[len(parts)-2]),
|
||||
Object: normalizeDuckDBIdentifier(parts[len(parts)-1]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeDuckDBSchemaAndTable(dbName string, tableName string) (string, string) {
|
||||
path := normalizeDuckDBObjectPath(dbName, tableName)
|
||||
schema := path.Schema
|
||||
if schema == "" {
|
||||
schema = "main"
|
||||
}
|
||||
return schema, path.Object
|
||||
}
|
||||
|
||||
func normalizeDuckDBIdentifier(raw string) string {
|
||||
text := strings.TrimSpace(normalizeSQLIdentifierEscapes(raw))
|
||||
if len(text) >= 2 {
|
||||
first := text[0]
|
||||
last := text[len(text)-1]
|
||||
if (first == '"' && last == '"') || (first == '`' && last == '`') {
|
||||
text = strings.TrimSpace(text[1 : len(text)-1])
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func quoteDuckDBIdentifier(raw string) string {
|
||||
text := normalizeDuckDBIdentifier(raw)
|
||||
return `"` + strings.ReplaceAll(text, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
func quoteDuckDBQualifiedTable(schema string, table string) string {
|
||||
s := strings.TrimSpace(schema)
|
||||
t := strings.TrimSpace(table)
|
||||
if s == "" {
|
||||
return quoteDuckDBIdentifier(t)
|
||||
}
|
||||
return quoteDuckDBIdentifier(s) + "." + quoteDuckDBIdentifier(t)
|
||||
}
|
||||
|
||||
func duckDBRowString(row map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
for rowKey, value := range row {
|
||||
if !strings.EqualFold(rowKey, key) || value == nil {
|
||||
continue
|
||||
}
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func duckDBRowBool(row map[string]interface{}, keys ...string) bool {
|
||||
value := strings.TrimSpace(strings.ToLower(duckDBRowString(row, keys...)))
|
||||
return value == "true" || value == "1" || value == "yes"
|
||||
}
|
||||
|
||||
func duckDBRowInt(row map[string]interface{}, keys ...string) int {
|
||||
raw := strings.TrimSpace(duckDBRowString(row, keys...))
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
var value int
|
||||
_, _ = fmt.Sscanf(raw, "%d", &value)
|
||||
return value
|
||||
}
|
||||
|
||||
func parseDuckDBIdentifierList(raw string) []string {
|
||||
return parseDuckDBList(raw, true)
|
||||
}
|
||||
|
||||
func parseDuckDBExpressionList(raw string) []string {
|
||||
values := parseDuckDBList(raw, false)
|
||||
if len(values) == 0 {
|
||||
return values
|
||||
}
|
||||
normalized := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
switch {
|
||||
case trimmed == "":
|
||||
continue
|
||||
case isDuckDBSimpleIdentifierExpression(trimmed):
|
||||
normalized = append(normalized, normalizeDuckDBIdentifier(trimmed))
|
||||
default:
|
||||
normalized = append(normalized, trimmed)
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func parseDuckDBList(raw string, normalize bool) []string {
|
||||
text := strings.TrimSpace(normalizeSQLIdentifierEscapes(raw))
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(text, "[") && strings.HasSuffix(text, "]") {
|
||||
text = text[1 : len(text)-1]
|
||||
}
|
||||
values := make([]string, 0)
|
||||
var current strings.Builder
|
||||
inDouble := false
|
||||
inBacktick := false
|
||||
depth := 0
|
||||
|
||||
flush := func() {
|
||||
value := strings.TrimSpace(current.String())
|
||||
current.Reset()
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
if normalize {
|
||||
value = normalizeDuckDBIdentifier(value)
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
|
||||
for i := 0; i < len(text); i++ {
|
||||
ch := text[i]
|
||||
switch ch {
|
||||
case '"':
|
||||
current.WriteByte(ch)
|
||||
if inDouble && i+1 < len(text) && text[i+1] == '"' {
|
||||
current.WriteByte(text[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if !inBacktick {
|
||||
inDouble = !inDouble
|
||||
}
|
||||
case '`':
|
||||
current.WriteByte(ch)
|
||||
if inBacktick && i+1 < len(text) && text[i+1] == '`' {
|
||||
current.WriteByte(text[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if !inDouble {
|
||||
inBacktick = !inBacktick
|
||||
}
|
||||
case '(':
|
||||
current.WriteByte(ch)
|
||||
if !inDouble && !inBacktick {
|
||||
depth++
|
||||
}
|
||||
case ')':
|
||||
current.WriteByte(ch)
|
||||
if !inDouble && !inBacktick && depth > 0 {
|
||||
depth--
|
||||
}
|
||||
case ',':
|
||||
if !inDouble && !inBacktick && depth == 0 {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
current.WriteByte(ch)
|
||||
default:
|
||||
current.WriteByte(ch)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
func splitDuckDBQualifiedName(raw string) []string {
|
||||
text := strings.TrimSpace(normalizeSQLIdentifierEscapes(raw))
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
parts := make([]string, 0, 3)
|
||||
var current strings.Builder
|
||||
inDouble := false
|
||||
inBacktick := false
|
||||
inBracket := false
|
||||
|
||||
flush := func() {
|
||||
value := strings.TrimSpace(current.String())
|
||||
current.Reset()
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
parts = append(parts, value)
|
||||
}
|
||||
|
||||
for i := 0; i < len(text); i++ {
|
||||
ch := text[i]
|
||||
if inDouble {
|
||||
current.WriteByte(ch)
|
||||
if ch == '"' && i+1 < len(text) && text[i+1] == '"' {
|
||||
current.WriteByte(text[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inDouble = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if inBacktick {
|
||||
current.WriteByte(ch)
|
||||
if ch == '`' && i+1 < len(text) && text[i+1] == '`' {
|
||||
current.WriteByte(text[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if ch == '`' {
|
||||
inBacktick = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if inBracket {
|
||||
current.WriteByte(ch)
|
||||
if ch == ']' && i+1 < len(text) && text[i+1] == ']' {
|
||||
current.WriteByte(text[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if ch == ']' {
|
||||
inBracket = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch ch {
|
||||
case '"':
|
||||
inDouble = true
|
||||
current.WriteByte(ch)
|
||||
case '`':
|
||||
inBacktick = true
|
||||
current.WriteByte(ch)
|
||||
case '[':
|
||||
inBracket = true
|
||||
current.WriteByte(ch)
|
||||
case '.':
|
||||
flush()
|
||||
default:
|
||||
current.WriteByte(ch)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
func isDuckDBSimpleIdentifierExpression(raw string) bool {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
if strings.ContainsAny(text, "() +-/*%") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func escapeDuckDBLiteral(raw string) string {
|
||||
return strings.ReplaceAll(raw, "'", "''")
|
||||
}
|
||||
165
internal/db/duckdb_metadata_test.go
Normal file
165
internal/db/duckdb_metadata_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildDuckDBConstraintMetadataQuery_UsesDuckDBConstraints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
query := buildDuckDBConstraintMetadataQuery(duckDBObjectPath{
|
||||
Catalog: "analytics",
|
||||
Schema: "main",
|
||||
Object: "events",
|
||||
}, true)
|
||||
|
||||
if !containsAll(query,
|
||||
"FROM duckdb_constraints()",
|
||||
"constraint_type IN ('PRIMARY KEY', 'UNIQUE')",
|
||||
"database_name = 'analytics'",
|
||||
"schema_name = 'main'",
|
||||
"table_name = 'events'",
|
||||
) {
|
||||
t.Fatalf("DuckDB 约束查询未正确包含 catalog/schema/table 过滤: %s", query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDuckDBIndexMetadataQuery_UsesDuckDBIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
query := buildDuckDBIndexMetadataQuery(duckDBObjectPath{
|
||||
Catalog: "analytics",
|
||||
Schema: "main",
|
||||
Object: "events",
|
||||
}, true)
|
||||
|
||||
if !containsAll(query,
|
||||
"FROM duckdb_indexes()",
|
||||
"database_name = 'analytics'",
|
||||
"schema_name = 'main'",
|
||||
"table_name = 'events'",
|
||||
) {
|
||||
t.Fatalf("DuckDB 索引查询未正确包含 catalog/schema/table 过滤: %s", query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDuckDBColumnDefinitions_MarksPrimaryAndUniqueColumns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
columns := buildDuckDBColumnDefinitions(
|
||||
[]map[string]interface{}{
|
||||
{
|
||||
"column_name": "id",
|
||||
"data_type": "BIGINT",
|
||||
"is_nullable": "NO",
|
||||
"column_default": nil,
|
||||
},
|
||||
{
|
||||
"column_name": "email",
|
||||
"data_type": "VARCHAR",
|
||||
"is_nullable": "YES",
|
||||
"column_default": "'guest@example.com'",
|
||||
},
|
||||
},
|
||||
[]map[string]interface{}{
|
||||
{
|
||||
"constraint_name": "events_pkey",
|
||||
"constraint_type": "PRIMARY KEY",
|
||||
"constraint_column_names": "[id]",
|
||||
},
|
||||
{
|
||||
"constraint_name": "events_email_key",
|
||||
"constraint_type": "UNIQUE",
|
||||
"constraint_column_names": "[email]",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if len(columns) != 2 {
|
||||
t.Fatalf("unexpected column count: %d", len(columns))
|
||||
}
|
||||
if columns[0].Name != "id" || columns[0].Key != "PRI" {
|
||||
t.Fatalf("主键列未正确标记: %+v", columns[0])
|
||||
}
|
||||
if columns[1].Name != "email" || columns[1].Key != "UNI" {
|
||||
t.Fatalf("唯一键列未正确标记: %+v", columns[1])
|
||||
}
|
||||
if columns[1].Default == nil || *columns[1].Default != "'guest@example.com'" {
|
||||
t.Fatalf("默认值未保留: %+v", columns[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDuckDBIndexDefinitions_MergesConstraintsAndUniqueIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
indexes := buildDuckDBIndexDefinitions(
|
||||
[]map[string]interface{}{
|
||||
{
|
||||
"constraint_name": "events_pkey",
|
||||
"constraint_type": "PRIMARY KEY",
|
||||
"constraint_column_names": "[id]",
|
||||
},
|
||||
{
|
||||
"constraint_name": "events_business_key",
|
||||
"constraint_type": "UNIQUE",
|
||||
"constraint_column_names": "[email, region]",
|
||||
},
|
||||
},
|
||||
[]map[string]interface{}{
|
||||
{
|
||||
"index_name": "idx_events_slug",
|
||||
"is_unique": true,
|
||||
"expressions": "[slug]",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if len(indexes) != 4 {
|
||||
t.Fatalf("unexpected index row count: %d", len(indexes))
|
||||
}
|
||||
if indexes[0].Name != "events_pkey" || indexes[0].ColumnName != "id" || indexes[0].NonUnique != 0 {
|
||||
t.Fatalf("主键索引映射异常: %+v", indexes[0])
|
||||
}
|
||||
if indexes[1].Name != "events_business_key" || indexes[1].ColumnName != "email" || indexes[1].SeqInIndex != 1 {
|
||||
t.Fatalf("约束唯一索引首列映射异常: %+v", indexes[1])
|
||||
}
|
||||
if indexes[2].Name != "events_business_key" || indexes[2].ColumnName != "region" || indexes[2].SeqInIndex != 2 {
|
||||
t.Fatalf("约束唯一索引次列映射异常: %+v", indexes[2])
|
||||
}
|
||||
if indexes[3].Name != "idx_events_slug" || indexes[3].ColumnName != "slug" || indexes[3].NonUnique != 0 || indexes[3].IndexType != "INDEX" {
|
||||
t.Fatalf("显式唯一索引映射异常: %+v", indexes[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDuckDBObjectPath_PreservesCatalogSchemaAndQuotedDots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := normalizeDuckDBObjectPath(`"analytics.catalog"."main.schema"`, `"daily.events"."2026.06"`)
|
||||
if path.Catalog != "analytics.catalog" || path.Schema != "daily.events" || path.Object != "2026.06" {
|
||||
t.Fatalf("unexpected duckdb path: %+v", path)
|
||||
}
|
||||
|
||||
qualified := normalizeDuckDBObjectPath(`analytics`, `"main.schema"."daily.events"`)
|
||||
if qualified.Catalog != "analytics" || qualified.Schema != "main.schema" || qualified.Object != "daily.events" {
|
||||
t.Fatalf("unexpected duckdb qualified path without catalog: %+v", qualified)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuckDBExpressionList_KeepsQuotedExpressionsIntact(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parts := parseDuckDBExpressionList(`["slug", lower("name.with.dot")]`)
|
||||
if len(parts) != 2 || parts[0] != `slug` || parts[1] != `lower("name.with.dot")` {
|
||||
t.Fatalf("unexpected expression list: %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func containsAll(source string, needles ...string) bool {
|
||||
for _, needle := range needles {
|
||||
if !strings.Contains(source, needle) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user