Skip to content

Commit c7fe133

Browse files
h3n4lclaude
andauthored
feat: support toArray no-op, runCommand, explain on read ops, and empty-statement no-op (#29)
Four mongosh compatibility fixes drawn from production gomongoFallback telemetry. Each one was fronting a real fallback to mongosh; with these in place ~9 distinct user-query shapes that previously fell back now run natively in gomongo. - toArray(): treat as a no-op cursor terminator alongside pretty(). The result rows are already materialized into Result.Value, so the chained call is purely cosmetic in mongo shell. Wild shape: db.coll.find({…}).limit(N).toArray(). - db.runCommand({…}): generic escape hatch. Adds OpRunCommand and a thin executor that hands the command body to mongo.Database.RunCommand and returns the bson.D server response unchanged. Used in the wild for the legacy `count` command form, and useful for any server command without a dedicated typed wrapper. - explain() on read operations: a trailing .explain() / .explain(verbosity) on find / aggregate / count / distinct rewrites the operation into {explain: <innerCommand>, verbosity: <v>} run via runCommand. Also accepts the aggregate-option form db.coll.aggregate([…], {explain: true}). Verbosity defaults to "queryPlanner" and is validated against the three accepted values. Write-op explain (update/delete) is left out for now. - Empty / comment-only input: gomongo previously rejected pure-comment statements with "empty statement: …". mongosh treats them as no-op successes; now gomongo does too via a new OpNoOp that the executor handles by returning an empty Result. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9938f7d commit c7fe133

12 files changed

Lines changed: 432 additions & 5 deletions

File tree

admin_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,3 +827,86 @@ func TestLatencyStats(t *testing.T) {
827827
require.Contains(t, row, `"latencyStats"`)
828828
})
829829
}
830+
831+
func TestRunCommand(t *testing.T) {
832+
testutil.RunOnAllDBs(t, func(t *testing.T, db testutil.TestDB) {
833+
dbName := fmt.Sprintf("testdb_run_command_%s", db.Name)
834+
defer testutil.CleanupDatabase(t, db.Client, dbName)
835+
836+
ctx := context.Background()
837+
_, err := db.Client.Database(dbName).Collection("users").InsertMany(ctx, []any{
838+
bson.M{"name": "alice"},
839+
bson.M{"name": "bob"},
840+
bson.M{"name": "carol"},
841+
})
842+
require.NoError(t, err)
843+
844+
gc := gomongo.NewClient(db.Client)
845+
846+
// ping is the canonical "does runCommand work" probe
847+
result, err := gc.Execute(ctx, dbName, `db.runCommand({ ping: 1 })`)
848+
require.NoError(t, err)
849+
require.Equal(t, 1, len(result.Value))
850+
require.Contains(t, valueToJSON(result.Value[0]), `"ok"`)
851+
852+
// Legacy "count" command form — the wild case from the gomongoFallback events
853+
result, err = gc.Execute(ctx, dbName, `db.runCommand({ count: "users", query: {} })`)
854+
require.NoError(t, err)
855+
require.Equal(t, 1, len(result.Value))
856+
row := valueToJSON(result.Value[0])
857+
require.Contains(t, row, `"n"`)
858+
require.Contains(t, row, `"ok"`)
859+
860+
// Empty body is rejected at parse time
861+
_, err = gc.Execute(ctx, dbName, `db.runCommand({})`)
862+
require.Error(t, err)
863+
864+
// Wrong arity is rejected
865+
_, err = gc.Execute(ctx, dbName, `db.runCommand({ ping: 1 }, { writeConcern: {} })`)
866+
require.Error(t, err)
867+
})
868+
}
869+
870+
func TestCommentOnlyStatementIsNoOp(t *testing.T) {
871+
testutil.RunOnAllDBs(t, func(t *testing.T, db testutil.TestDB) {
872+
dbName := fmt.Sprintf("testdb_comment_noop_%s", db.Name)
873+
defer testutil.CleanupDatabase(t, db.Client, dbName)
874+
875+
ctx := context.Background()
876+
gc := gomongo.NewClient(db.Client)
877+
878+
// Pure JS line comment — mongosh evaluates this as nothing,
879+
// gomongo should return an empty Result with no error.
880+
result, err := gc.Execute(ctx, dbName, `// db.users.updateOne(...)`)
881+
require.NoError(t, err)
882+
require.NotNil(t, result)
883+
require.Empty(t, result.Value)
884+
885+
// Multiple comment lines (the wild shape).
886+
result, err = gc.Execute(ctx, dbName, "// $gte: ISODate(\"2026-01-01T00:00:00Z\"),\n // $lt: ISODate(\"2026-02-01T00:00:00Z\")\n // },")
887+
require.NoError(t, err)
888+
require.Empty(t, result.Value)
889+
890+
// Block comment.
891+
result, err = gc.Execute(ctx, dbName, `/* nothing here */`)
892+
require.NoError(t, err)
893+
require.Empty(t, result.Value)
894+
895+
// Pure whitespace.
896+
result, err = gc.Execute(ctx, dbName, " \n\t ")
897+
require.NoError(t, err)
898+
require.Empty(t, result.Value)
899+
900+
// Empty string.
901+
result, err = gc.Execute(ctx, dbName, ``)
902+
require.NoError(t, err)
903+
require.Empty(t, result.Value)
904+
905+
// Comment + real statement: the real statement still wins.
906+
_, err = db.Client.Database(dbName).Collection("users").InsertOne(ctx, bson.M{"name": "alice"})
907+
require.NoError(t, err)
908+
result, err = gc.Execute(ctx, dbName, "// preceding comment\ndb.users.find({})")
909+
require.NoError(t, err)
910+
require.Equal(t, 1, len(result.Value))
911+
})
912+
}

client.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ func NewClient(client *mongo.Client) *Client {
3131
// - OpCreateIndexes: each element is string (index name)
3232
// - OpDropIndex, OpDropIndexes, OpCreateCollection, OpDropDatabase, OpRenameCollection: single bson.D with {ok: 1}
3333
// - OpDrop: single element of bool (true)
34-
// - OpDbStats, OpCollectionStats, OpServerStatus, OpServerBuildInfo, OpHostInfo, OpListCommands, OpValidate: single bson.D (command result)
34+
// - OpDbStats, OpCollectionStats, OpServerStatus, OpServerBuildInfo, OpHostInfo, OpListCommands, OpValidate, OpRunCommand: single bson.D (command result)
35+
// - OpNoOp: empty Value (input was comment-only / whitespace-only)
3536
// - OpDbVersion: single element of string (version)
3637
// - OpDataSize, OpStorageSize, OpTotalIndexSize: single numeric value from collStats
3738
// - OpTotalSize: single int64 (storageSize + totalIndexSize)

collection_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2473,3 +2473,113 @@ func TestPrettyNoOp(t *testing.T) {
24732473
require.Equal(t, 1, len(result.Value))
24742474
})
24752475
}
2476+
2477+
func TestToArrayNoOp(t *testing.T) {
2478+
testutil.RunOnAllDBs(t, func(t *testing.T, db testutil.TestDB) {
2479+
dbName := fmt.Sprintf("testdb_toarray_%s", db.Name)
2480+
defer testutil.CleanupDatabase(t, db.Client, dbName)
2481+
2482+
ctx := context.Background()
2483+
collection := db.Client.Database(dbName).Collection("users")
2484+
_, err := collection.InsertMany(ctx, []any{
2485+
bson.M{"name": "bob", "age": 25},
2486+
bson.M{"name": "alice", "age": 30},
2487+
bson.M{"name": "carol", "age": 28},
2488+
})
2489+
require.NoError(t, err)
2490+
2491+
gc := gomongo.NewClient(db.Client)
2492+
2493+
// toArray() at the end of a find()
2494+
result, err := gc.Execute(ctx, dbName, `db.users.find().toArray()`)
2495+
require.NoError(t, err)
2496+
require.Equal(t, 3, len(result.Value))
2497+
2498+
// toArray() with a filter and projection — the most common shape from the wild
2499+
result, err = gc.Execute(ctx, dbName, `db.users.find({ name: "alice" }, { _id: 0, name: 1 }).toArray()`)
2500+
require.NoError(t, err)
2501+
require.Equal(t, 1, len(result.Value))
2502+
rows := valuesToStrings(result.Value)
2503+
require.Contains(t, rows[0], `"alice"`)
2504+
2505+
// toArray() chained after limit()
2506+
result, err = gc.Execute(ctx, dbName, `db.users.find().limit(2).toArray()`)
2507+
require.NoError(t, err)
2508+
require.Equal(t, 2, len(result.Value))
2509+
2510+
// aggregate().toArray()
2511+
result, err = gc.Execute(ctx, dbName, `db.users.aggregate([{$match: {name: "alice"}}]).toArray()`)
2512+
require.NoError(t, err)
2513+
require.Equal(t, 1, len(result.Value))
2514+
})
2515+
}
2516+
2517+
func TestExplain(t *testing.T) {
2518+
testutil.RunOnMongoDBOnly(t, func(t *testing.T, db testutil.TestDB) {
2519+
dbName := fmt.Sprintf("testdb_explain_%s", db.Name)
2520+
defer testutil.CleanupDatabase(t, db.Client, dbName)
2521+
2522+
ctx := context.Background()
2523+
_, err := db.Client.Database(dbName).Collection("users").InsertMany(ctx, []any{
2524+
bson.M{"name": "alice", "age": 30, "city": "NYC"},
2525+
bson.M{"name": "bob", "age": 25, "city": "SF"},
2526+
bson.M{"name": "carol", "age": 28, "city": "NYC"},
2527+
})
2528+
require.NoError(t, err)
2529+
2530+
gc := gomongo.NewClient(db.Client)
2531+
2532+
// find().explain() — default verbosity is queryPlanner
2533+
result, err := gc.Execute(ctx, dbName, `db.users.find({ city: "NYC" }).explain()`)
2534+
require.NoError(t, err)
2535+
require.Equal(t, 1, len(result.Value))
2536+
require.Contains(t, valueToJSON(result.Value[0]), `"queryPlanner"`)
2537+
2538+
// find().explain("executionStats") — explicit verbosity
2539+
result, err = gc.Execute(ctx, dbName, `db.users.find({ name: "alice" }).explain("executionStats")`)
2540+
require.NoError(t, err)
2541+
require.Equal(t, 1, len(result.Value))
2542+
require.Contains(t, valueToJSON(result.Value[0]), `"executionStats"`)
2543+
2544+
// aggregate().explain()
2545+
result, err = gc.Execute(ctx, dbName, `db.users.aggregate([{ $match: { city: "NYC" } }]).explain()`)
2546+
require.NoError(t, err)
2547+
require.Equal(t, 1, len(result.Value))
2548+
require.Contains(t, valueToJSON(result.Value[0]), `"queryPlanner"`)
2549+
2550+
// aggregate(pipeline, {explain: true}) — option form, the wild case
2551+
result, err = gc.Execute(ctx, dbName, `db.users.aggregate([{ $match: { city: "NYC" } }], { explain: true })`)
2552+
require.NoError(t, err)
2553+
require.Equal(t, 1, len(result.Value))
2554+
require.Contains(t, valueToJSON(result.Value[0]), `"queryPlanner"`)
2555+
2556+
// count({…}).explain() — legacy count command form
2557+
result, err = gc.Execute(ctx, dbName, `db.users.count({ city: "NYC" }).explain()`)
2558+
require.NoError(t, err)
2559+
require.Equal(t, 1, len(result.Value))
2560+
2561+
// count().explain() — zero-arg routes to estimatedDocumentCount, still uses count command
2562+
result, err = gc.Execute(ctx, dbName, `db.users.count().explain()`)
2563+
require.NoError(t, err)
2564+
require.Equal(t, 1, len(result.Value))
2565+
2566+
// distinct().explain()
2567+
result, err = gc.Execute(ctx, dbName, `db.users.distinct("city").explain()`)
2568+
require.NoError(t, err)
2569+
require.Equal(t, 1, len(result.Value))
2570+
2571+
// Negative cases
2572+
_, err = gc.Execute(ctx, dbName, `db.users.find().explain("bogus")`)
2573+
require.Error(t, err)
2574+
require.Contains(t, err.Error(), "verbosity")
2575+
2576+
_, err = gc.Execute(ctx, dbName, `db.users.find().explain("queryPlanner", "extra")`)
2577+
require.Error(t, err)
2578+
require.Contains(t, err.Error(), "at most 1 argument")
2579+
2580+
// explain on an unsupported operation type — write ops aren't covered yet
2581+
_, err = gc.Execute(ctx, dbName, `db.users.deleteOne({ name: "alice" }).explain()`)
2582+
require.Error(t, err)
2583+
require.Contains(t, err.Error(), "explain() is not supported")
2584+
})
2585+
}

internal/executor/admin.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,18 @@ func executeListCommands(ctx context.Context, client *mongo.Client, database str
444444
return &Result{Operation: types.OpListCommands, Value: []any{result}}, nil
445445
}
446446

447+
// executeRunCommand executes a db.runCommand({...}) and returns the server response.
448+
// The command body is passed through to the server unchanged; gomongo does not
449+
// attempt to interpret or rewrite individual command names. This is the generic
450+
// escape hatch for commands that don't have a dedicated typed wrapper.
451+
func executeRunCommand(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) {
452+
result, err := runCommand(ctx, client.Database(database), op.Command)
453+
if err != nil {
454+
return nil, fmt.Errorf("runCommand failed: %w", err)
455+
}
456+
return &Result{Operation: types.OpRunCommand, Value: []any{result}}, nil
457+
}
458+
447459
// executeDataSize executes a db.collection.dataSize() command.
448460
func executeDataSize(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) {
449461
stats, err := runCollStats(ctx, client, database, op.Collection)

internal/executor/executor.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ type Result struct {
1818
// Execute executes a parsed operation against MongoDB.
1919
func Execute(ctx context.Context, client *mongo.Client, database string, op *translator.Operation, statement string, maxRows *int64) (*Result, error) {
2020
switch op.OpType {
21+
case types.OpNoOp:
22+
// Comment-only / whitespace-only input — return empty Result.
23+
return &Result{Operation: types.OpNoOp}, nil
2124
case types.OpFind:
2225
return executeFind(ctx, client, database, op, maxRows)
2326
case types.OpFindOne:
@@ -92,6 +95,8 @@ func Execute(ctx context.Context, client *mongo.Client, database string, op *tra
9295
return executeHostInfo(ctx, client, database)
9396
case types.OpListCommands:
9497
return executeListCommands(ctx, client, database)
98+
case types.OpRunCommand:
99+
return executeRunCommand(ctx, client, database, op)
95100
// Collection Information
96101
case types.OpDataSize:
97102
return executeDataSize(ctx, client, database, op)

internal/translator/collection.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,15 @@ func extractAggregateArgs(op *Operation, args []ast.Node) error {
256256
} else {
257257
return fmt.Errorf("aggregate() maxTimeMS must be a number")
258258
}
259+
case "explain":
260+
val, ok := opt.Value.(bool)
261+
if !ok {
262+
return fmt.Errorf("aggregate() explain must be a boolean")
263+
}
264+
if val {
265+
verbosity := defaultExplainVerbosity
266+
op.Explain = &verbosity
267+
}
259268
default:
260269
return &UnsupportedOptionError{
261270
Method: "aggregate()",

internal/translator/database.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,18 @@ func extractCreateCollectionArgs(op *Operation, args []ast.Node) (*Operation, er
124124
}
125125
return op, nil
126126
}
127+
128+
func extractRunCommandArgs(op *Operation, args []ast.Node) (*Operation, error) {
129+
if len(args) != 1 {
130+
return nil, fmt.Errorf("runCommand() takes exactly 1 argument")
131+
}
132+
command, err := requireDocument(args, 0, "runCommand() command")
133+
if err != nil {
134+
return nil, err
135+
}
136+
if len(command) == 0 {
137+
return nil, fmt.Errorf("runCommand() command cannot be empty")
138+
}
139+
op.Command = command
140+
return op, nil
141+
}

0 commit comments

Comments
 (0)