feat(mongodb): 支持文档可视化编辑与删除

- 前端表格预览使用 _id 构建 MongoDB 行定位,并隐藏 typed ObjectID locator

- 后端 ApplyChanges 支持 MongoDB 更新、单删和批量删除,区分 ObjectID 与字符串 _id

- 补充 DataViewer、DataGrid 与双版本 Mongo driver 回归测试

Refs #458
This commit is contained in:
Syngnat
2026-05-13 21:48:14 +08:00
parent 2ad2f26b2b
commit 01eb2c25e0
10 changed files with 547 additions and 52 deletions

View File

@@ -40,6 +40,7 @@ func (d *mongoProxyDialer) DialContext(ctx context.Context, network, address str
}
const defaultMongoPort = 27017
const mongoObjectIDLocatorColumn = "__gonavi_mongodb_id_locator__"
func normalizeMongoAddress(host string, port int) string {
h := strings.TrimSpace(host)
@@ -922,6 +923,7 @@ func (m *MongoDB) execFind(ctx context.Context, cmd bson.D) ([]map[string]interf
var skip int64
var sortDoc interface{}
var projection interface{}
var includeObjectIDLocator bool
for _, elem := range cmd {
switch elem.Key {
@@ -937,6 +939,10 @@ func (m *MongoDB) execFind(ctx context.Context, cmd bson.D) ([]map[string]interf
sortDoc = elem.Value
case "projection":
projection = elem.Value
case "__gonaviIncludeObjectIDLocator":
if enabled, ok := elem.Value.(bool); ok {
includeObjectIDLocator = enabled
}
}
}
@@ -978,6 +984,10 @@ func (m *MongoDB) execFind(ctx context.Context, cmd bson.D) ([]map[string]interf
}
row := make(map[string]interface{})
for k, v := range doc {
if includeObjectIDLocator && k == "_id" {
row[mongoObjectIDLocatorColumn] = buildMongoObjectIDLocatorValue(v)
columnSet[mongoObjectIDLocatorColumn] = true
}
row[k] = convertBsonValue(v)
columnSet[k] = true
}
@@ -1006,6 +1016,13 @@ func (m *MongoDB) execFind(ctx context.Context, cmd bson.D) ([]map[string]interf
return data, columns, nil
}
func buildMongoObjectIDLocatorValue(v interface{}) interface{} {
if oid, ok := v.(bson.ObjectID); ok {
return bson.M{"$oid": oid.Hex()}
}
return convertBsonValue(v)
}
// execCount 使用原生 Collection.CountDocuments() 执行计数
func (m *MongoDB) execCount(ctx context.Context, cmd bson.D) ([]map[string]interface{}, []string, error) {
var collName string
@@ -1198,6 +1215,57 @@ func (m *MongoDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDef
return []connection.TriggerDefinition{}, nil
}
func copyMongoChangeDocument(row map[string]interface{}) bson.M {
doc := bson.M{}
for k, v := range row {
doc[k] = v
}
return doc
}
func buildMongoChangeFilter(row map[string]interface{}) bson.M {
filter := bson.M{}
for k, v := range row {
filter[k] = normalizeMongoChangeFilterValue(k, v)
}
return filter
}
func normalizeMongoChangeFilterValue(key string, value interface{}) interface{} {
if strings.TrimSpace(key) != "_id" {
return value
}
switch val := value.(type) {
case map[string]interface{}:
if raw, ok := val["$oid"]; ok {
if oid, parsed := parseMongoObjectIDHex(fmt.Sprintf("%v", raw)); parsed {
return oid
}
}
case bson.M:
if raw, ok := val["$oid"]; ok {
if oid, parsed := parseMongoObjectIDHex(fmt.Sprintf("%v", raw)); parsed {
return oid
}
}
}
return value
}
func parseMongoObjectIDHex(value string) (bson.ObjectID, bool) {
text := strings.TrimSpace(value)
var zero bson.ObjectID
if len(text) != 24 {
return zero, false
}
oid, err := bson.ObjectIDFromHex(text)
if err != nil {
return zero, false
}
return oid, true
}
// ApplyChanges implements batch changes for MongoDB
func (m *MongoDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if m.client == nil {
@@ -1211,23 +1279,21 @@ func (m *MongoDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
// Process deletes
for _, pk := range changes.Deletes {
filter := bson.M{}
for k, v := range pk {
filter[k] = v
}
filter := buildMongoChangeFilter(pk)
if len(filter) > 0 {
if _, err := collection.DeleteOne(ctx, filter); err != nil {
result, err := collection.DeleteOne(ctx, filter)
if err != nil {
return fmt.Errorf("删除失败:%v", err)
}
if result.DeletedCount == 0 {
return fmt.Errorf("删除失败:未匹配到文档")
}
}
}
// Process updates
for _, update := range changes.Updates {
filter := bson.M{}
for k, v := range update.Keys {
filter[k] = v
}
filter := buildMongoChangeFilter(update.Keys)
if len(filter) == 0 {
return fmt.Errorf("更新操作需要主键条件")
}
@@ -1237,17 +1303,18 @@ func (m *MongoDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
updateDoc["$set"].(bson.M)[k] = v
}
if _, err := collection.UpdateOne(ctx, filter, updateDoc); err != nil {
result, err := collection.UpdateOne(ctx, filter, updateDoc)
if err != nil {
return fmt.Errorf("更新失败:%v", err)
}
if result.MatchedCount == 0 {
return fmt.Errorf("更新失败:未匹配到文档")
}
}
// Process inserts
for _, row := range changes.Inserts {
doc := bson.M{}
for k, v := range row {
doc[k] = v
}
doc := copyMongoChangeDocument(row)
if len(doc) > 0 {
if _, err := collection.InsertOne(ctx, doc); err != nil {
return fmt.Errorf("插入失败:%v", err)

View File

@@ -7,6 +7,8 @@ import (
"testing"
"GoNavi-Wails/internal/connection"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestApplyMongoURI_ExplicitHostDoesNotAdoptURIHosts(t *testing.T) {
@@ -65,3 +67,64 @@ func TestMongoURI_MergesConnectionParamsIntoExistingURI(t *testing.T) {
t.Fatalf("uri 未合并已有 URI query 与额外参数:%s", uri)
}
}
func TestBuildMongoChangeFilter_ConvertsExplicitOIDToObjectID(t *testing.T) {
const oidHex = "507f1f77bcf86cd799439011"
filter := buildMongoChangeFilter(map[string]interface{}{
"_id": map[string]interface{}{"$oid": oidHex},
"name": oidHex,
})
gotID, ok := filter["_id"].(bson.ObjectID)
if !ok {
t.Fatalf("expected _id to be bson.ObjectID, got %T", filter["_id"])
}
if gotID.Hex() != oidHex {
t.Fatalf("unexpected ObjectID: got=%s want=%s", gotID.Hex(), oidHex)
}
if filter["name"] != oidHex {
t.Fatalf("non-_id 24 hex string should stay string, got %T %v", filter["name"], filter["name"])
}
}
func TestBuildMongoChangeFilter_LeavesIDHexStringUntouched(t *testing.T) {
const oidHex = "507f1f77bcf86cd799439011"
filter := buildMongoChangeFilter(map[string]interface{}{
"_id": oidHex,
})
if filter["_id"] != oidHex {
t.Fatalf("plain _id string should stay string, got %T %v", filter["_id"], filter["_id"])
}
}
func TestBuildMongoObjectIDLocatorValue_EncodesObjectID(t *testing.T) {
const oidHex = "507f1f77bcf86cd799439011"
oid, err := bson.ObjectIDFromHex(oidHex)
if err != nil {
t.Fatal(err)
}
locator := buildMongoObjectIDLocatorValue(oid)
locatorMap, ok := locator.(bson.M)
if !ok {
t.Fatalf("expected locator bson.M, got %T", locator)
}
if locatorMap["$oid"] != oidHex {
t.Fatalf("unexpected locator value: %v", locatorMap["$oid"])
}
}
func TestCopyMongoChangeDocument_LeavesInsertIDStringUntouched(t *testing.T) {
const oidHex = "507f1f77bcf86cd799439011"
doc := copyMongoChangeDocument(map[string]interface{}{
"_id": oidHex,
})
if doc["_id"] != oidHex {
t.Fatalf("insert _id string should stay string, got %T %v", doc["_id"], doc["_id"])
}
}

View File

@@ -41,6 +41,7 @@ func (d *mongoProxyDialer) DialContext(ctx context.Context, network, address str
}
const defaultMongoPort = 27017
const mongoObjectIDLocatorColumn = "__gonavi_mongodb_id_locator__"
func normalizeMongoAddress(host string, port int) string {
h := strings.TrimSpace(host)
@@ -925,6 +926,7 @@ func (m *MongoDBV1) execFind(ctx context.Context, cmd bson.D) ([]map[string]inte
var skip int64
var sortDoc interface{}
var projection interface{}
var includeObjectIDLocator bool
for _, elem := range cmd {
switch elem.Key {
@@ -940,6 +942,10 @@ func (m *MongoDBV1) execFind(ctx context.Context, cmd bson.D) ([]map[string]inte
sortDoc = elem.Value
case "projection":
projection = elem.Value
case "__gonaviIncludeObjectIDLocator":
if enabled, ok := elem.Value.(bool); ok {
includeObjectIDLocator = enabled
}
}
}
@@ -981,6 +987,10 @@ func (m *MongoDBV1) execFind(ctx context.Context, cmd bson.D) ([]map[string]inte
}
row := make(map[string]interface{})
for k, v := range doc {
if includeObjectIDLocator && k == "_id" {
row[mongoObjectIDLocatorColumn] = buildMongoObjectIDLocatorValue(v)
columnSet[mongoObjectIDLocatorColumn] = true
}
row[k] = convertBsonValue(v)
columnSet[k] = true
}
@@ -1009,6 +1019,13 @@ func (m *MongoDBV1) execFind(ctx context.Context, cmd bson.D) ([]map[string]inte
return data, columns, nil
}
func buildMongoObjectIDLocatorValue(v interface{}) interface{} {
if oid, ok := v.(primitive.ObjectID); ok {
return bson.M{"$oid": oid.Hex()}
}
return convertBsonValue(v)
}
// execCount 使用原生 Collection.CountDocuments() 执行计数
func (m *MongoDBV1) execCount(ctx context.Context, cmd bson.D) ([]map[string]interface{}, []string, error) {
var collName string
@@ -1201,6 +1218,57 @@ func (m *MongoDBV1) GetTriggers(dbName, tableName string) ([]connection.TriggerD
return []connection.TriggerDefinition{}, nil
}
func copyMongoChangeDocument(row map[string]interface{}) bson.M {
doc := bson.M{}
for k, v := range row {
doc[k] = v
}
return doc
}
func buildMongoChangeFilter(row map[string]interface{}) bson.M {
filter := bson.M{}
for k, v := range row {
filter[k] = normalizeMongoChangeFilterValue(k, v)
}
return filter
}
func normalizeMongoChangeFilterValue(key string, value interface{}) interface{} {
if strings.TrimSpace(key) != "_id" {
return value
}
switch val := value.(type) {
case map[string]interface{}:
if raw, ok := val["$oid"]; ok {
if oid, parsed := parseMongoObjectIDHex(fmt.Sprintf("%v", raw)); parsed {
return oid
}
}
case bson.M:
if raw, ok := val["$oid"]; ok {
if oid, parsed := parseMongoObjectIDHex(fmt.Sprintf("%v", raw)); parsed {
return oid
}
}
}
return value
}
func parseMongoObjectIDHex(value string) (primitive.ObjectID, bool) {
text := strings.TrimSpace(value)
var zero primitive.ObjectID
if len(text) != 24 {
return zero, false
}
oid, err := primitive.ObjectIDFromHex(text)
if err != nil {
return zero, false
}
return oid, true
}
// ApplyChanges implements batch changes for MongoDB
func (m *MongoDBV1) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if m.client == nil {
@@ -1214,23 +1282,21 @@ func (m *MongoDBV1) ApplyChanges(tableName string, changes connection.ChangeSet)
// Process deletes
for _, pk := range changes.Deletes {
filter := bson.M{}
for k, v := range pk {
filter[k] = v
}
filter := buildMongoChangeFilter(pk)
if len(filter) > 0 {
if _, err := collection.DeleteOne(ctx, filter); err != nil {
result, err := collection.DeleteOne(ctx, filter)
if err != nil {
return fmt.Errorf("删除失败:%v", err)
}
if result.DeletedCount == 0 {
return fmt.Errorf("删除失败:未匹配到文档")
}
}
}
// Process updates
for _, update := range changes.Updates {
filter := bson.M{}
for k, v := range update.Keys {
filter[k] = v
}
filter := buildMongoChangeFilter(update.Keys)
if len(filter) == 0 {
return fmt.Errorf("更新操作需要主键条件")
}
@@ -1240,17 +1306,18 @@ func (m *MongoDBV1) ApplyChanges(tableName string, changes connection.ChangeSet)
updateDoc["$set"].(bson.M)[k] = v
}
if _, err := collection.UpdateOne(ctx, filter, updateDoc); err != nil {
result, err := collection.UpdateOne(ctx, filter, updateDoc)
if err != nil {
return fmt.Errorf("更新失败:%v", err)
}
if result.MatchedCount == 0 {
return fmt.Errorf("更新失败:未匹配到文档")
}
}
// Process inserts
for _, row := range changes.Inserts {
doc := bson.M{}
for k, v := range row {
doc[k] = v
}
doc := copyMongoChangeDocument(row)
if len(doc) > 0 {
if _, err := collection.InsertOne(ctx, doc); err != nil {
return fmt.Errorf("插入失败:%v", err)

View File

@@ -6,6 +6,9 @@ import (
"testing"
"GoNavi-Wails/internal/connection"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func TestApplyMongoURIV1_ExplicitHostDoesNotAdoptURIHosts(t *testing.T) {
@@ -23,3 +26,64 @@ func TestApplyMongoURIV1_ExplicitHostDoesNotAdoptURIHosts(t *testing.T) {
t.Fatalf("expected hosts to remain empty when explicit host exists, got %v", got.Hosts)
}
}
func TestBuildMongoChangeFilterV1_ConvertsExplicitOIDToObjectID(t *testing.T) {
const oidHex = "507f1f77bcf86cd799439011"
filter := buildMongoChangeFilter(map[string]interface{}{
"_id": map[string]interface{}{"$oid": oidHex},
"name": oidHex,
})
gotID, ok := filter["_id"].(primitive.ObjectID)
if !ok {
t.Fatalf("expected _id to be primitive.ObjectID, got %T", filter["_id"])
}
if gotID.Hex() != oidHex {
t.Fatalf("unexpected ObjectID: got=%s want=%s", gotID.Hex(), oidHex)
}
if filter["name"] != oidHex {
t.Fatalf("non-_id 24 hex string should stay string, got %T %v", filter["name"], filter["name"])
}
}
func TestBuildMongoChangeFilterV1_LeavesIDHexStringUntouched(t *testing.T) {
const oidHex = "507f1f77bcf86cd799439011"
filter := buildMongoChangeFilter(map[string]interface{}{
"_id": oidHex,
})
if filter["_id"] != oidHex {
t.Fatalf("plain _id string should stay string, got %T %v", filter["_id"], filter["_id"])
}
}
func TestBuildMongoObjectIDLocatorValueV1_EncodesObjectID(t *testing.T) {
const oidHex = "507f1f77bcf86cd799439011"
oid, err := primitive.ObjectIDFromHex(oidHex)
if err != nil {
t.Fatal(err)
}
locator := buildMongoObjectIDLocatorValue(oid)
locatorMap, ok := locator.(bson.M)
if !ok {
t.Fatalf("expected locator bson.M, got %T", locator)
}
if locatorMap["$oid"] != oidHex {
t.Fatalf("unexpected locator value: %v", locatorMap["$oid"])
}
}
func TestCopyMongoChangeDocumentV1_LeavesInsertIDStringUntouched(t *testing.T) {
const oidHex = "507f1f77bcf86cd799439011"
doc := copyMongoChangeDocument(map[string]interface{}{
"_id": oidHex,
})
if doc["_id"] != oidHex {
t.Fatalf("insert _id string should stay string, got %T %v", doc["_id"], doc["_id"])
}
}