diff --git a/pkg/mongo/duplicate.go b/pkg/mongo/duplicate.go index 17359b3..a45b18c 100644 --- a/pkg/mongo/duplicate.go +++ b/pkg/mongo/duplicate.go @@ -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 +} + +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 } - msg := strings.ToLower(err.Error()) - field = strings.ToLower(field) - if strings.Contains(msg, field+"_idx") { - return true - } - 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 } diff --git a/pkg/mongo/duplicate_test.go b/pkg/mongo/duplicate_test.go new file mode 100644 index 0000000..d81ecdb --- /dev/null +++ b/pkg/mongo/duplicate_test.go @@ -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}}, + }, + } +}