Enhance duplicate key error handling in MongoDB operations
- Improved the `IsDuplicateKeyError` function to utilize structured error messages for better accuracy in identifying duplicate key errors. - Introduced `duplicateKeyMessages` and `hasStructuredWriteException` functions to parse and extract relevant information from MongoDB error messages. - Updated `DuplicateKeyMatchesField` to match duplicate key errors against specific fields by analyzing error messages rather than relying on substring matching. This refactor enhances the clarity and reliability of duplicate key error handling, ensuring more precise identification of conflicts in MongoDB operations.
This commit is contained in:
+164
-16
@@ -2,43 +2,191 @@ package mongo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
var dupKeyFieldNamePattern = regexp.MustCompile(`(?:^|[,{]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:`)
|
||||
|
||||
// IsDuplicateKeyError reports whether err is a MongoDB E11000 duplicate key error.
|
||||
func IsDuplicateKeyError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(err.Error(), "E11000") || strings.Contains(err.Error(), "duplicate key") {
|
||||
|
||||
if len(duplicateKeyMessages(err)) > 0 {
|
||||
return true
|
||||
}
|
||||
var we mongodriver.WriteException
|
||||
if errors.As(err, &we) {
|
||||
for _, wErr := range we.WriteErrors {
|
||||
if wErr.Code == 11000 {
|
||||
return true
|
||||
|
||||
if hasStructuredWriteException(err) {
|
||||
return false
|
||||
}
|
||||
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "e11000") || strings.Contains(msg, "duplicate key")
|
||||
}
|
||||
|
||||
// DuplicateKeyMatchesField reports whether a duplicate key error involves the given field
|
||||
// by parsing WriteException messages (index name and dup key document keys), not broad
|
||||
// substring matching on the full error text.
|
||||
func DuplicateKeyMatchesField(err error, field string) bool {
|
||||
if err == nil || field == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
field = strings.ToLower(field)
|
||||
msgs := duplicateKeyMessages(err)
|
||||
if len(msgs) == 0 {
|
||||
if hasStructuredWriteException(err) {
|
||||
return false
|
||||
}
|
||||
if !IsDuplicateKeyError(err) {
|
||||
return false
|
||||
}
|
||||
msgs = []string{err.Error()}
|
||||
}
|
||||
for _, msg := range msgs {
|
||||
if duplicateKeyMessageMatchesField(msg, field) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DuplicateKeyMatchesField reports whether a duplicate key error involves the given field
|
||||
// (e.g. "phone" matches index phone_idx or dup key { phone: "..." }).
|
||||
func DuplicateKeyMatchesField(err error, field string) bool {
|
||||
if err == nil || field == "" {
|
||||
func duplicateKeyMessages(err error) []string {
|
||||
var msgs []string
|
||||
|
||||
var writeException mongodriver.WriteException
|
||||
if errors.As(err, &writeException) {
|
||||
for _, writeErr := range writeException.WriteErrors {
|
||||
if writeErr.Code == 11000 {
|
||||
msgs = append(msgs, writeErr.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var bulkWriteException mongodriver.BulkWriteException
|
||||
if errors.As(err, &bulkWriteException) {
|
||||
for _, writeErr := range bulkWriteException.WriteErrors {
|
||||
if writeErr.Code == 11000 {
|
||||
msgs = append(msgs, writeErr.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return msgs
|
||||
}
|
||||
|
||||
func hasStructuredWriteException(err error) bool {
|
||||
var writeException mongodriver.WriteException
|
||||
if errors.As(err, &writeException) {
|
||||
return true
|
||||
}
|
||||
|
||||
var bulkWriteException mongodriver.BulkWriteException
|
||||
return errors.As(err, &bulkWriteException)
|
||||
}
|
||||
|
||||
func duplicateKeyMessageMatchesField(message, field string) bool {
|
||||
message = strings.ToLower(message)
|
||||
|
||||
if indexName := extractDuplicateIndexName(message); indexName != "" {
|
||||
if indexNameMatchesField(indexName, field) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range extractDuplicateKeyFields(message) {
|
||||
if key == field {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
field = strings.ToLower(field)
|
||||
if strings.Contains(msg, field+"_idx") {
|
||||
|
||||
func extractDuplicateIndexName(message string) string {
|
||||
const prefix = "index: "
|
||||
start := strings.Index(message, prefix)
|
||||
if start < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
rest := strings.TrimSpace(message[start+len(prefix):])
|
||||
if rest == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
end := strings.IndexByte(rest, ' ')
|
||||
if end >= 0 {
|
||||
return rest[:end]
|
||||
}
|
||||
|
||||
return rest
|
||||
}
|
||||
|
||||
func indexNameMatchesField(indexName, field string) bool {
|
||||
switch indexName {
|
||||
case field, field + "_idx", field + "_1":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
if strings.Contains(msg, "dup key") && strings.Contains(msg, field) {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(msg, `"`+field+`"`) || strings.Contains(msg, field+":")
|
||||
|
||||
func extractDuplicateKeyFields(message string) []string {
|
||||
const prefix = "dup key:"
|
||||
start := strings.Index(message, prefix)
|
||||
if start < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
document := strings.TrimSpace(message[start+len(prefix):])
|
||||
if !strings.HasPrefix(document, "{") {
|
||||
return nil
|
||||
}
|
||||
|
||||
depth := 0
|
||||
end := -1
|
||||
for i, char := range document {
|
||||
switch char {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
end = i
|
||||
}
|
||||
}
|
||||
if end >= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if end <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
inner := strings.TrimSpace(document[1:end])
|
||||
matches := dupKeyFieldNamePattern.FindAllStringSubmatch(inner, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := make([]string, 0, len(matches))
|
||||
seen := make(map[string]struct{}, len(matches))
|
||||
for _, match := range matches {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(match[1])
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
fields = append(fields, key)
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package mongo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
func TestIsDuplicateKeyError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unrelated error",
|
||||
err: errors.New("connection refused"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "write exception duplicate key",
|
||||
err: writeExceptionWithMessage(
|
||||
11000,
|
||||
`E11000 duplicate key error collection: tm.users index: phone_idx dup key: { phone: "+1234567890" }`,
|
||||
),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "write exception non duplicate code",
|
||||
err: writeExceptionWithMessage(
|
||||
121,
|
||||
`Document failed validation`,
|
||||
),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "wrapped write exception duplicate key",
|
||||
err: fmt.Errorf(
|
||||
"failed to create user: %w",
|
||||
writeExceptionWithMessage(
|
||||
11000,
|
||||
`E11000 duplicate key error collection: tm.users index: username_idx dup key: { username: "admin" }`,
|
||||
),
|
||||
),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "bulk write exception duplicate key",
|
||||
err: bulkWriteExceptionWithMessage(
|
||||
11000,
|
||||
`E11000 duplicate key error collection: tm.customers index: phone_idx dup key: { phone: "+1234567890" }`,
|
||||
),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "string fallback duplicate key",
|
||||
err: errors.New(`write exception: write errors: [E11000 duplicate key error collection: tm.users index: phone_idx dup key: { phone: "+1234567890" }] `),
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := IsDuplicateKeyError(tt.err); got != tt.want {
|
||||
t.Fatalf("IsDuplicateKeyError() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateKeyMatchesField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const phoneMsg = `E11000 duplicate key error collection: tm.users index: phone_idx dup key: { phone: "+1234567890" }`
|
||||
const usernameMsg = `E11000 duplicate key error collection: tm.users index: username_idx dup key: { username: "admin" }`
|
||||
const emailMsg = `E11000 duplicate key error collection: tm.customers index: email_1 dup key: { email: "user@example.com" }`
|
||||
const phoneValueContainsNameMsg = `E11000 duplicate key error collection: tm.users index: phone_idx dup key: { phone: "username-like-value" }`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
field string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
field: "phone",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty field",
|
||||
err: writeExceptionWithMessage(11000, phoneMsg),
|
||||
field: "",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "phone duplicate matches phone field",
|
||||
err: writeExceptionWithMessage(11000, phoneMsg),
|
||||
field: "phone",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "phone duplicate does not match email field",
|
||||
err: writeExceptionWithMessage(11000, phoneMsg),
|
||||
field: "email",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "username duplicate matches username field",
|
||||
err: writeExceptionWithMessage(11000, usernameMsg),
|
||||
field: "username",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "username duplicate does not match name substring",
|
||||
err: writeExceptionWithMessage(11000, usernameMsg),
|
||||
field: "name",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "email duplicate matches email field via default index suffix",
|
||||
err: writeExceptionWithMessage(11000, emailMsg),
|
||||
field: "email",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "dup key value substring does not match unrelated field",
|
||||
err: writeExceptionWithMessage(11000, phoneValueContainsNameMsg),
|
||||
field: "name",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "wrapped duplicate key matches field",
|
||||
err: fmt.Errorf(
|
||||
"failed to register customer: %w",
|
||||
writeExceptionWithMessage(11000, phoneMsg),
|
||||
),
|
||||
field: "phone",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non duplicate write error does not match",
|
||||
err: writeExceptionWithMessage(121, phoneMsg),
|
||||
field: "phone",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unrelated error does not match",
|
||||
err: errors.New("timeout"),
|
||||
field: "phone",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "string fallback duplicate key matches field",
|
||||
err: errors.New(`write exception: write errors: [E11000 duplicate key error collection: tm.users index: phone_idx dup key: { phone: "+1234567890" }] `),
|
||||
field: "phone",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "string fallback duplicate key does not match unrelated field",
|
||||
err: errors.New(`write exception: write errors: [E11000 duplicate key error collection: tm.users index: username_idx dup key: { username: "admin" }] `),
|
||||
field: "name",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := DuplicateKeyMatchesField(tt.err, tt.field); got != tt.want {
|
||||
t.Fatalf("DuplicateKeyMatchesField(%q) = %v, want %v", tt.field, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDuplicateIndexName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
message string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "phone index",
|
||||
message: `E11000 duplicate key error collection: tm.users index: phone_idx dup key: { phone: "+1234567890" }`,
|
||||
want: "phone_idx",
|
||||
},
|
||||
{
|
||||
name: "missing index segment",
|
||||
message: `E11000 duplicate key error collection: tm.users dup key: { phone: "+1234567890" }`,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := extractDuplicateIndexName(tt.message); got != tt.want {
|
||||
t.Fatalf("extractDuplicateIndexName() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDuplicateKeyFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
message string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "single field",
|
||||
message: `index: phone_idx dup key: { phone: "+1234567890" }`,
|
||||
want: []string{"phone"},
|
||||
},
|
||||
{
|
||||
name: "compound dup key",
|
||||
message: `index: user_company_idx dup key: { user_id: ObjectId('507f1f77bcf86cd799439011'), company_id: ObjectId('507f1f77bcf86cd799439012') }`,
|
||||
want: []string{"user_id", "company_id"},
|
||||
},
|
||||
{
|
||||
name: "value containing colon does not add false field",
|
||||
message: `index: phone_idx dup key: { phone: "office:123" }`,
|
||||
want: []string{"phone"},
|
||||
},
|
||||
{
|
||||
name: "missing dup key segment",
|
||||
message: `index: phone_idx`,
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := extractDuplicateKeyFields(tt.message)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("extractDuplicateKeyFields() = %v, want %v", got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Fatalf("extractDuplicateKeyFields() = %v, want %v", got, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexNameMatchesField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
indexName string
|
||||
field string
|
||||
want bool
|
||||
}{
|
||||
{indexName: "phone_idx", field: "phone", want: true},
|
||||
{indexName: "phone_1", field: "phone", want: true},
|
||||
{indexName: "phone", field: "phone", want: true},
|
||||
{indexName: "username_idx", field: "name", want: false},
|
||||
{indexName: "email_idx", field: "mail", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.indexName+"_"+tt.field, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := indexNameMatchesField(tt.indexName, tt.field); got != tt.want {
|
||||
t.Fatalf("indexNameMatchesField(%q, %q) = %v, want %v", tt.indexName, tt.field, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeExceptionWithMessage(code int, message string) error {
|
||||
return mongodriver.WriteException{
|
||||
WriteErrors: []mongodriver.WriteError{
|
||||
{Code: code, Message: message},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func bulkWriteExceptionWithMessage(code int, message string) error {
|
||||
return mongodriver.BulkWriteException{
|
||||
WriteErrors: []mongodriver.BulkWriteError{
|
||||
{WriteError: mongodriver.WriteError{Code: code, Message: message}},
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user