diff --git a/README.md b/README.md index 482a85c..bb2ff70 100644 --- a/README.md +++ b/README.md @@ -121,20 +121,56 @@ Results are returned in Extended JSON (Relaxed) format: | BinData() | `BinData(subtype, base64)` | | | RegExp() | `RegExp("pattern", "flags")`, `/pattern/flags` | | -### Milestone 2: Write Operations (Planned) +### Milestone 2: Write Operations (Current) + +#### Insert Commands + +| Command | Syntax | Status | +|---------|--------|--------| +| db.collection.insertOne() | `insertOne(document, options)` | Supported | +| db.collection.insertMany() | `insertMany(documents, options)` | Supported | + +#### Update Commands + +| Command | Syntax | Status | +|---------|--------|--------| +| db.collection.updateOne() | `updateOne(filter, update, options)` | Supported | +| db.collection.updateMany() | `updateMany(filter, update, options)` | Supported | +| db.collection.replaceOne() | `replaceOne(filter, replacement, options)` | Supported | + +#### Delete Commands + +| Command | Syntax | Status | +|---------|--------|--------| +| db.collection.deleteOne() | `deleteOne(filter, options)` | Supported | +| db.collection.deleteMany() | `deleteMany(filter, options)` | Supported | + +#### Atomic Find-and-Modify Commands | Command | Syntax | Status | |---------|--------|--------| -| db.collection.insertOne() | `insertOne(document)` | Not yet supported | -| db.collection.insertMany() | `insertMany(documents)` | Not yet supported | -| db.collection.updateOne() | `updateOne(filter, update)` | Not yet supported | -| db.collection.updateMany() | `updateMany(filter, update)` | Not yet supported | -| db.collection.deleteOne() | `deleteOne(filter)` | Not yet supported | -| db.collection.deleteMany() | `deleteMany(filter)` | Not yet supported | -| db.collection.replaceOne() | `replaceOne(filter, replacement)` | Not yet supported | -| db.collection.findOneAndUpdate() | `findOneAndUpdate(filter, update)` | Not yet supported | -| db.collection.findOneAndReplace() | `findOneAndReplace(filter, replacement)` | Not yet supported | -| db.collection.findOneAndDelete() | `findOneAndDelete(filter)` | Not yet supported | +| db.collection.findOneAndUpdate() | `findOneAndUpdate(filter, update, options)` | Supported | +| db.collection.findOneAndReplace() | `findOneAndReplace(filter, replacement, options)` | Supported | +| db.collection.findOneAndDelete() | `findOneAndDelete(filter, options)` | Supported | + +#### Write Operation Options + +| Option | Applies To | Description | +|--------|-----------|-------------| +| `writeConcern` | All write ops | Write concern settings (`w`, `j`, `wtimeout`*) | +| `bypassDocumentValidation` | Insert, Update, Replace, FindOneAndUpdate/Replace | Skip schema validation | +| `comment` | All write ops | Comment for server logs | +| `ordered` | insertMany | Execute inserts sequentially (default: true) | +| `upsert` | Update, Replace, FindOneAndUpdate/Replace | Insert if no match found | +| `hint` | Update, Replace, Delete, FindOneAnd* | Force index usage | +| `collation` | Update, Replace, Delete, FindOneAnd* | String comparison rules | +| `arrayFilters` | updateOne, updateMany, findOneAndUpdate | Array element filtering | +| `let` | Update, Replace, Delete, FindOneAnd* | Variables for expressions | +| `sort` | updateOne, replaceOne, FindOneAnd* | Document selection order | +| `projection` | FindOneAnd* | Fields to return | +| `returnDocument` | FindOneAndUpdate/Replace | Return "before" or "after" | + +*Note: `wtimeout` is parsed but ignored as it's not supported in MongoDB Go driver v2. ### Milestone 3: Administrative Operations (Planned) diff --git a/error_test.go b/error_test.go index 1733678..0ce873e 100644 --- a/error_test.go +++ b/error_test.go @@ -32,13 +32,13 @@ func TestPlannedOperation(t *testing.T) { gc := gomongo.NewClient(client) ctx := context.Background() - // insertOne is a planned M2 operation - should return PlannedOperationError - _, err := gc.Execute(ctx, dbName, "db.users.insertOne({ name: 'test' })") + // createIndex is a planned M3 operation - should return PlannedOperationError + _, err := gc.Execute(ctx, dbName, "db.users.createIndex({ name: 1 })") require.Error(t, err) var plannedErr *gomongo.PlannedOperationError require.ErrorAs(t, err, &plannedErr) - require.Equal(t, "insertOne()", plannedErr.Operation) + require.Equal(t, "createIndex()", plannedErr.Operation) } func TestUnsupportedOperation(t *testing.T) { @@ -78,8 +78,9 @@ func TestUnsupportedOptionError(t *testing.T) { func TestMethodRegistryStats(t *testing.T) { total := gomongo.MethodRegistryStats() - // Registry should contain M2 (10) + M3 (22) = 32 planned methods - require.Equal(t, 32, total, "expected 32 planned methods in registry (M2: 10, M3: 22)") + // Registry should contain M3 (22) planned methods + // M2 write operations have been implemented and removed from the registry + require.Equal(t, 22, total, "expected 22 planned methods in registry (M3: 22)") // Log stats for visibility t.Logf("Method Registry Stats: total=%d planned methods", total) diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 6a1d139..7ef8838 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -40,6 +40,26 @@ func Execute(ctx context.Context, client *mongo.Client, database string, op *tra return executeEstimatedDocumentCount(ctx, client, database, op) case translator.OpDistinct: return executeDistinct(ctx, client, database, op) + case translator.OpInsertOne: + return executeInsertOne(ctx, client, database, op) + case translator.OpInsertMany: + return executeInsertMany(ctx, client, database, op) + case translator.OpUpdateOne: + return executeUpdateOne(ctx, client, database, op) + case translator.OpUpdateMany: + return executeUpdateMany(ctx, client, database, op) + case translator.OpReplaceOne: + return executeReplaceOne(ctx, client, database, op) + case translator.OpDeleteOne: + return executeDeleteOne(ctx, client, database, op) + case translator.OpDeleteMany: + return executeDeleteMany(ctx, client, database, op) + case translator.OpFindOneAndUpdate: + return executeFindOneAndUpdate(ctx, client, database, op) + case translator.OpFindOneAndReplace: + return executeFindOneAndReplace(ctx, client, database, op) + case translator.OpFindOneAndDelete: + return executeFindOneAndDelete(ctx, client, database, op) default: return nil, fmt.Errorf("unsupported operation: %s", statement) } diff --git a/internal/executor/write.go b/internal/executor/write.go new file mode 100644 index 0000000..52e50ed --- /dev/null +++ b/internal/executor/write.go @@ -0,0 +1,574 @@ +package executor + +import ( + "context" + "fmt" + + "github.com/bytebase/gomongo/internal/translator" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + "go.mongodb.org/mongo-driver/v2/mongo/writeconcern" +) + +// convertWriteConcern converts a bson.D writeConcern document to *writeconcern.WriteConcern. +// Note: wtimeout is not supported in MongoDB Go driver v2, so it is ignored if present. +func convertWriteConcern(doc bson.D) *writeconcern.WriteConcern { + if doc == nil { + return nil + } + wc := &writeconcern.WriteConcern{} + for _, elem := range doc { + switch elem.Key { + case "w": + // w can be an int or string like "majority" + wc.W = elem.Value + case "j": + if v, ok := elem.Value.(bool); ok { + wc.Journal = &v + } + case "wtimeout": + // wtimeout is not supported in MongoDB Go driver v2 + // The field was removed from the WriteConcern struct + // We silently ignore it to maintain compatibility with mongosh syntax + } + } + return wc +} + +// getCollection returns a collection, optionally cloned with a custom write concern. +func getCollection(client *mongo.Client, database, collection string, wc bson.D) *mongo.Collection { + coll := client.Database(database).Collection(collection) + if wc != nil { + coll = coll.Clone(options.Collection().SetWriteConcern(convertWriteConcern(wc))) + } + return coll +} + +// convertCollation converts a bson.D collation document to *options.Collation. +func convertCollation(doc bson.D) *options.Collation { + if doc == nil { + return nil + } + coll := &options.Collation{} + for _, elem := range doc { + switch elem.Key { + case "locale": + if v, ok := elem.Value.(string); ok { + coll.Locale = v + } + case "caseLevel": + if v, ok := elem.Value.(bool); ok { + coll.CaseLevel = v + } + case "caseFirst": + if v, ok := elem.Value.(string); ok { + coll.CaseFirst = v + } + case "strength": + if v, ok := elem.Value.(int32); ok { + coll.Strength = int(v) + } else if v, ok := elem.Value.(int64); ok { + coll.Strength = int(v) + } + case "numericOrdering": + if v, ok := elem.Value.(bool); ok { + coll.NumericOrdering = v + } + case "alternate": + if v, ok := elem.Value.(string); ok { + coll.Alternate = v + } + case "maxVariable": + if v, ok := elem.Value.(string); ok { + coll.MaxVariable = v + } + case "normalization": + if v, ok := elem.Value.(bool); ok { + coll.Normalization = v + } + case "backwards": + if v, ok := elem.Value.(bool); ok { + coll.Backwards = v + } + } + } + return coll +} + +// executeInsertOne executes an insertOne operation. +func executeInsertOne(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) { + collection := getCollection(client, database, op.Collection, op.WriteConcern) + + opts := options.InsertOne() + if op.BypassDocumentValidation != nil && *op.BypassDocumentValidation { + opts.SetBypassDocumentValidation(true) + } + if op.Comment != nil { + opts.SetComment(op.Comment) + } + + result, err := collection.InsertOne(ctx, op.Document, opts) + if err != nil { + return nil, fmt.Errorf("insertOne failed: %w", err) + } + + // Build response document matching mongosh format + response := bson.M{ + "acknowledged": true, + "insertedId": result.InsertedID, + } + + jsonBytes, err := bson.MarshalExtJSONIndent(response, false, false, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal failed: %w", err) + } + + return &Result{ + Rows: []string{string(jsonBytes)}, + RowCount: 1, + }, nil +} + +// executeInsertMany executes an insertMany operation. +func executeInsertMany(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) { + collection := getCollection(client, database, op.Collection, op.WriteConcern) + + // Convert []bson.D to []any for InsertMany + docs := make([]any, len(op.Documents)) + for i, doc := range op.Documents { + docs[i] = doc + } + + opts := options.InsertMany() + if op.Ordered != nil { + opts.SetOrdered(*op.Ordered) + } + if op.BypassDocumentValidation != nil && *op.BypassDocumentValidation { + opts.SetBypassDocumentValidation(true) + } + if op.Comment != nil { + opts.SetComment(op.Comment) + } + + result, err := collection.InsertMany(ctx, docs, opts) + if err != nil { + return nil, fmt.Errorf("insertMany failed: %w", err) + } + + // Build response document matching mongosh format + response := bson.M{ + "acknowledged": true, + "insertedIds": result.InsertedIDs, + } + + jsonBytes, err := bson.MarshalExtJSONIndent(response, false, false, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal failed: %w", err) + } + + return &Result{ + Rows: []string{string(jsonBytes)}, + RowCount: 1, + }, nil +} + +// executeUpdateOne executes an updateOne operation. +func executeUpdateOne(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) { + collection := getCollection(client, database, op.Collection, op.WriteConcern) + + opts := options.UpdateOne() + if op.Upsert != nil && *op.Upsert { + opts.SetUpsert(true) + } + if op.Hint != nil { + opts.SetHint(op.Hint) + } + if op.Collation != nil { + opts.SetCollation(convertCollation(op.Collation)) + } + if op.ArrayFilters != nil { + opts.SetArrayFilters(op.ArrayFilters) + } + if op.Let != nil { + opts.SetLet(op.Let) + } + if op.BypassDocumentValidation != nil && *op.BypassDocumentValidation { + opts.SetBypassDocumentValidation(true) + } + if op.Comment != nil { + opts.SetComment(op.Comment) + } + if op.Sort != nil { + opts.SetSort(op.Sort) + } + + result, err := collection.UpdateOne(ctx, op.Filter, op.Update, opts) + if err != nil { + return nil, fmt.Errorf("updateOne failed: %w", err) + } + + // Build response document matching mongosh format + response := bson.M{ + "acknowledged": true, + "matchedCount": result.MatchedCount, + "modifiedCount": result.ModifiedCount, + } + if result.UpsertedID != nil { + response["upsertedId"] = result.UpsertedID + } + + jsonBytes, err := bson.MarshalExtJSONIndent(response, false, false, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal failed: %w", err) + } + + return &Result{ + Rows: []string{string(jsonBytes)}, + RowCount: 1, + }, nil +} + +// executeUpdateMany executes an updateMany operation. +func executeUpdateMany(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) { + collection := getCollection(client, database, op.Collection, op.WriteConcern) + + opts := options.UpdateMany() + if op.Upsert != nil && *op.Upsert { + opts.SetUpsert(true) + } + if op.Hint != nil { + opts.SetHint(op.Hint) + } + if op.Collation != nil { + opts.SetCollation(convertCollation(op.Collation)) + } + if op.ArrayFilters != nil { + opts.SetArrayFilters(op.ArrayFilters) + } + if op.Let != nil { + opts.SetLet(op.Let) + } + if op.BypassDocumentValidation != nil && *op.BypassDocumentValidation { + opts.SetBypassDocumentValidation(true) + } + if op.Comment != nil { + opts.SetComment(op.Comment) + } + + result, err := collection.UpdateMany(ctx, op.Filter, op.Update, opts) + if err != nil { + return nil, fmt.Errorf("updateMany failed: %w", err) + } + + response := bson.M{ + "acknowledged": true, + "matchedCount": result.MatchedCount, + "modifiedCount": result.ModifiedCount, + } + if result.UpsertedID != nil { + response["upsertedId"] = result.UpsertedID + } + + jsonBytes, err := bson.MarshalExtJSONIndent(response, false, false, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal failed: %w", err) + } + + return &Result{ + Rows: []string{string(jsonBytes)}, + RowCount: 1, + }, nil +} + +// executeReplaceOne executes a replaceOne operation. +func executeReplaceOne(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) { + collection := getCollection(client, database, op.Collection, op.WriteConcern) + + opts := options.Replace() + if op.Upsert != nil && *op.Upsert { + opts.SetUpsert(true) + } + if op.Hint != nil { + opts.SetHint(op.Hint) + } + if op.Collation != nil { + opts.SetCollation(convertCollation(op.Collation)) + } + if op.Let != nil { + opts.SetLet(op.Let) + } + if op.BypassDocumentValidation != nil && *op.BypassDocumentValidation { + opts.SetBypassDocumentValidation(true) + } + if op.Comment != nil { + opts.SetComment(op.Comment) + } + if op.Sort != nil { + opts.SetSort(op.Sort) + } + + result, err := collection.ReplaceOne(ctx, op.Filter, op.Replacement, opts) + if err != nil { + return nil, fmt.Errorf("replaceOne failed: %w", err) + } + + response := bson.M{ + "acknowledged": true, + "matchedCount": result.MatchedCount, + "modifiedCount": result.ModifiedCount, + } + if result.UpsertedID != nil { + response["upsertedId"] = result.UpsertedID + } + + jsonBytes, err := bson.MarshalExtJSONIndent(response, false, false, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal failed: %w", err) + } + + return &Result{ + Rows: []string{string(jsonBytes)}, + RowCount: 1, + }, nil +} + +// executeDeleteOne executes a deleteOne operation. +func executeDeleteOne(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) { + collection := getCollection(client, database, op.Collection, op.WriteConcern) + + opts := options.DeleteOne() + if op.Hint != nil { + opts.SetHint(op.Hint) + } + if op.Collation != nil { + opts.SetCollation(convertCollation(op.Collation)) + } + if op.Let != nil { + opts.SetLet(op.Let) + } + if op.Comment != nil { + opts.SetComment(op.Comment) + } + + result, err := collection.DeleteOne(ctx, op.Filter, opts) + if err != nil { + return nil, fmt.Errorf("deleteOne failed: %w", err) + } + + response := bson.M{ + "acknowledged": true, + "deletedCount": result.DeletedCount, + } + + jsonBytes, err := bson.MarshalExtJSONIndent(response, false, false, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal failed: %w", err) + } + + return &Result{ + Rows: []string{string(jsonBytes)}, + RowCount: 1, + }, nil +} + +// executeDeleteMany executes a deleteMany operation. +func executeDeleteMany(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) { + collection := getCollection(client, database, op.Collection, op.WriteConcern) + + opts := options.DeleteMany() + if op.Hint != nil { + opts.SetHint(op.Hint) + } + if op.Collation != nil { + opts.SetCollation(convertCollation(op.Collation)) + } + if op.Let != nil { + opts.SetLet(op.Let) + } + if op.Comment != nil { + opts.SetComment(op.Comment) + } + + result, err := collection.DeleteMany(ctx, op.Filter, opts) + if err != nil { + return nil, fmt.Errorf("deleteMany failed: %w", err) + } + + response := bson.M{ + "acknowledged": true, + "deletedCount": result.DeletedCount, + } + + jsonBytes, err := bson.MarshalExtJSONIndent(response, false, false, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal failed: %w", err) + } + + return &Result{ + Rows: []string{string(jsonBytes)}, + RowCount: 1, + }, nil +} + +// executeFindOneAndUpdate executes a findOneAndUpdate operation. +func executeFindOneAndUpdate(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) { + collection := getCollection(client, database, op.Collection, op.WriteConcern) + + opts := options.FindOneAndUpdate() + if op.Upsert != nil && *op.Upsert { + opts.SetUpsert(true) + } + if op.ReturnDocument != nil && *op.ReturnDocument == "after" { + opts.SetReturnDocument(options.After) + } + if op.Projection != nil { + opts.SetProjection(op.Projection) + } + if op.Sort != nil { + opts.SetSort(op.Sort) + } + if op.Hint != nil { + opts.SetHint(op.Hint) + } + if op.Collation != nil { + opts.SetCollation(convertCollation(op.Collation)) + } + if op.ArrayFilters != nil { + opts.SetArrayFilters(op.ArrayFilters) + } + if op.Let != nil { + opts.SetLet(op.Let) + } + if op.BypassDocumentValidation != nil && *op.BypassDocumentValidation { + opts.SetBypassDocumentValidation(true) + } + if op.Comment != nil { + opts.SetComment(op.Comment) + } + + var doc bson.M + err := collection.FindOneAndUpdate(ctx, op.Filter, op.Update, opts).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return &Result{ + Rows: []string{"null"}, + RowCount: 1, + }, nil + } + return nil, fmt.Errorf("findOneAndUpdate failed: %w", err) + } + + jsonBytes, err := bson.MarshalExtJSONIndent(doc, false, false, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal failed: %w", err) + } + + return &Result{ + Rows: []string{string(jsonBytes)}, + RowCount: 1, + }, nil +} + +// executeFindOneAndReplace executes a findOneAndReplace operation. +func executeFindOneAndReplace(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) { + collection := getCollection(client, database, op.Collection, op.WriteConcern) + + opts := options.FindOneAndReplace() + if op.Upsert != nil && *op.Upsert { + opts.SetUpsert(true) + } + if op.ReturnDocument != nil && *op.ReturnDocument == "after" { + opts.SetReturnDocument(options.After) + } + if op.Projection != nil { + opts.SetProjection(op.Projection) + } + if op.Sort != nil { + opts.SetSort(op.Sort) + } + if op.Hint != nil { + opts.SetHint(op.Hint) + } + if op.Collation != nil { + opts.SetCollation(convertCollation(op.Collation)) + } + if op.Let != nil { + opts.SetLet(op.Let) + } + if op.BypassDocumentValidation != nil && *op.BypassDocumentValidation { + opts.SetBypassDocumentValidation(true) + } + if op.Comment != nil { + opts.SetComment(op.Comment) + } + + var doc bson.M + err := collection.FindOneAndReplace(ctx, op.Filter, op.Replacement, opts).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return &Result{ + Rows: []string{"null"}, + RowCount: 1, + }, nil + } + return nil, fmt.Errorf("findOneAndReplace failed: %w", err) + } + + jsonBytes, err := bson.MarshalExtJSONIndent(doc, false, false, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal failed: %w", err) + } + + return &Result{ + Rows: []string{string(jsonBytes)}, + RowCount: 1, + }, nil +} + +// executeFindOneAndDelete executes a findOneAndDelete operation. +func executeFindOneAndDelete(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) { + collection := getCollection(client, database, op.Collection, op.WriteConcern) + + opts := options.FindOneAndDelete() + if op.Projection != nil { + opts.SetProjection(op.Projection) + } + if op.Sort != nil { + opts.SetSort(op.Sort) + } + if op.Hint != nil { + opts.SetHint(op.Hint) + } + if op.Collation != nil { + opts.SetCollation(convertCollation(op.Collation)) + } + if op.Let != nil { + opts.SetLet(op.Let) + } + if op.Comment != nil { + opts.SetComment(op.Comment) + } + + var doc bson.M + err := collection.FindOneAndDelete(ctx, op.Filter, opts).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return &Result{ + Rows: []string{"null"}, + RowCount: 1, + }, nil + } + return nil, fmt.Errorf("findOneAndDelete failed: %w", err) + } + + jsonBytes, err := bson.MarshalExtJSONIndent(doc, false, false, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal failed: %w", err) + } + + return &Result{ + Rows: []string{string(jsonBytes)}, + RowCount: 1, + }, nil +} diff --git a/internal/translator/collection.go b/internal/translator/collection.go index ebfd62e..f4fa33d 100644 --- a/internal/translator/collection.go +++ b/internal/translator/collection.go @@ -840,3 +840,988 @@ func (v *visitor) extractArgumentsForDistinct(args mongodb.IArgumentsContext) { return } } + +// extractInsertOneArgs extracts arguments from InsertOneMethodContext. +func (v *visitor) extractInsertOneArgs(ctx mongodb.IInsertOneMethodContext) { + method, ok := ctx.(*mongodb.InsertOneMethodContext) + if !ok { + return + } + + args := method.Arguments() + if args == nil { + v.err = fmt.Errorf("insertOne() requires a document argument") + return + } + + argsCtx, ok := args.(*mongodb.ArgumentsContext) + if !ok { + v.err = fmt.Errorf("insertOne() requires a document argument") + return + } + + allArgs := argsCtx.AllArgument() + if len(allArgs) == 0 { + v.err = fmt.Errorf("insertOne() requires a document argument") + return + } + + // First argument: document (required) + firstArg, ok := allArgs[0].(*mongodb.ArgumentContext) + if !ok { + v.err = fmt.Errorf("insertOne() requires a document argument") + return + } + + valueCtx := firstArg.Value() + if valueCtx == nil { + v.err = fmt.Errorf("insertOne() requires a document argument") + return + } + + docValue, ok := valueCtx.(*mongodb.DocumentValueContext) + if !ok { + v.err = fmt.Errorf("insertOne() document must be an object") + return + } + + doc, err := convertDocument(docValue.Document()) + if err != nil { + v.err = fmt.Errorf("invalid document: %w", err) + return + } + v.operation.Document = doc + + // Second argument: options (optional) + if len(allArgs) >= 2 { + secondArg, ok := allArgs[1].(*mongodb.ArgumentContext) + if !ok { + return + } + + optionsValueCtx := secondArg.Value() + if optionsValueCtx == nil { + return + } + + optionsDocValue, ok := optionsValueCtx.(*mongodb.DocumentValueContext) + if !ok { + v.err = fmt.Errorf("insertOne() options must be a document") + return + } + + options, err := convertDocument(optionsDocValue.Document()) + if err != nil { + v.err = fmt.Errorf("invalid options: %w", err) + return + } + + for _, opt := range options { + switch opt.Key { + case "bypassDocumentValidation": + if val, ok := opt.Value.(bool); ok { + v.operation.BypassDocumentValidation = &val + } else { + v.err = fmt.Errorf("insertOne() bypassDocumentValidation must be a boolean") + return + } + case "comment": + v.operation.Comment = opt.Value + case "writeConcern": + if doc, ok := opt.Value.(bson.D); ok { + v.operation.WriteConcern = doc + } else { + v.err = fmt.Errorf("insertOne() writeConcern must be a document") + return + } + default: + v.err = &UnsupportedOptionError{ + Method: "insertOne()", + Option: opt.Key, + } + return + } + } + } + + if len(allArgs) > 2 { + v.err = fmt.Errorf("insertOne() takes at most 2 arguments") + return + } +} + +// extractUpdateOneArgs extracts arguments from UpdateOneMethodContext. +func (v *visitor) extractUpdateOneArgs(ctx mongodb.IUpdateOneMethodContext) { + method, ok := ctx.(*mongodb.UpdateOneMethodContext) + if !ok { + return + } + v.extractUpdateArgs("updateOne", method.Arguments()) +} + +// extractUpdateManyArgs extracts arguments from UpdateManyMethodContext. +func (v *visitor) extractUpdateManyArgs(ctx mongodb.IUpdateManyMethodContext) { + method, ok := ctx.(*mongodb.UpdateManyMethodContext) + if !ok { + return + } + v.extractUpdateArgs("updateMany", method.Arguments()) +} + +// extractUpdateArgs is shared between updateOne and updateMany. +func (v *visitor) extractUpdateArgs(methodName string, args mongodb.IArgumentsContext) { + if args == nil { + v.err = fmt.Errorf("%s() requires filter and update arguments", methodName) + return + } + + argsCtx, ok := args.(*mongodb.ArgumentsContext) + if !ok { + v.err = fmt.Errorf("%s() requires filter and update arguments", methodName) + return + } + + allArgs := argsCtx.AllArgument() + if len(allArgs) < 2 { + v.err = fmt.Errorf("%s() requires filter and update arguments", methodName) + return + } + + // First argument: filter (required) + firstArg, ok := allArgs[0].(*mongodb.ArgumentContext) + if !ok { + v.err = fmt.Errorf("%s() filter must be a document", methodName) + return + } + + filterValueCtx := firstArg.Value() + if filterValueCtx == nil { + v.err = fmt.Errorf("%s() filter must be a document", methodName) + return + } + + filterDocValue, ok := filterValueCtx.(*mongodb.DocumentValueContext) + if !ok { + v.err = fmt.Errorf("%s() filter must be a document", methodName) + return + } + + filter, err := convertDocument(filterDocValue.Document()) + if err != nil { + v.err = fmt.Errorf("invalid filter: %w", err) + return + } + v.operation.Filter = filter + + // Second argument: update (required) - can be document or pipeline + secondArg, ok := allArgs[1].(*mongodb.ArgumentContext) + if !ok { + v.err = fmt.Errorf("%s() update must be a document or array", methodName) + return + } + + updateValueCtx := secondArg.Value() + if updateValueCtx == nil { + v.err = fmt.Errorf("%s() update must be a document or array", methodName) + return + } + + switch uv := updateValueCtx.(type) { + case *mongodb.DocumentValueContext: + update, err := convertDocument(uv.Document()) + if err != nil { + v.err = fmt.Errorf("invalid update: %w", err) + return + } + v.operation.Update = update + case *mongodb.ArrayValueContext: + // Aggregation pipeline update + pipeline, err := convertArray(uv.Array()) + if err != nil { + v.err = fmt.Errorf("invalid update pipeline: %w", err) + return + } + v.operation.Update = pipeline + default: + v.err = fmt.Errorf("%s() update must be a document or array", methodName) + return + } + + // Third argument: options (optional) + if len(allArgs) >= 3 { + thirdArg, ok := allArgs[2].(*mongodb.ArgumentContext) + if !ok { + return + } + + optionsValueCtx := thirdArg.Value() + if optionsValueCtx == nil { + return + } + + optionsDocValue, ok := optionsValueCtx.(*mongodb.DocumentValueContext) + if !ok { + v.err = fmt.Errorf("%s() options must be a document", methodName) + return + } + + options, err := convertDocument(optionsDocValue.Document()) + if err != nil { + v.err = fmt.Errorf("invalid options: %w", err) + return + } + + for _, opt := range options { + switch opt.Key { + case "upsert": + if val, ok := opt.Value.(bool); ok { + v.operation.Upsert = &val + } else { + v.err = fmt.Errorf("%s() upsert must be a boolean", methodName) + return + } + case "hint": + v.operation.Hint = opt.Value + case "collation": + if doc, ok := opt.Value.(bson.D); ok { + v.operation.Collation = doc + } else { + v.err = fmt.Errorf("%s() collation must be a document", methodName) + return + } + case "arrayFilters": + if arr, ok := opt.Value.(bson.A); ok { + v.operation.ArrayFilters = arr + } else { + v.err = fmt.Errorf("%s() arrayFilters must be an array", methodName) + return + } + case "let": + if doc, ok := opt.Value.(bson.D); ok { + v.operation.Let = doc + } else { + v.err = fmt.Errorf("%s() let must be a document", methodName) + return + } + case "bypassDocumentValidation": + if val, ok := opt.Value.(bool); ok { + v.operation.BypassDocumentValidation = &val + } else { + v.err = fmt.Errorf("%s() bypassDocumentValidation must be a boolean", methodName) + return + } + case "comment": + v.operation.Comment = opt.Value + case "sort": + // sort is only valid for updateOne (MongoDB 8.0+) + if methodName != "updateOne" { + v.err = &UnsupportedOptionError{ + Method: methodName + "()", + Option: opt.Key, + } + return + } + if doc, ok := opt.Value.(bson.D); ok { + v.operation.Sort = doc + } else { + v.err = fmt.Errorf("%s() sort must be a document", methodName) + return + } + case "writeConcern": + if doc, ok := opt.Value.(bson.D); ok { + v.operation.WriteConcern = doc + } else { + v.err = fmt.Errorf("%s() writeConcern must be a document", methodName) + return + } + default: + v.err = &UnsupportedOptionError{ + Method: methodName + "()", + Option: opt.Key, + } + return + } + } + } + + if len(allArgs) > 3 { + v.err = fmt.Errorf("%s() takes at most 3 arguments", methodName) + return + } +} + +// extractInsertManyArgs extracts arguments from InsertManyMethodContext. +func (v *visitor) extractInsertManyArgs(ctx mongodb.IInsertManyMethodContext) { + method, ok := ctx.(*mongodb.InsertManyMethodContext) + if !ok { + return + } + + args := method.Arguments() + if args == nil { + v.err = fmt.Errorf("insertMany() requires an array argument") + return + } + + argsCtx, ok := args.(*mongodb.ArgumentsContext) + if !ok { + v.err = fmt.Errorf("insertMany() requires an array argument") + return + } + + allArgs := argsCtx.AllArgument() + if len(allArgs) == 0 { + v.err = fmt.Errorf("insertMany() requires an array argument") + return + } + + // First argument: array of documents (required) + firstArg, ok := allArgs[0].(*mongodb.ArgumentContext) + if !ok { + v.err = fmt.Errorf("insertMany() requires an array argument") + return + } + + valueCtx := firstArg.Value() + if valueCtx == nil { + v.err = fmt.Errorf("insertMany() requires an array argument") + return + } + + arrayValue, ok := valueCtx.(*mongodb.ArrayValueContext) + if !ok { + v.err = fmt.Errorf("insertMany() requires an array argument") + return + } + + arr, err := convertArray(arrayValue.Array()) + if err != nil { + v.err = fmt.Errorf("invalid documents array: %w", err) + return + } + + // Convert array elements to bson.D + var docs []bson.D + for i, elem := range arr { + doc, ok := elem.(bson.D) + if !ok { + v.err = fmt.Errorf("insertMany() element %d must be a document", i) + return + } + docs = append(docs, doc) + } + v.operation.Documents = docs + + // Second argument: options (optional) + if len(allArgs) >= 2 { + secondArg, ok := allArgs[1].(*mongodb.ArgumentContext) + if !ok { + return + } + + optionsValueCtx := secondArg.Value() + if optionsValueCtx == nil { + return + } + + optionsDocValue, ok := optionsValueCtx.(*mongodb.DocumentValueContext) + if !ok { + v.err = fmt.Errorf("insertMany() options must be a document") + return + } + + options, err := convertDocument(optionsDocValue.Document()) + if err != nil { + v.err = fmt.Errorf("invalid options: %w", err) + return + } + + for _, opt := range options { + switch opt.Key { + case "ordered": + if val, ok := opt.Value.(bool); ok { + v.operation.Ordered = &val + } else { + v.err = fmt.Errorf("insertMany() ordered must be a boolean") + return + } + case "bypassDocumentValidation": + if val, ok := opt.Value.(bool); ok { + v.operation.BypassDocumentValidation = &val + } else { + v.err = fmt.Errorf("insertMany() bypassDocumentValidation must be a boolean") + return + } + case "comment": + v.operation.Comment = opt.Value + case "writeConcern": + if doc, ok := opt.Value.(bson.D); ok { + v.operation.WriteConcern = doc + } else { + v.err = fmt.Errorf("insertMany() writeConcern must be a document") + return + } + default: + v.err = &UnsupportedOptionError{ + Method: "insertMany()", + Option: opt.Key, + } + return + } + } + } + + if len(allArgs) > 2 { + v.err = fmt.Errorf("insertMany() takes at most 2 arguments") + return + } +} + +// extractReplaceOneArgs extracts arguments from ReplaceOneMethodContext. +func (v *visitor) extractReplaceOneArgs(ctx mongodb.IReplaceOneMethodContext) { + method, ok := ctx.(*mongodb.ReplaceOneMethodContext) + if !ok { + return + } + + args := method.Arguments() + if args == nil { + v.err = fmt.Errorf("replaceOne() requires filter and replacement arguments") + return + } + + argsCtx, ok := args.(*mongodb.ArgumentsContext) + if !ok { + v.err = fmt.Errorf("replaceOne() requires filter and replacement arguments") + return + } + + allArgs := argsCtx.AllArgument() + if len(allArgs) < 2 { + v.err = fmt.Errorf("replaceOne() requires filter and replacement arguments") + return + } + + // First argument: filter + firstArg, ok := allArgs[0].(*mongodb.ArgumentContext) + if !ok { + return + } + + filterValueCtx := firstArg.Value() + if filterValueCtx == nil { + return + } + + filterDocValue, ok := filterValueCtx.(*mongodb.DocumentValueContext) + if !ok { + v.err = fmt.Errorf("replaceOne() filter must be a document") + return + } + + filter, err := convertDocument(filterDocValue.Document()) + if err != nil { + v.err = fmt.Errorf("invalid filter: %w", err) + return + } + v.operation.Filter = filter + + // Second argument: replacement document + secondArg, ok := allArgs[1].(*mongodb.ArgumentContext) + if !ok { + return + } + + replacementValueCtx := secondArg.Value() + if replacementValueCtx == nil { + return + } + + replacementDocValue, ok := replacementValueCtx.(*mongodb.DocumentValueContext) + if !ok { + v.err = fmt.Errorf("replaceOne() replacement must be a document") + return + } + + replacement, err := convertDocument(replacementDocValue.Document()) + if err != nil { + v.err = fmt.Errorf("invalid replacement: %w", err) + return + } + v.operation.Replacement = replacement + + // Third argument: options (optional) + if len(allArgs) >= 3 { + thirdArg, ok := allArgs[2].(*mongodb.ArgumentContext) + if !ok { + return + } + + optionsValueCtx := thirdArg.Value() + if optionsValueCtx == nil { + return + } + + optionsDocValue, ok := optionsValueCtx.(*mongodb.DocumentValueContext) + if !ok { + v.err = fmt.Errorf("replaceOne() options must be a document") + return + } + + options, err := convertDocument(optionsDocValue.Document()) + if err != nil { + v.err = fmt.Errorf("invalid options: %w", err) + return + } + + for _, opt := range options { + switch opt.Key { + case "upsert": + if val, ok := opt.Value.(bool); ok { + v.operation.Upsert = &val + } else { + v.err = fmt.Errorf("replaceOne() upsert must be a boolean") + return + } + case "hint": + v.operation.Hint = opt.Value + case "collation": + if doc, ok := opt.Value.(bson.D); ok { + v.operation.Collation = doc + } else { + v.err = fmt.Errorf("replaceOne() collation must be a document") + return + } + case "let": + if doc, ok := opt.Value.(bson.D); ok { + v.operation.Let = doc + } else { + v.err = fmt.Errorf("replaceOne() let must be a document") + return + } + case "bypassDocumentValidation": + if val, ok := opt.Value.(bool); ok { + v.operation.BypassDocumentValidation = &val + } else { + v.err = fmt.Errorf("replaceOne() bypassDocumentValidation must be a boolean") + return + } + case "comment": + v.operation.Comment = opt.Value + case "sort": + // sort is supported for replaceOne (MongoDB 8.0+) + if doc, ok := opt.Value.(bson.D); ok { + v.operation.Sort = doc + } else { + v.err = fmt.Errorf("replaceOne() sort must be a document") + return + } + case "writeConcern": + if doc, ok := opt.Value.(bson.D); ok { + v.operation.WriteConcern = doc + } else { + v.err = fmt.Errorf("replaceOne() writeConcern must be a document") + return + } + default: + v.err = &UnsupportedOptionError{ + Method: "replaceOne()", + Option: opt.Key, + } + return + } + } + } + + if len(allArgs) > 3 { + v.err = fmt.Errorf("replaceOne() takes at most 3 arguments") + return + } +} + +// extractDeleteOneArgs extracts arguments from DeleteOneMethodContext. +func (v *visitor) extractDeleteOneArgs(ctx mongodb.IDeleteOneMethodContext) { + method, ok := ctx.(*mongodb.DeleteOneMethodContext) + if !ok { + return + } + v.extractDeleteArgs("deleteOne", method.Arguments()) +} + +// extractDeleteManyArgs extracts arguments from DeleteManyMethodContext. +func (v *visitor) extractDeleteManyArgs(ctx mongodb.IDeleteManyMethodContext) { + method, ok := ctx.(*mongodb.DeleteManyMethodContext) + if !ok { + return + } + v.extractDeleteArgs("deleteMany", method.Arguments()) +} + +// extractDeleteArgs is shared between deleteOne and deleteMany. +func (v *visitor) extractDeleteArgs(methodName string, args mongodb.IArgumentsContext) { + if args == nil { + v.err = fmt.Errorf("%s() requires a filter argument", methodName) + return + } + + argsCtx, ok := args.(*mongodb.ArgumentsContext) + if !ok { + v.err = fmt.Errorf("%s() requires a filter argument", methodName) + return + } + + allArgs := argsCtx.AllArgument() + if len(allArgs) == 0 { + v.err = fmt.Errorf("%s() requires a filter argument", methodName) + return + } + + // First argument: filter (required) + firstArg, ok := allArgs[0].(*mongodb.ArgumentContext) + if !ok { + return + } + + filterValueCtx := firstArg.Value() + if filterValueCtx == nil { + return + } + + filterDocValue, ok := filterValueCtx.(*mongodb.DocumentValueContext) + if !ok { + v.err = fmt.Errorf("%s() filter must be a document", methodName) + return + } + + filter, err := convertDocument(filterDocValue.Document()) + if err != nil { + v.err = fmt.Errorf("invalid filter: %w", err) + return + } + v.operation.Filter = filter + + // Second argument: options (optional) + if len(allArgs) >= 2 { + secondArg, ok := allArgs[1].(*mongodb.ArgumentContext) + if !ok { + return + } + + optionsValueCtx := secondArg.Value() + if optionsValueCtx == nil { + return + } + + optionsDocValue, ok := optionsValueCtx.(*mongodb.DocumentValueContext) + if !ok { + v.err = fmt.Errorf("%s() options must be a document", methodName) + return + } + + options, err := convertDocument(optionsDocValue.Document()) + if err != nil { + v.err = fmt.Errorf("invalid options: %w", err) + return + } + + for _, opt := range options { + switch opt.Key { + case "hint": + v.operation.Hint = opt.Value + case "collation": + if doc, ok := opt.Value.(bson.D); ok { + v.operation.Collation = doc + } else { + v.err = fmt.Errorf("%s() collation must be a document", methodName) + return + } + case "let": + if doc, ok := opt.Value.(bson.D); ok { + v.operation.Let = doc + } else { + v.err = fmt.Errorf("%s() let must be a document", methodName) + return + } + case "comment": + v.operation.Comment = opt.Value + case "writeConcern": + if doc, ok := opt.Value.(bson.D); ok { + v.operation.WriteConcern = doc + } else { + v.err = fmt.Errorf("%s() writeConcern must be a document", methodName) + return + } + default: + v.err = &UnsupportedOptionError{ + Method: methodName + "()", + Option: opt.Key, + } + return + } + } + } + + if len(allArgs) > 2 { + v.err = fmt.Errorf("%s() takes at most 2 arguments", methodName) + return + } +} + +// extractFindOneAndUpdateArgs extracts arguments from FindOneAndUpdateMethodContext. +func (v *visitor) extractFindOneAndUpdateArgs(ctx mongodb.IFindOneAndUpdateMethodContext) { + method, ok := ctx.(*mongodb.FindOneAndUpdateMethodContext) + if !ok { + return + } + v.extractFindOneAndModifyArgs("findOneAndUpdate", method.Arguments(), true) +} + +// extractFindOneAndReplaceArgs extracts arguments from FindOneAndReplaceMethodContext. +func (v *visitor) extractFindOneAndReplaceArgs(ctx mongodb.IFindOneAndReplaceMethodContext) { + method, ok := ctx.(*mongodb.FindOneAndReplaceMethodContext) + if !ok { + return + } + v.extractFindOneAndModifyArgs("findOneAndReplace", method.Arguments(), true) +} + +// extractFindOneAndDeleteArgs extracts arguments from FindOneAndDeleteMethodContext. +func (v *visitor) extractFindOneAndDeleteArgs(ctx mongodb.IFindOneAndDeleteMethodContext) { + method, ok := ctx.(*mongodb.FindOneAndDeleteMethodContext) + if !ok { + return + } + v.extractFindOneAndModifyArgs("findOneAndDelete", method.Arguments(), false) +} + +// extractFindOneAndModifyArgs handles arguments for findOneAndUpdate/Replace/Delete. +// hasUpdate indicates whether the second arg is update/replacement (true) or not (false for delete). +func (v *visitor) extractFindOneAndModifyArgs(methodName string, args mongodb.IArgumentsContext, hasUpdate bool) { + if args == nil { + if hasUpdate { + v.err = fmt.Errorf("%s() requires filter and update arguments", methodName) + } else { + v.err = fmt.Errorf("%s() requires a filter argument", methodName) + } + return + } + + argsCtx, ok := args.(*mongodb.ArgumentsContext) + if !ok { + return + } + + allArgs := argsCtx.AllArgument() + minArgs := 1 + if hasUpdate { + minArgs = 2 + } + if len(allArgs) < minArgs { + if hasUpdate { + v.err = fmt.Errorf("%s() requires filter and update arguments", methodName) + } else { + v.err = fmt.Errorf("%s() requires a filter argument", methodName) + } + return + } + + // First argument: filter + firstArg, ok := allArgs[0].(*mongodb.ArgumentContext) + if !ok { + return + } + + filterValueCtx := firstArg.Value() + if filterValueCtx == nil { + return + } + + filterDocValue, ok := filterValueCtx.(*mongodb.DocumentValueContext) + if !ok { + v.err = fmt.Errorf("%s() filter must be a document", methodName) + return + } + + filter, err := convertDocument(filterDocValue.Document()) + if err != nil { + v.err = fmt.Errorf("invalid filter: %w", err) + return + } + v.operation.Filter = filter + + optionsArgIdx := 1 + if hasUpdate { + // Second argument: update/replacement + secondArg, ok := allArgs[1].(*mongodb.ArgumentContext) + if !ok { + return + } + + updateValueCtx := secondArg.Value() + if updateValueCtx == nil { + return + } + + if methodName == "findOneAndReplace" { + // Replacement must be a document + docValue, ok := updateValueCtx.(*mongodb.DocumentValueContext) + if !ok { + v.err = fmt.Errorf("%s() replacement must be a document", methodName) + return + } + replacement, err := convertDocument(docValue.Document()) + if err != nil { + v.err = fmt.Errorf("invalid replacement: %w", err) + return + } + v.operation.Replacement = replacement + } else { + // Update can be document or pipeline + switch uv := updateValueCtx.(type) { + case *mongodb.DocumentValueContext: + update, err := convertDocument(uv.Document()) + if err != nil { + v.err = fmt.Errorf("invalid update: %w", err) + return + } + v.operation.Update = update + case *mongodb.ArrayValueContext: + pipeline, err := convertArray(uv.Array()) + if err != nil { + v.err = fmt.Errorf("invalid update pipeline: %w", err) + return + } + v.operation.Update = pipeline + default: + v.err = fmt.Errorf("%s() update must be a document or array", methodName) + return + } + } + optionsArgIdx = 2 + } + + // Options argument + if len(allArgs) > optionsArgIdx { + optArg, ok := allArgs[optionsArgIdx].(*mongodb.ArgumentContext) + if !ok { + return + } + + optionsValueCtx := optArg.Value() + if optionsValueCtx == nil { + return + } + + optionsDocValue, ok := optionsValueCtx.(*mongodb.DocumentValueContext) + if !ok { + v.err = fmt.Errorf("%s() options must be a document", methodName) + return + } + + opts, err := convertDocument(optionsDocValue.Document()) + if err != nil { + v.err = fmt.Errorf("invalid options: %w", err) + return + } + + for _, opt := range opts { + switch opt.Key { + case "upsert": + if methodName == "findOneAndDelete" { + v.err = &UnsupportedOptionError{Method: methodName + "()", Option: opt.Key} + return + } + if val, ok := opt.Value.(bool); ok { + v.operation.Upsert = &val + } else { + v.err = fmt.Errorf("%s() upsert must be a boolean", methodName) + return + } + case "returnDocument": + if val, ok := opt.Value.(string); ok { + if val != "before" && val != "after" { + v.err = fmt.Errorf("%s() returnDocument must be 'before' or 'after'", methodName) + return + } + v.operation.ReturnDocument = &val + } else { + v.err = fmt.Errorf("%s() returnDocument must be a string", methodName) + return + } + case "projection": + if doc, ok := opt.Value.(bson.D); ok { + v.operation.Projection = doc + } else { + v.err = fmt.Errorf("%s() projection must be a document", methodName) + return + } + case "sort": + if doc, ok := opt.Value.(bson.D); ok { + v.operation.Sort = doc + } else { + v.err = fmt.Errorf("%s() sort must be a document", methodName) + return + } + case "hint": + v.operation.Hint = opt.Value + case "collation": + if doc, ok := opt.Value.(bson.D); ok { + v.operation.Collation = doc + } else { + v.err = fmt.Errorf("%s() collation must be a document", methodName) + return + } + case "arrayFilters": + if methodName == "findOneAndDelete" || methodName == "findOneAndReplace" { + v.err = &UnsupportedOptionError{Method: methodName + "()", Option: opt.Key} + return + } + if arr, ok := opt.Value.(bson.A); ok { + v.operation.ArrayFilters = arr + } else { + v.err = fmt.Errorf("%s() arrayFilters must be an array", methodName) + return + } + case "let": + if doc, ok := opt.Value.(bson.D); ok { + v.operation.Let = doc + } else { + v.err = fmt.Errorf("%s() let must be a document", methodName) + return + } + case "bypassDocumentValidation": + if methodName == "findOneAndDelete" { + v.err = &UnsupportedOptionError{Method: methodName + "()", Option: opt.Key} + return + } + if val, ok := opt.Value.(bool); ok { + v.operation.BypassDocumentValidation = &val + } else { + v.err = fmt.Errorf("%s() bypassDocumentValidation must be a boolean", methodName) + return + } + case "comment": + v.operation.Comment = opt.Value + case "writeConcern": + if doc, ok := opt.Value.(bson.D); ok { + v.operation.WriteConcern = doc + } else { + v.err = fmt.Errorf("%s() writeConcern must be a document", methodName) + return + } + default: + v.err = &UnsupportedOptionError{ + Method: methodName + "()", + Option: opt.Key, + } + return + } + } + } + + maxArgs := optionsArgIdx + 1 + if len(allArgs) > maxArgs { + v.err = fmt.Errorf("%s() takes at most %d arguments", methodName, maxArgs) + return + } +} diff --git a/internal/translator/method_registry.go b/internal/translator/method_registry.go index 6456ceb..e34b6df 100644 --- a/internal/translator/method_registry.go +++ b/internal/translator/method_registry.go @@ -18,28 +18,6 @@ type methodInfo struct { // If a method is NOT in this registry, it's unsupported (throw error, no fallback). // If a method IS in this registry, it's planned (fallback to mongosh). var methodRegistry = map[string]methodInfo{ - // ============================================================ - // MILESTONE 2: Write Operations (10 methods) - // ============================================================ - - // Insert Commands (2) - "collection:insertOne": {status: statusPlanned}, - "collection:insertMany": {status: statusPlanned}, - - // Update Commands (3) - "collection:updateOne": {status: statusPlanned}, - "collection:updateMany": {status: statusPlanned}, - "collection:replaceOne": {status: statusPlanned}, - - // Delete Commands (2) - "collection:deleteOne": {status: statusPlanned}, - "collection:deleteMany": {status: statusPlanned}, - - // Atomic Find-and-Modify Commands (3) - "collection:findOneAndUpdate": {status: statusPlanned}, - "collection:findOneAndReplace": {status: statusPlanned}, - "collection:findOneAndDelete": {status: statusPlanned}, - // ============================================================ // MILESTONE 3: Administrative Operations (22 methods) // ============================================================ diff --git a/internal/translator/types.go b/internal/translator/types.go index f44191d..a31753a 100644 --- a/internal/translator/types.go +++ b/internal/translator/types.go @@ -18,6 +18,17 @@ const ( OpCountDocuments OpEstimatedDocumentCount OpDistinct + // M2: Write Operations + OpInsertOne + OpInsertMany + OpUpdateOne + OpUpdateMany + OpReplaceOne + OpDeleteOne + OpDeleteMany + OpFindOneAndUpdate + OpFindOneAndReplace + OpFindOneAndDelete ) // Operation represents a parsed MongoDB operation. @@ -42,4 +53,21 @@ type Operation struct { // getCollectionInfos options NameOnly *bool AuthorizedCollections *bool + + // M2: Write operation fields + Document bson.D // insertOne document + Documents []bson.D // insertMany documents + Update any // update document or pipeline (bson.D or bson.A) + Replacement bson.D // replaceOne replacement document + Upsert *bool // upsert option for update/replace operations + ReturnDocument *string // "before" or "after" for findOneAnd* operations + + // M2: Additional write operation options + Ordered *bool // insertMany ordered option + Collation bson.D // collation settings for string comparison + ArrayFilters bson.A // array element filters for update operations + Let bson.D // variables for aggregation expressions + BypassDocumentValidation *bool // bypass schema validation + Comment any // comment for server logs/profiling + WriteConcern bson.D // write concern settings (w, j, wtimeout) } diff --git a/internal/translator/visitor.go b/internal/translator/visitor.go index d78a4f5..380c99e 100644 --- a/internal/translator/visitor.go +++ b/internal/translator/visitor.go @@ -178,27 +178,40 @@ func (v *visitor) visitMethodCall(ctx mongodb.IMethodCallContext) { case mc.MinMethod() != nil: v.extractMin(mc.MinMethod()) - // Planned M2 write operations - return PlannedOperationError for fallback + // Supported M2 write operations case mc.InsertOneMethod() != nil: - v.handleUnsupportedMethod("collection", "insertOne") + v.operation.OpType = OpInsertOne + v.extractInsertOneArgs(mc.InsertOneMethod()) + case mc.InsertManyMethod() != nil: - v.handleUnsupportedMethod("collection", "insertMany") + v.operation.OpType = OpInsertMany + v.extractInsertManyArgs(mc.InsertManyMethod()) + + // Supported M2 write operations - updateOne case mc.UpdateOneMethod() != nil: - v.handleUnsupportedMethod("collection", "updateOne") + v.operation.OpType = OpUpdateOne + v.extractUpdateOneArgs(mc.UpdateOneMethod()) case mc.UpdateManyMethod() != nil: - v.handleUnsupportedMethod("collection", "updateMany") + v.operation.OpType = OpUpdateMany + v.extractUpdateManyArgs(mc.UpdateManyMethod()) case mc.DeleteOneMethod() != nil: - v.handleUnsupportedMethod("collection", "deleteOne") + v.operation.OpType = OpDeleteOne + v.extractDeleteOneArgs(mc.DeleteOneMethod()) case mc.DeleteManyMethod() != nil: - v.handleUnsupportedMethod("collection", "deleteMany") + v.operation.OpType = OpDeleteMany + v.extractDeleteManyArgs(mc.DeleteManyMethod()) case mc.ReplaceOneMethod() != nil: - v.handleUnsupportedMethod("collection", "replaceOne") + v.operation.OpType = OpReplaceOne + v.extractReplaceOneArgs(mc.ReplaceOneMethod()) case mc.FindOneAndUpdateMethod() != nil: - v.handleUnsupportedMethod("collection", "findOneAndUpdate") + v.operation.OpType = OpFindOneAndUpdate + v.extractFindOneAndUpdateArgs(mc.FindOneAndUpdateMethod()) case mc.FindOneAndReplaceMethod() != nil: - v.handleUnsupportedMethod("collection", "findOneAndReplace") + v.operation.OpType = OpFindOneAndReplace + v.extractFindOneAndReplaceArgs(mc.FindOneAndReplaceMethod()) case mc.FindOneAndDeleteMethod() != nil: - v.handleUnsupportedMethod("collection", "findOneAndDelete") + v.operation.OpType = OpFindOneAndDelete + v.extractFindOneAndDeleteArgs(mc.FindOneAndDeleteMethod()) // Planned M3 index operations - return PlannedOperationError for fallback case mc.CreateIndexMethod() != nil: diff --git a/write_test.go b/write_test.go new file mode 100644 index 0000000..1eae494 --- /dev/null +++ b/write_test.go @@ -0,0 +1,565 @@ +package gomongo_test + +import ( + "context" + "testing" + + "github.com/bytebase/gomongo" + "github.com/bytebase/gomongo/internal/testutil" + "github.com/stretchr/testify/require" +) + +func TestInsertOneBasic(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_insert_one" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + // Insert a document + result, err := gc.Execute(ctx, dbName, `db.users.insertOne({ name: "alice", age: 30 })`) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, result.RowCount) + require.Contains(t, result.Rows[0], `"acknowledged": true`) + require.Contains(t, result.Rows[0], `"insertedId"`) + + // Verify document was inserted + verifyResult, err := gc.Execute(ctx, dbName, `db.users.find({ name: "alice" })`) + require.NoError(t, err) + require.Equal(t, 1, verifyResult.RowCount) + require.Contains(t, verifyResult.Rows[0], `"alice"`) + require.Contains(t, verifyResult.Rows[0], `"age": 30`) +} + +func TestInsertOneWithObjectId(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_insert_one_oid" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + // Insert with explicit ObjectId + result, err := gc.Execute(ctx, dbName, `db.users.insertOne({ _id: ObjectId("507f1f77bcf86cd799439011"), name: "bob" })`) + require.NoError(t, err) + require.NotNil(t, result) + require.Contains(t, result.Rows[0], `"507f1f77bcf86cd799439011"`) + + // Verify + verifyResult, err := gc.Execute(ctx, dbName, `db.users.findOne({ _id: ObjectId("507f1f77bcf86cd799439011") })`) + require.NoError(t, err) + require.Equal(t, 1, verifyResult.RowCount) + require.Contains(t, verifyResult.Rows[0], `"bob"`) +} + +func TestInsertOneWithNestedDocument(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_insert_one_nested" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + result, err := gc.Execute(ctx, dbName, `db.users.insertOne({ + name: "carol", + address: { city: "NYC", zip: "10001" }, + tags: ["admin", "user"] + })`) + require.NoError(t, err) + require.NotNil(t, result) + + // Verify nested structure + verifyResult, err := gc.Execute(ctx, dbName, `db.users.findOne({ name: "carol" })`) + require.NoError(t, err) + require.Contains(t, verifyResult.Rows[0], `"city": "NYC"`) + require.Contains(t, verifyResult.Rows[0], `"admin"`) +} + +func TestInsertOneMissingDocument(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_insert_one_missing" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + // Note: When insertOne() is called without arguments, the parser may not + // recognize it as InsertOneMethod (grammar limitation). The error message + // varies based on parser behavior - it may be "unsupported operation" or + // "requires a document". Either way, it should be an error. + _, err := gc.Execute(ctx, dbName, `db.users.insertOne()`) + require.Error(t, err) +} + +func TestInsertOneInvalidDocument(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_insert_one_invalid" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + _, err := gc.Execute(ctx, dbName, `db.users.insertOne("not a document")`) + require.Error(t, err) + require.Contains(t, err.Error(), "must be an object") +} + +func TestInsertManyBasic(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_insert_many" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + result, err := gc.Execute(ctx, dbName, `db.users.insertMany([ + { name: "alice", age: 30 }, + { name: "bob", age: 25 }, + { name: "carol", age: 35 } + ])`) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, result.RowCount) + require.Contains(t, result.Rows[0], `"acknowledged": true`) + require.Contains(t, result.Rows[0], `"insertedIds"`) + + // Verify all documents were inserted + verifyResult, err := gc.Execute(ctx, dbName, `db.users.countDocuments()`) + require.NoError(t, err) + require.Equal(t, "3", verifyResult.Rows[0]) +} + +func TestInsertManyEmpty(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_insert_many_empty" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + _, err := gc.Execute(ctx, dbName, `db.users.insertMany([])`) + require.Error(t, err) // MongoDB doesn't allow empty array +} + +func TestUpdateOneBasic(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_update_one" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + // Insert test data + _, err := gc.Execute(ctx, dbName, `db.users.insertOne({ name: "alice", age: 30 })`) + require.NoError(t, err) + + // Update + result, err := gc.Execute(ctx, dbName, `db.users.updateOne({ name: "alice" }, { $set: { age: 31 } })`) + require.NoError(t, err) + require.Contains(t, result.Rows[0], `"acknowledged": true`) + require.Contains(t, result.Rows[0], `"matchedCount": 1`) + require.Contains(t, result.Rows[0], `"modifiedCount": 1`) + + // Verify + verifyResult, err := gc.Execute(ctx, dbName, `db.users.findOne({ name: "alice" })`) + require.NoError(t, err) + require.Contains(t, verifyResult.Rows[0], `"age": 31`) +} + +func TestUpdateOneNoMatch(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_update_one_no_match" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + result, err := gc.Execute(ctx, dbName, `db.users.updateOne({ name: "nobody" }, { $set: { age: 99 } })`) + require.NoError(t, err) + require.Contains(t, result.Rows[0], `"matchedCount": 0`) + require.Contains(t, result.Rows[0], `"modifiedCount": 0`) +} + +func TestUpdateOneUpsert(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_update_one_upsert" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + result, err := gc.Execute(ctx, dbName, `db.users.updateOne( + { name: "newuser" }, + { $set: { age: 25 } }, + { upsert: true } + )`) + require.NoError(t, err) + require.Contains(t, result.Rows[0], `"upsertedId"`) + + // Verify upserted document + verifyResult, err := gc.Execute(ctx, dbName, `db.users.findOne({ name: "newuser" })`) + require.NoError(t, err) + require.Equal(t, 1, verifyResult.RowCount) +} + +func TestUpdateManyBasic(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_update_many" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + // Insert test data + _, err := gc.Execute(ctx, dbName, `db.users.insertMany([ + { name: "alice", status: "active" }, + { name: "bob", status: "active" }, + { name: "carol", status: "inactive" } + ])`) + require.NoError(t, err) + + // Update all active users + result, err := gc.Execute(ctx, dbName, `db.users.updateMany( + { status: "active" }, + { $set: { verified: true } } + )`) + require.NoError(t, err) + require.Contains(t, result.Rows[0], `"matchedCount": 2`) + require.Contains(t, result.Rows[0], `"modifiedCount": 2`) +} + +func TestUpdateManyNoMatch(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_update_many_no_match" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + result, err := gc.Execute(ctx, dbName, `db.users.updateMany({ status: "nonexistent" }, { $set: { verified: true } })`) + require.NoError(t, err) + require.Contains(t, result.Rows[0], `"matchedCount": 0`) + require.Contains(t, result.Rows[0], `"modifiedCount": 0`) +} + +func TestUpdateManyUpsert(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_update_many_upsert" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + result, err := gc.Execute(ctx, dbName, `db.users.updateMany( + { status: "pending" }, + { $set: { verified: false } }, + { upsert: true } + )`) + require.NoError(t, err) + require.Contains(t, result.Rows[0], `"upsertedId"`) + + // Verify upserted document + verifyResult, err := gc.Execute(ctx, dbName, `db.users.findOne({ status: "pending" })`) + require.NoError(t, err) + require.Equal(t, 1, verifyResult.RowCount) +} + +func TestReplaceOneBasic(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_replace_one" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + // Insert test data + _, err := gc.Execute(ctx, dbName, `db.users.insertOne({ name: "alice", age: 30, city: "NYC" })`) + require.NoError(t, err) + + // Replace entire document + result, err := gc.Execute(ctx, dbName, `db.users.replaceOne( + { name: "alice" }, + { name: "alice", age: 31, country: "USA" } + )`) + require.NoError(t, err) + require.Contains(t, result.Rows[0], `"matchedCount": 1`) + require.Contains(t, result.Rows[0], `"modifiedCount": 1`) + + // Verify - city should be gone, country should exist + verifyResult, err := gc.Execute(ctx, dbName, `db.users.findOne({ name: "alice" })`) + require.NoError(t, err) + require.Contains(t, verifyResult.Rows[0], `"country": "USA"`) + require.NotContains(t, verifyResult.Rows[0], `"city"`) +} + +func TestReplaceOneUpsert(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_replace_one_upsert" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + result, err := gc.Execute(ctx, dbName, `db.users.replaceOne( + { name: "newuser" }, + { name: "newuser", age: 25 }, + { upsert: true } + )`) + require.NoError(t, err) + require.Contains(t, result.Rows[0], `"upsertedId"`) +} + +func TestDeleteOneBasic(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_delete_one" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + // Insert test data + _, err := gc.Execute(ctx, dbName, `db.users.insertMany([ + { name: "alice" }, + { name: "bob" }, + { name: "carol" } + ])`) + require.NoError(t, err) + + // Delete one + result, err := gc.Execute(ctx, dbName, `db.users.deleteOne({ name: "bob" })`) + require.NoError(t, err) + require.Contains(t, result.Rows[0], `"acknowledged": true`) + require.Contains(t, result.Rows[0], `"deletedCount": 1`) + + // Verify + countResult, err := gc.Execute(ctx, dbName, `db.users.countDocuments()`) + require.NoError(t, err) + require.Equal(t, "2", countResult.Rows[0]) +} + +func TestDeleteOneNoMatch(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_delete_one_no_match" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + result, err := gc.Execute(ctx, dbName, `db.users.deleteOne({ name: "nobody" })`) + require.NoError(t, err) + require.Contains(t, result.Rows[0], `"deletedCount": 0`) +} + +func TestDeleteManyBasic(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_delete_many" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + // Insert test data + _, err := gc.Execute(ctx, dbName, `db.users.insertMany([ + { name: "alice", status: "inactive" }, + { name: "bob", status: "inactive" }, + { name: "carol", status: "active" } + ])`) + require.NoError(t, err) + + // Delete all inactive + result, err := gc.Execute(ctx, dbName, `db.users.deleteMany({ status: "inactive" })`) + require.NoError(t, err) + require.Contains(t, result.Rows[0], `"deletedCount": 2`) + + // Verify only carol remains + countResult, err := gc.Execute(ctx, dbName, `db.users.countDocuments()`) + require.NoError(t, err) + require.Equal(t, "1", countResult.Rows[0]) +} + +func TestDeleteManyAll(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_delete_many_all" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + // Insert test data + _, err := gc.Execute(ctx, dbName, `db.users.insertMany([ + { name: "alice" }, + { name: "bob" } + ])`) + require.NoError(t, err) + + // Delete all with empty filter + result, err := gc.Execute(ctx, dbName, `db.users.deleteMany({})`) + require.NoError(t, err) + require.Contains(t, result.Rows[0], `"deletedCount": 2`) +} + +func TestFindOneAndUpdateBasic(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_find_one_and_update" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + _, err := gc.Execute(ctx, dbName, `db.users.insertOne({ name: "alice", age: 30 })`) + require.NoError(t, err) + + // Returns document BEFORE update by default + result, err := gc.Execute(ctx, dbName, `db.users.findOneAndUpdate( + { name: "alice" }, + { $set: { age: 31 } } + )`) + require.NoError(t, err) + require.Equal(t, 1, result.RowCount) + require.Contains(t, result.Rows[0], `"age": 30`) +} + +func TestFindOneAndUpdateReturnAfter(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_find_one_and_update_after" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + _, err := gc.Execute(ctx, dbName, `db.users.insertOne({ name: "alice", age: 30 })`) + require.NoError(t, err) + + result, err := gc.Execute(ctx, dbName, `db.users.findOneAndUpdate( + { name: "alice" }, + { $set: { age: 31 } }, + { returnDocument: "after" } + )`) + require.NoError(t, err) + require.Contains(t, result.Rows[0], `"age": 31`) +} + +func TestFindOneAndUpdateNoMatch(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_find_one_and_update_no_match" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + result, err := gc.Execute(ctx, dbName, `db.users.findOneAndUpdate( + { name: "nobody" }, + { $set: { age: 99 } } + )`) + require.NoError(t, err) + require.Equal(t, "null", result.Rows[0]) +} + +func TestFindOneAndReplaceBasic(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_find_one_and_replace" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + _, err := gc.Execute(ctx, dbName, `db.users.insertOne({ name: "alice", age: 30, city: "NYC" })`) + require.NoError(t, err) + + // Returns document BEFORE replacement + result, err := gc.Execute(ctx, dbName, `db.users.findOneAndReplace( + { name: "alice" }, + { name: "alice", age: 31, country: "USA" } + )`) + require.NoError(t, err) + require.Contains(t, result.Rows[0], `"city": "NYC"`) +} + +func TestFindOneAndReplaceReturnAfter(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_find_one_and_replace_after" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + _, err := gc.Execute(ctx, dbName, `db.users.insertOne({ name: "alice", age: 30 })`) + require.NoError(t, err) + + result, err := gc.Execute(ctx, dbName, `db.users.findOneAndReplace( + { name: "alice" }, + { name: "alice", age: 31 }, + { returnDocument: "after" } + )`) + require.NoError(t, err) + require.Contains(t, result.Rows[0], `"age": 31`) +} + +func TestFindOneAndDeleteBasic(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_find_one_and_delete" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + _, err := gc.Execute(ctx, dbName, `db.users.insertMany([ + { name: "alice", age: 30 }, + { name: "bob", age: 25 } + ])`) + require.NoError(t, err) + + // Returns the deleted document + result, err := gc.Execute(ctx, dbName, `db.users.findOneAndDelete({ name: "alice" })`) + require.NoError(t, err) + require.Contains(t, result.Rows[0], `"alice"`) + require.Contains(t, result.Rows[0], `"age": 30`) + + // Verify alice is deleted + countResult, err := gc.Execute(ctx, dbName, `db.users.countDocuments()`) + require.NoError(t, err) + require.Equal(t, "1", countResult.Rows[0]) +} + +func TestFindOneAndDeleteNoMatch(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_find_one_and_delete_no_match" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + result, err := gc.Execute(ctx, dbName, `db.users.findOneAndDelete({ name: "nobody" })`) + require.NoError(t, err) + require.Equal(t, "null", result.Rows[0]) +} + +func TestFindOneAndDeleteWithSort(t *testing.T) { + client := testutil.GetClient(t) + dbName := "testdb_find_one_and_delete_sort" + defer testutil.CleanupDatabase(t, client, dbName) + + ctx := context.Background() + gc := gomongo.NewClient(client) + + _, err := gc.Execute(ctx, dbName, `db.users.insertMany([ + { name: "alice", score: 10 }, + { name: "alice", score: 20 } + ])`) + require.NoError(t, err) + + // Delete the alice with lowest score + result, err := gc.Execute(ctx, dbName, `db.users.findOneAndDelete( + { name: "alice" }, + { sort: { score: 1 } } + )`) + require.NoError(t, err) + require.Contains(t, result.Rows[0], `"score": 10`) + + // Verify only score=20 remains + verifyResult, err := gc.Execute(ctx, dbName, `db.users.findOne({ name: "alice" })`) + require.NoError(t, err) + require.Contains(t, verifyResult.Rows[0], `"score": 20`) +}