Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -827,3 +827,86 @@ func TestLatencyStats(t *testing.T) {
require.Contains(t, row, `"latencyStats"`)
})
}

func TestRunCommand(t *testing.T) {
testutil.RunOnAllDBs(t, func(t *testing.T, db testutil.TestDB) {
dbName := fmt.Sprintf("testdb_run_command_%s", db.Name)
defer testutil.CleanupDatabase(t, db.Client, dbName)

ctx := context.Background()
_, err := db.Client.Database(dbName).Collection("users").InsertMany(ctx, []any{
bson.M{"name": "alice"},
bson.M{"name": "bob"},
bson.M{"name": "carol"},
})
require.NoError(t, err)

gc := gomongo.NewClient(db.Client)

// ping is the canonical "does runCommand work" probe
result, err := gc.Execute(ctx, dbName, `db.runCommand({ ping: 1 })`)
require.NoError(t, err)
require.Equal(t, 1, len(result.Value))
require.Contains(t, valueToJSON(result.Value[0]), `"ok"`)

// Legacy "count" command form — the wild case from the gomongoFallback events
result, err = gc.Execute(ctx, dbName, `db.runCommand({ count: "users", query: {} })`)
require.NoError(t, err)
require.Equal(t, 1, len(result.Value))
row := valueToJSON(result.Value[0])
require.Contains(t, row, `"n"`)
require.Contains(t, row, `"ok"`)

// Empty body is rejected at parse time
_, err = gc.Execute(ctx, dbName, `db.runCommand({})`)
require.Error(t, err)

// Wrong arity is rejected
_, err = gc.Execute(ctx, dbName, `db.runCommand({ ping: 1 }, { writeConcern: {} })`)
require.Error(t, err)
})
}

func TestCommentOnlyStatementIsNoOp(t *testing.T) {
testutil.RunOnAllDBs(t, func(t *testing.T, db testutil.TestDB) {
dbName := fmt.Sprintf("testdb_comment_noop_%s", db.Name)
defer testutil.CleanupDatabase(t, db.Client, dbName)

ctx := context.Background()
gc := gomongo.NewClient(db.Client)

// Pure JS line comment — mongosh evaluates this as nothing,
// gomongo should return an empty Result with no error.
result, err := gc.Execute(ctx, dbName, `// db.users.updateOne(...)`)
require.NoError(t, err)
require.NotNil(t, result)
require.Empty(t, result.Value)

// Multiple comment lines (the wild shape).
result, err = gc.Execute(ctx, dbName, "// $gte: ISODate(\"2026-01-01T00:00:00Z\"),\n // $lt: ISODate(\"2026-02-01T00:00:00Z\")\n // },")
require.NoError(t, err)
require.Empty(t, result.Value)

// Block comment.
result, err = gc.Execute(ctx, dbName, `/* nothing here */`)
require.NoError(t, err)
require.Empty(t, result.Value)

// Pure whitespace.
result, err = gc.Execute(ctx, dbName, " \n\t ")
require.NoError(t, err)
require.Empty(t, result.Value)

// Empty string.
result, err = gc.Execute(ctx, dbName, ``)
require.NoError(t, err)
require.Empty(t, result.Value)

// Comment + real statement: the real statement still wins.
_, err = db.Client.Database(dbName).Collection("users").InsertOne(ctx, bson.M{"name": "alice"})
require.NoError(t, err)
result, err = gc.Execute(ctx, dbName, "// preceding comment\ndb.users.find({})")
require.NoError(t, err)
require.Equal(t, 1, len(result.Value))
})
}
3 changes: 2 additions & 1 deletion client.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ func NewClient(client *mongo.Client) *Client {
// - OpCreateIndexes: each element is string (index name)
// - OpDropIndex, OpDropIndexes, OpCreateCollection, OpDropDatabase, OpRenameCollection: single bson.D with {ok: 1}
// - OpDrop: single element of bool (true)
// - OpDbStats, OpCollectionStats, OpServerStatus, OpServerBuildInfo, OpHostInfo, OpListCommands, OpValidate: single bson.D (command result)
// - OpDbStats, OpCollectionStats, OpServerStatus, OpServerBuildInfo, OpHostInfo, OpListCommands, OpValidate, OpRunCommand: single bson.D (command result)
// - OpNoOp: empty Value (input was comment-only / whitespace-only)
// - OpDbVersion: single element of string (version)
// - OpDataSize, OpStorageSize, OpTotalIndexSize: single numeric value from collStats
// - OpTotalSize: single int64 (storageSize + totalIndexSize)
Expand Down
110 changes: 110 additions & 0 deletions collection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2473,3 +2473,113 @@ func TestPrettyNoOp(t *testing.T) {
require.Equal(t, 1, len(result.Value))
})
}

func TestToArrayNoOp(t *testing.T) {
testutil.RunOnAllDBs(t, func(t *testing.T, db testutil.TestDB) {
dbName := fmt.Sprintf("testdb_toarray_%s", db.Name)
defer testutil.CleanupDatabase(t, db.Client, dbName)

ctx := context.Background()
collection := db.Client.Database(dbName).Collection("users")
_, err := collection.InsertMany(ctx, []any{
bson.M{"name": "bob", "age": 25},
bson.M{"name": "alice", "age": 30},
bson.M{"name": "carol", "age": 28},
})
require.NoError(t, err)

gc := gomongo.NewClient(db.Client)

// toArray() at the end of a find()
result, err := gc.Execute(ctx, dbName, `db.users.find().toArray()`)
require.NoError(t, err)
require.Equal(t, 3, len(result.Value))

// toArray() with a filter and projection — the most common shape from the wild
result, err = gc.Execute(ctx, dbName, `db.users.find({ name: "alice" }, { _id: 0, name: 1 }).toArray()`)
require.NoError(t, err)
require.Equal(t, 1, len(result.Value))
rows := valuesToStrings(result.Value)
require.Contains(t, rows[0], `"alice"`)

// toArray() chained after limit()
result, err = gc.Execute(ctx, dbName, `db.users.find().limit(2).toArray()`)
require.NoError(t, err)
require.Equal(t, 2, len(result.Value))

// aggregate().toArray()
result, err = gc.Execute(ctx, dbName, `db.users.aggregate([{$match: {name: "alice"}}]).toArray()`)
require.NoError(t, err)
require.Equal(t, 1, len(result.Value))
})
}

func TestExplain(t *testing.T) {
testutil.RunOnMongoDBOnly(t, func(t *testing.T, db testutil.TestDB) {
dbName := fmt.Sprintf("testdb_explain_%s", db.Name)
defer testutil.CleanupDatabase(t, db.Client, dbName)

ctx := context.Background()
_, err := db.Client.Database(dbName).Collection("users").InsertMany(ctx, []any{
bson.M{"name": "alice", "age": 30, "city": "NYC"},
bson.M{"name": "bob", "age": 25, "city": "SF"},
bson.M{"name": "carol", "age": 28, "city": "NYC"},
})
require.NoError(t, err)

gc := gomongo.NewClient(db.Client)

// find().explain() — default verbosity is queryPlanner
result, err := gc.Execute(ctx, dbName, `db.users.find({ city: "NYC" }).explain()`)
require.NoError(t, err)
require.Equal(t, 1, len(result.Value))
require.Contains(t, valueToJSON(result.Value[0]), `"queryPlanner"`)

// find().explain("executionStats") — explicit verbosity
result, err = gc.Execute(ctx, dbName, `db.users.find({ name: "alice" }).explain("executionStats")`)
require.NoError(t, err)
require.Equal(t, 1, len(result.Value))
require.Contains(t, valueToJSON(result.Value[0]), `"executionStats"`)

// aggregate().explain()
result, err = gc.Execute(ctx, dbName, `db.users.aggregate([{ $match: { city: "NYC" } }]).explain()`)
require.NoError(t, err)
require.Equal(t, 1, len(result.Value))
require.Contains(t, valueToJSON(result.Value[0]), `"queryPlanner"`)

// aggregate(pipeline, {explain: true}) — option form, the wild case
result, err = gc.Execute(ctx, dbName, `db.users.aggregate([{ $match: { city: "NYC" } }], { explain: true })`)
require.NoError(t, err)
require.Equal(t, 1, len(result.Value))
require.Contains(t, valueToJSON(result.Value[0]), `"queryPlanner"`)

// count({…}).explain() — legacy count command form
result, err = gc.Execute(ctx, dbName, `db.users.count({ city: "NYC" }).explain()`)
require.NoError(t, err)
require.Equal(t, 1, len(result.Value))

// count().explain() — zero-arg routes to estimatedDocumentCount, still uses count command
result, err = gc.Execute(ctx, dbName, `db.users.count().explain()`)
require.NoError(t, err)
require.Equal(t, 1, len(result.Value))

// distinct().explain()
result, err = gc.Execute(ctx, dbName, `db.users.distinct("city").explain()`)
require.NoError(t, err)
require.Equal(t, 1, len(result.Value))

// Negative cases
_, err = gc.Execute(ctx, dbName, `db.users.find().explain("bogus")`)
require.Error(t, err)
require.Contains(t, err.Error(), "verbosity")

_, err = gc.Execute(ctx, dbName, `db.users.find().explain("queryPlanner", "extra")`)
require.Error(t, err)
require.Contains(t, err.Error(), "at most 1 argument")

// explain on an unsupported operation type — write ops aren't covered yet
_, err = gc.Execute(ctx, dbName, `db.users.deleteOne({ name: "alice" }).explain()`)
require.Error(t, err)
require.Contains(t, err.Error(), "explain() is not supported")
})
}
12 changes: 12 additions & 0 deletions internal/executor/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,18 @@ func executeListCommands(ctx context.Context, client *mongo.Client, database str
return &Result{Operation: types.OpListCommands, Value: []any{result}}, nil
}

// executeRunCommand executes a db.runCommand({...}) and returns the server response.
// The command body is passed through to the server unchanged; gomongo does not
// attempt to interpret or rewrite individual command names. This is the generic
// escape hatch for commands that don't have a dedicated typed wrapper.
func executeRunCommand(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) {
result, err := runCommand(ctx, client.Database(database), op.Command)
if err != nil {
return nil, fmt.Errorf("runCommand failed: %w", err)
}
return &Result{Operation: types.OpRunCommand, Value: []any{result}}, nil
}

// executeDataSize executes a db.collection.dataSize() command.
func executeDataSize(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) {
stats, err := runCollStats(ctx, client, database, op.Collection)
Expand Down
5 changes: 5 additions & 0 deletions internal/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ type Result struct {
// Execute executes a parsed operation against MongoDB.
func Execute(ctx context.Context, client *mongo.Client, database string, op *translator.Operation, statement string, maxRows *int64) (*Result, error) {
switch op.OpType {
case types.OpNoOp:
// Comment-only / whitespace-only input — return empty Result.
return &Result{Operation: types.OpNoOp}, nil
case types.OpFind:
return executeFind(ctx, client, database, op, maxRows)
case types.OpFindOne:
Expand Down Expand Up @@ -92,6 +95,8 @@ func Execute(ctx context.Context, client *mongo.Client, database string, op *tra
return executeHostInfo(ctx, client, database)
case types.OpListCommands:
return executeListCommands(ctx, client, database)
case types.OpRunCommand:
return executeRunCommand(ctx, client, database, op)
// Collection Information
case types.OpDataSize:
return executeDataSize(ctx, client, database, op)
Expand Down
9 changes: 9 additions & 0 deletions internal/translator/collection.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,15 @@ func extractAggregateArgs(op *Operation, args []ast.Node) error {
} else {
return fmt.Errorf("aggregate() maxTimeMS must be a number")
}
case "explain":
val, ok := opt.Value.(bool)
if !ok {
return fmt.Errorf("aggregate() explain must be a boolean")
}
if val {
verbosity := defaultExplainVerbosity
op.Explain = &verbosity
}
default:
return &UnsupportedOptionError{
Method: "aggregate()",
Expand Down
15 changes: 15 additions & 0 deletions internal/translator/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,18 @@ func extractCreateCollectionArgs(op *Operation, args []ast.Node) (*Operation, er
}
return op, nil
}

func extractRunCommandArgs(op *Operation, args []ast.Node) (*Operation, error) {
if len(args) != 1 {
return nil, fmt.Errorf("runCommand() takes exactly 1 argument")
}
command, err := requireDocument(args, 0, "runCommand() command")
if err != nil {
return nil, err
}
if len(command) == 0 {
return nil, fmt.Errorf("runCommand() command cannot be empty")
}
op.Command = command
return op, nil
}
Loading
Loading