Files
MyGoNavi/internal/db/duckdb_metadata.go
Syngnat 5b602bff75 🐛 fix(duckdb): 修复唯一索引元数据安全编辑定位
- DuckDB 显式唯一索引表达式返回字符串包裹标识符时,统一归一化为真实列名

- 补充 DuckDB 主键、唯一约束、显式唯一索引的真实驱动回归测试

- 将 duckdb_metadata.go 纳入 DuckDB driver-agent revision 计算,确保重装驱动后加载新元数据逻辑
2026-06-04 13:52:05 +08:00

612 lines
15 KiB
Go

package db
import (
"fmt"
"reflect"
"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 := duckDBRowIdentifierList(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 := duckDBRowIdentifierList(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 := duckDBRowExpressionList(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 && (strings.EqualFold(dbParts[0], "main") || strings.EqualFold(dbParts[0], "memory")) {
return duckDBObjectPath{
Schema: normalizeDuckDBIdentifier(parts[0]),
Object: normalizeDuckDBIdentifier(parts[1]),
}
}
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 duckDBRowValue(row map[string]interface{}, keys ...string) interface{} {
for _, key := range keys {
for rowKey, value := range row {
if !strings.EqualFold(rowKey, key) {
continue
}
return value
}
}
return nil
}
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 duckDBRowIdentifierList(row map[string]interface{}, keys ...string) []string {
return parseDuckDBListValue(duckDBRowValue(row, keys...), true)
}
func duckDBRowExpressionList(row map[string]interface{}, keys ...string) []string {
return parseDuckDBListValue(duckDBRowValue(row, keys...), false)
}
func parseDuckDBIdentifierList(raw string) []string {
return parseDuckDBList(raw, true)
}
func parseDuckDBExpressionList(raw string) []string {
values := parseDuckDBList(raw, false)
return normalizeDuckDBExpressionList(values)
}
func parseDuckDBListValue(raw interface{}, normalize bool) []string {
if raw == nil {
return nil
}
switch typed := raw.(type) {
case []string:
values := append([]string(nil), typed...)
if normalize {
return normalizeDuckDBIdentifierEntries(values)
}
return normalizeDuckDBExpressionList(values)
case []interface{}:
values := make([]string, 0, len(typed))
for _, item := range typed {
values = append(values, strings.TrimSpace(fmt.Sprintf("%v", item)))
}
if normalize {
return normalizeDuckDBIdentifierEntries(values)
}
return normalizeDuckDBExpressionList(values)
}
rv := reflect.ValueOf(raw)
if rv.IsValid() && rv.Kind() != reflect.String && rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array {
values := parseDuckDBList(strings.TrimSpace(fmt.Sprintf("%v", raw)), normalize)
if !normalize {
return normalizeDuckDBExpressionList(values)
}
return values
}
if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array) {
if rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() == reflect.Uint8 {
values := parseDuckDBList(strings.TrimSpace(fmt.Sprintf("%v", raw)), normalize)
if !normalize {
return normalizeDuckDBExpressionList(values)
}
return values
}
values := make([]string, 0, rv.Len())
for i := 0; i < rv.Len(); i++ {
values = append(values, strings.TrimSpace(fmt.Sprintf("%v", rv.Index(i).Interface())))
}
if normalize {
return normalizeDuckDBIdentifierEntries(values)
}
return normalizeDuckDBExpressionList(values)
}
values := parseDuckDBList(strings.TrimSpace(fmt.Sprintf("%v", raw)), normalize)
if !normalize {
return normalizeDuckDBExpressionList(values)
}
return values
}
func normalizeDuckDBIdentifierEntries(values []string) []string {
normalized := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
normalized = append(normalized, normalizeDuckDBIdentifier(trimmed))
}
return normalized
}
func normalizeDuckDBExpressionList(values []string) []string {
if len(values) == 0 {
return values
}
normalized := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
trimmed = normalizeDuckDBExpressionIdentifierLiteral(trimmed)
switch {
case trimmed == "":
continue
case isDuckDBSimpleIdentifierExpression(trimmed):
normalized = append(normalized, normalizeDuckDBIdentifier(trimmed))
default:
normalized = append(normalized, trimmed)
}
}
return normalized
}
func normalizeDuckDBExpressionIdentifierLiteral(raw string) string {
text := strings.TrimSpace(raw)
if len(text) < 2 || text[0] != '\'' || text[len(text)-1] != '\'' {
return text
}
inner := strings.TrimSpace(text[1 : len(text)-1])
inner = strings.ReplaceAll(inner, `''`, `'`)
inner = normalizeSQLIdentifierEscapes(inner)
if inner == "" {
return text
}
if strings.ContainsAny(inner, "() +-/*%") {
return text
}
if len(inner) >= 2 {
first := inner[0]
last := inner[len(inner)-1]
if (first == '"' && last == '"') || (first == '`' && last == '`') {
return inner
}
}
if strings.ContainsAny(inner, `"'`) {
return text
}
return inner
}
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, "'", "''")
}