diff --git a/admin_test.go b/admin_test.go index c61c693..7c76376 100644 --- a/admin_test.go +++ b/admin_test.go @@ -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)) + }) +} diff --git a/client.go b/client.go index 0476779..b8b887e 100644 --- a/client.go +++ b/client.go @@ -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) diff --git a/collection_test.go b/collection_test.go index c2235ec..46f3f41 100644 --- a/collection_test.go +++ b/collection_test.go @@ -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") + }) +} diff --git a/internal/executor/admin.go b/internal/executor/admin.go index d7661e3..821818c 100644 --- a/internal/executor/admin.go +++ b/internal/executor/admin.go @@ -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) diff --git a/internal/executor/executor.go b/internal/executor/executor.go index f203972..029953d 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -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: @@ -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) diff --git a/internal/translator/collection.go b/internal/translator/collection.go index e409c77..f941f92 100644 --- a/internal/translator/collection.go +++ b/internal/translator/collection.go @@ -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()", diff --git a/internal/translator/database.go b/internal/translator/database.go index eba19a4..6cafae7 100644 --- a/internal/translator/database.go +++ b/internal/translator/database.go @@ -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 +} diff --git a/internal/translator/explain.go b/internal/translator/explain.go new file mode 100644 index 0000000..7b16aa3 --- /dev/null +++ b/internal/translator/explain.go @@ -0,0 +1,158 @@ +package translator + +import ( + "fmt" + + "github.com/bytebase/gomongo/types" + "github.com/bytebase/omni/mongo/ast" + "go.mongodb.org/mongo-driver/v2/bson" +) + +const defaultExplainVerbosity = "queryPlanner" + +func isValidExplainVerbosity(v string) bool { + switch v { + case "queryPlanner", "executionStats", "allPlansExecution": + return true + } + return false +} + +// applyExplainCursor handles a trailing .explain() / .explain("verbosity") +// cursor terminator by recording the requested verbosity on the operation. +// The actual rewrite into a runCommand happens in applyExplainIfRequested +// once cursor-method translation is complete. +func applyExplainCursor(op *Operation, args []ast.Node) error { + verbosity := defaultExplainVerbosity + switch len(args) { + case 0: + case 1: + v, err := requireString(args, 0, "explain() verbosity") + if err != nil { + return err + } + verbosity = v + default: + return fmt.Errorf("explain() takes at most 1 argument") + } + if !isValidExplainVerbosity(verbosity) { + return fmt.Errorf("explain() verbosity must be queryPlanner, executionStats, or allPlansExecution; got %q", verbosity) + } + op.Explain = &verbosity + return nil +} + +// applyExplainIfRequested rewrites op into an OpRunCommand carrying +// {explain: , verbosity: } when op.Explain is set. +// Supported inner operations: find, aggregate, count (zero-arg + filtered), +// countDocuments, distinct. +func applyExplainIfRequested(op *Operation) error { + if op.Explain == nil { + return nil + } + inner, err := buildExplainInner(op) + if err != nil { + return err + } + op.Command = bson.D{ + {Key: "explain", Value: inner}, + {Key: "verbosity", Value: *op.Explain}, + } + op.OpType = types.OpRunCommand + return nil +} + +func buildExplainInner(op *Operation) (bson.D, error) { + switch op.OpType { + case types.OpFind: + return buildFindCommand(op), nil + case types.OpAggregate: + return buildAggregateCommand(op), nil + case types.OpEstimatedDocumentCount, types.OpCountDocuments: + return buildCountCommand(op), nil + case types.OpDistinct: + return buildDistinctCommand(op), nil + default: + return nil, fmt.Errorf("explain() is not supported for this operation; supported: find, aggregate, count, countDocuments, distinct") + } +} + +func buildFindCommand(op *Operation) bson.D { + cmd := bson.D{{Key: "find", Value: op.Collection}} + if len(op.Filter) > 0 { + cmd = append(cmd, bson.E{Key: "filter", Value: op.Filter}) + } + if len(op.Projection) > 0 { + cmd = append(cmd, bson.E{Key: "projection", Value: op.Projection}) + } + if len(op.Sort) > 0 { + cmd = append(cmd, bson.E{Key: "sort", Value: op.Sort}) + } + if op.Limit != nil { + cmd = append(cmd, bson.E{Key: "limit", Value: *op.Limit}) + } + if op.Skip != nil { + cmd = append(cmd, bson.E{Key: "skip", Value: *op.Skip}) + } + if op.Hint != nil { + cmd = append(cmd, bson.E{Key: "hint", Value: op.Hint}) + } + if len(op.Max) > 0 { + cmd = append(cmd, bson.E{Key: "max", Value: op.Max}) + } + if len(op.Min) > 0 { + cmd = append(cmd, bson.E{Key: "min", Value: op.Min}) + } + return cmd +} + +func buildAggregateCommand(op *Operation) bson.D { + pipeline := op.Pipeline + if pipeline == nil { + pipeline = bson.A{} + } + cmd := bson.D{ + {Key: "aggregate", Value: op.Collection}, + {Key: "pipeline", Value: pipeline}, + // The aggregate command requires either a "cursor" field or + // "explain: true" at the inner level. We're at the explain wrapper + // layer, so the inner command still needs a cursor field; an empty + // document is the conventional "default cursor settings" form. + {Key: "cursor", Value: bson.D{}}, + } + if op.Hint != nil { + cmd = append(cmd, bson.E{Key: "hint", Value: op.Hint}) + } + if op.MaxTimeMS != nil { + cmd = append(cmd, bson.E{Key: "maxTimeMS", Value: *op.MaxTimeMS}) + } + return cmd +} + +func buildCountCommand(op *Operation) bson.D { + cmd := bson.D{{Key: "count", Value: op.Collection}} + if len(op.Filter) > 0 { + cmd = append(cmd, bson.E{Key: "query", Value: op.Filter}) + } + if op.Limit != nil { + cmd = append(cmd, bson.E{Key: "limit", Value: *op.Limit}) + } + if op.Skip != nil { + cmd = append(cmd, bson.E{Key: "skip", Value: *op.Skip}) + } + if op.Hint != nil { + cmd = append(cmd, bson.E{Key: "hint", Value: op.Hint}) + } + return cmd +} + +func buildDistinctCommand(op *Operation) bson.D { + cmd := bson.D{ + {Key: "distinct", Value: op.Collection}, + {Key: "key", Value: op.DistinctField}, + } + if len(op.Filter) > 0 { + cmd = append(cmd, bson.E{Key: "query", Value: op.Filter}) + } + return cmd +} diff --git a/internal/translator/translate.go b/internal/translator/translate.go index 06f57e6..24a1f97 100644 --- a/internal/translator/translate.go +++ b/internal/translator/translate.go @@ -58,6 +58,9 @@ func translateDatabaseStatement(op *Operation, stmt *ast.DatabaseStatement) (*Op op.OpType = types.OpHostInfo case "listCommands": op.OpType = types.OpListCommands + case "runCommand": + op.OpType = types.OpRunCommand + return extractRunCommandArgs(op, stmt.Args) default: return nil, &UnsupportedOperationError{Operation: stmt.Method + "()"} } @@ -231,6 +234,12 @@ func translateCollectionStatement(op *Operation, stmt *ast.CollectionStatement) } } + // If the operation was tagged for explain (via a trailing .explain() or an + // aggregate {explain: true} option), rewrite it into an explain runCommand. + if err := applyExplainIfRequested(op); err != nil { + return nil, err + } + return op, nil } @@ -250,8 +259,13 @@ func translateCursorMethod(op *Operation, cm ast.CursorMethod) error { return extractMax(op, cm.Args) case "min": return extractMin(op, cm.Args) - case "pretty": - return nil // no-op + case "pretty", "toArray": + // pretty(): formatting hint, no-op (results are returned structured). + // toArray(): cursor terminator, no-op (gomongo materializes the + // cursor's results into Result.Value already). + return nil + case "explain": + return applyExplainCursor(op, cm.Args) case "count", "itcount", "size": // mongosh's cursor.count() never iterates the cursor; it issues a // separate count command server-side. We mirror that by retargeting diff --git a/internal/translator/translator.go b/internal/translator/translator.go index 0ceca43..e15a0b5 100644 --- a/internal/translator/translator.go +++ b/internal/translator/translator.go @@ -2,13 +2,18 @@ package translator import ( "errors" - "fmt" + "github.com/bytebase/gomongo/types" "github.com/bytebase/omni/mongo" "github.com/bytebase/omni/mongo/parser" ) // Parse parses a MongoDB shell statement and returns the operation. +// +// Input that contains no executable statements (e.g., only comments or +// whitespace) is treated as a no-op: Parse returns an Operation with +// OpType = OpNoOp and no error, mirroring mongosh, where evaluating a +// comment-only line is silently successful. func Parse(statement string) (*Operation, error) { stmts, err := mongo.Parse(statement) if err != nil { @@ -30,5 +35,6 @@ func Parse(statement string) (*Operation, error) { } } - return nil, &ParseError{Message: fmt.Sprintf("empty statement: %s", statement)} + // Comment-only / whitespace-only input: no-op, no error. + return &Operation{OpType: types.OpNoOp}, nil } diff --git a/internal/translator/types.go b/internal/translator/types.go index c837a24..f842672 100644 --- a/internal/translator/types.go +++ b/internal/translator/types.go @@ -63,4 +63,13 @@ type Operation struct { ValidationLevel string // createCollection validationLevel option ValidationAction string // createCollection validationAction option Validator bson.D // createCollection validator option + + // runCommand + Command bson.D // runCommand body + + // explain — when non-nil, the operation runs as a server "explain" wrapper + // at this verbosity instead of executing for real. Set either by an + // explicit .explain([verbosity]) cursor terminator or by aggregate's + // {explain: true} option. Resolved into an OpRunCommand after parsing. + Explain *string } diff --git a/types/operation_type.go b/types/operation_type.go index fd6fe8a..103fb24 100644 --- a/types/operation_type.go +++ b/types/operation_type.go @@ -6,6 +6,10 @@ type OperationType int const ( OpUnknown OperationType = iota + // OpNoOp represents an input that contains no executable statements + // (e.g., the entire input is a JS comment or whitespace). Executors + // return an empty Result for OpNoOp without contacting the server. + OpNoOp OpFind OpFindOne OpAggregate @@ -45,6 +49,7 @@ const ( OpDbVersion OpHostInfo OpListCommands + OpRunCommand // Collection Information OpDataSize OpStorageSize