Skip to content

Commit c296164

Browse files
h3n4lclaude
andauthored
feat: implement high-ROI milestone 3 administrative operations (#20)
* feat: implement high-ROI milestone 3 administrative operations Add support for 7 commonly-used MongoDB administrative methods: Index Management: - db.collection.createIndex(keys, options?) - db.collection.dropIndex(index) - db.collection.dropIndexes() Collection Management: - db.collection.drop() - db.collection.renameCollection(newName, dropTarget?) Database Management: - db.createCollection(name) - db.dropDatabase() Lower-ROI methods (stats, serverStatus, etc.) remain as planned operations with PlannedOperationError for fallback to mongosh. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address PR review comments - createCollection(): reject options argument (not supported yet) - dropIndexes(): reject array argument instead of silently dropping all - createIndex(): reject unsupported options instead of ignoring them - Remove unused IndexModels field and IndexModel type Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: enhance M3 admin operations with extended options support Address PR review feedback by implementing proper support instead of rejecting unsupported features: createCollection options: - capped: boolean for capped collections - size: size in bytes for capped collections - max: max documents for capped collections - validator: validation rules document - validationLevel/validationAction: validation settings createIndex options: - unique: boolean for unique indexes - sparse: boolean for sparse indexes - expireAfterSeconds: TTL index support - background: accepted but deprecated (no-op) dropIndexes enhancements: - Support array of index names to drop multiple specific indexes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: centralize numeric helper functions in translator package Address PR review comment about duplicate toInt64 helper by: - Exporting ToInt64 and ToInt32 from translator/helpers.go - Removing duplicate toInt64 from executor/admin.go - Using translator.ToInt64 in executor for numeric comparisons Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5e3ae90 commit c296164

12 files changed

Lines changed: 1299 additions & 36 deletions

File tree

admin_test.go

Lines changed: 423 additions & 0 deletions
Large diffs are not rendered by default.

error_test.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,14 @@ func TestPlannedOperation(t *testing.T) {
3434
gc := gomongo.NewClient(db.Client)
3535
ctx := context.Background()
3636

37-
// createIndex is a planned M3 operation - should return PlannedOperationError
38-
_, err := gc.Execute(ctx, dbName, "db.users.createIndex({ name: 1 })")
37+
// createIndexes is a planned M3 operation - should return PlannedOperationError
38+
// (createIndex is now implemented, so we use createIndexes instead)
39+
_, err := gc.Execute(ctx, dbName, "db.users.createIndexes([{ key: { name: 1 } }])")
3940
require.Error(t, err)
4041

4142
var plannedErr *gomongo.PlannedOperationError
4243
require.ErrorAs(t, err, &plannedErr)
43-
require.Equal(t, "createIndex()", plannedErr.Operation)
44+
require.Equal(t, "createIndexes()", plannedErr.Operation)
4445
})
4546
}
4647

@@ -83,9 +84,12 @@ func TestUnsupportedOptionError(t *testing.T) {
8384
func TestMethodRegistryStats(t *testing.T) {
8485
total := gomongo.MethodRegistryStats()
8586

86-
// Registry should contain M3 (22) planned methods
87-
// M2 write operations have been implemented and removed from the registry
88-
require.Equal(t, 22, total, "expected 22 planned methods in registry (M3: 22)")
87+
// Registry should contain 15 planned methods after M3 high-ROI implementations
88+
// M3 high-ROI methods implemented (removed from registry):
89+
// - createIndex, dropIndex, dropIndexes (index management: 3)
90+
// - drop, createCollection, dropDatabase, renameCollection (collection management: 4)
91+
// M3 remaining planned methods: 15 (originally 22)
92+
require.Equal(t, 15, total, "expected 15 planned methods in registry (M3 remaining)")
8993

9094
// Log stats for visibility
9195
t.Logf("Method Registry Stats: total=%d planned methods", total)

internal/executor/admin.go

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
package executor
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/bytebase/gomongo/internal/translator"
8+
"go.mongodb.org/mongo-driver/v2/bson"
9+
"go.mongodb.org/mongo-driver/v2/mongo"
10+
"go.mongodb.org/mongo-driver/v2/mongo/options"
11+
)
12+
13+
// executeCreateIndex executes a db.collection.createIndex() command.
14+
func executeCreateIndex(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) {
15+
collection := client.Database(database).Collection(op.Collection)
16+
17+
indexModel := mongo.IndexModel{
18+
Keys: op.IndexKeys,
19+
}
20+
21+
// Build index options
22+
opts := options.Index()
23+
hasOptions := false
24+
25+
if op.IndexName != "" {
26+
opts.SetName(op.IndexName)
27+
hasOptions = true
28+
}
29+
if op.IndexUnique != nil && *op.IndexUnique {
30+
opts.SetUnique(true)
31+
hasOptions = true
32+
}
33+
if op.IndexSparse != nil && *op.IndexSparse {
34+
opts.SetSparse(true)
35+
hasOptions = true
36+
}
37+
if op.IndexTTL != nil {
38+
opts.SetExpireAfterSeconds(*op.IndexTTL)
39+
hasOptions = true
40+
}
41+
42+
if hasOptions {
43+
indexModel.Options = opts
44+
}
45+
46+
indexName, err := collection.Indexes().CreateOne(ctx, indexModel)
47+
if err != nil {
48+
return nil, fmt.Errorf("createIndex failed: %w", err)
49+
}
50+
51+
return &Result{
52+
Rows: []string{indexName},
53+
RowCount: 1,
54+
}, nil
55+
}
56+
57+
// executeDropIndex executes a db.collection.dropIndex() command.
58+
func executeDropIndex(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) {
59+
collection := client.Database(database).Collection(op.Collection)
60+
61+
var err error
62+
if op.IndexName != "" {
63+
// Drop by index name
64+
err = collection.Indexes().DropOne(ctx, op.IndexName)
65+
} else if op.IndexKeys != nil {
66+
// Drop by index key specification - need to find the index name first
67+
cursor, listErr := collection.Indexes().List(ctx)
68+
if listErr != nil {
69+
return nil, fmt.Errorf("dropIndex failed: %w", listErr)
70+
}
71+
defer func() { _ = cursor.Close(ctx) }()
72+
73+
var indexName string
74+
for cursor.Next(ctx) {
75+
var idx bson.M
76+
if decodeErr := cursor.Decode(&idx); decodeErr != nil {
77+
return nil, fmt.Errorf("dropIndex failed: %w", decodeErr)
78+
}
79+
// Check if keys match
80+
if keysMatch(idx["key"], op.IndexKeys) {
81+
indexName, _ = idx["name"].(string)
82+
break
83+
}
84+
}
85+
if indexName == "" {
86+
return nil, fmt.Errorf("dropIndex failed: index not found")
87+
}
88+
err = collection.Indexes().DropOne(ctx, indexName)
89+
} else {
90+
return nil, fmt.Errorf("dropIndex failed: no index specified")
91+
}
92+
93+
if err != nil {
94+
return nil, fmt.Errorf("dropIndex failed: %w", err)
95+
}
96+
97+
response := bson.M{"ok": 1}
98+
jsonBytes, err := bson.MarshalExtJSONIndent(response, false, false, "", " ")
99+
if err != nil {
100+
return nil, fmt.Errorf("marshal failed: %w", err)
101+
}
102+
103+
return &Result{
104+
Rows: []string{string(jsonBytes)},
105+
RowCount: 1,
106+
}, nil
107+
}
108+
109+
// keysMatch compares two index key specifications.
110+
func keysMatch(a any, b bson.D) bool {
111+
switch keys := a.(type) {
112+
case bson.D:
113+
if len(keys) != len(b) {
114+
return false
115+
}
116+
for i, elem := range keys {
117+
if elem.Key != b[i].Key {
118+
return false
119+
}
120+
// Compare values (could be int32, int64, string, etc.)
121+
if !valuesEqual(elem.Value, b[i].Value) {
122+
return false
123+
}
124+
}
125+
return true
126+
case bson.M:
127+
if len(keys) != len(b) {
128+
return false
129+
}
130+
for _, elem := range b {
131+
val, ok := keys[elem.Key]
132+
if !ok {
133+
return false
134+
}
135+
if !valuesEqual(val, elem.Value) {
136+
return false
137+
}
138+
}
139+
return true
140+
}
141+
return false
142+
}
143+
144+
// valuesEqual compares two values that could be different numeric types.
145+
func valuesEqual(a, b any) bool {
146+
// Convert both to int64 for comparison if they're numeric
147+
aInt, aOk := translator.ToInt64(a)
148+
bInt, bOk := translator.ToInt64(b)
149+
if aOk && bOk {
150+
return aInt == bInt
151+
}
152+
// Otherwise compare directly
153+
return a == b
154+
}
155+
156+
// executeDropIndexes executes a db.collection.dropIndexes() command.
157+
func executeDropIndexes(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) {
158+
collection := client.Database(database).Collection(op.Collection)
159+
160+
var err error
161+
if len(op.IndexNames) > 0 {
162+
// Drop each index in the array
163+
for _, name := range op.IndexNames {
164+
if dropErr := collection.Indexes().DropOne(ctx, name); dropErr != nil {
165+
return nil, fmt.Errorf("dropIndexes failed for index %q: %w", name, dropErr)
166+
}
167+
}
168+
} else if op.IndexName == "*" || op.IndexName == "" {
169+
// Drop all indexes (except _id)
170+
err = collection.Indexes().DropAll(ctx)
171+
} else {
172+
// Drop specific index
173+
err = collection.Indexes().DropOne(ctx, op.IndexName)
174+
}
175+
176+
if err != nil {
177+
return nil, fmt.Errorf("dropIndexes failed: %w", err)
178+
}
179+
180+
response := bson.M{"ok": 1}
181+
jsonBytes, err := bson.MarshalExtJSONIndent(response, false, false, "", " ")
182+
if err != nil {
183+
return nil, fmt.Errorf("marshal failed: %w", err)
184+
}
185+
186+
return &Result{
187+
Rows: []string{string(jsonBytes)},
188+
RowCount: 1,
189+
}, nil
190+
}
191+
192+
// executeDrop executes a db.collection.drop() command.
193+
func executeDrop(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) {
194+
collection := client.Database(database).Collection(op.Collection)
195+
196+
err := collection.Drop(ctx)
197+
if err != nil {
198+
return nil, fmt.Errorf("drop failed: %w", err)
199+
}
200+
201+
return &Result{
202+
Rows: []string{"true"},
203+
RowCount: 1,
204+
}, nil
205+
}
206+
207+
// executeCreateCollection executes a db.createCollection() command.
208+
func executeCreateCollection(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) {
209+
db := client.Database(database)
210+
211+
// Build create collection options
212+
opts := options.CreateCollection()
213+
if op.Capped != nil && *op.Capped {
214+
opts.SetCapped(true)
215+
}
216+
if op.CollectionSize != nil {
217+
opts.SetSizeInBytes(*op.CollectionSize)
218+
}
219+
if op.CollectionMax != nil {
220+
opts.SetMaxDocuments(*op.CollectionMax)
221+
}
222+
if op.Validator != nil {
223+
opts.SetValidator(op.Validator)
224+
}
225+
if op.ValidationLevel != "" {
226+
opts.SetValidationLevel(op.ValidationLevel)
227+
}
228+
if op.ValidationAction != "" {
229+
opts.SetValidationAction(op.ValidationAction)
230+
}
231+
232+
err := db.CreateCollection(ctx, op.Collection, opts)
233+
if err != nil {
234+
return nil, fmt.Errorf("createCollection failed: %w", err)
235+
}
236+
237+
response := bson.M{"ok": 1}
238+
jsonBytes, err := bson.MarshalExtJSONIndent(response, false, false, "", " ")
239+
if err != nil {
240+
return nil, fmt.Errorf("marshal failed: %w", err)
241+
}
242+
243+
return &Result{
244+
Rows: []string{string(jsonBytes)},
245+
RowCount: 1,
246+
}, nil
247+
}
248+
249+
// executeDropDatabase executes a db.dropDatabase() command.
250+
func executeDropDatabase(ctx context.Context, client *mongo.Client, database string) (*Result, error) {
251+
err := client.Database(database).Drop(ctx)
252+
if err != nil {
253+
return nil, fmt.Errorf("dropDatabase failed: %w", err)
254+
}
255+
256+
response := bson.M{"ok": 1, "dropped": database}
257+
jsonBytes, err := bson.MarshalExtJSONIndent(response, false, false, "", " ")
258+
if err != nil {
259+
return nil, fmt.Errorf("marshal failed: %w", err)
260+
}
261+
262+
return &Result{
263+
Rows: []string{string(jsonBytes)},
264+
RowCount: 1,
265+
}, nil
266+
}
267+
268+
// executeRenameCollection executes a db.collection.renameCollection() command.
269+
func executeRenameCollection(ctx context.Context, client *mongo.Client, database string, op *translator.Operation) (*Result, error) {
270+
// MongoDB's renameCollection command needs to be run on admin database
271+
// The source is in the form "database.collection"
272+
command := bson.D{
273+
{Key: "renameCollection", Value: database + "." + op.Collection},
274+
{Key: "to", Value: database + "." + op.NewName},
275+
}
276+
if op.DropTarget != nil && *op.DropTarget {
277+
command = append(command, bson.E{Key: "dropTarget", Value: true})
278+
}
279+
280+
result := client.Database("admin").RunCommand(ctx, command)
281+
if err := result.Err(); err != nil {
282+
return nil, fmt.Errorf("renameCollection failed: %w", err)
283+
}
284+
285+
response := bson.M{"ok": 1}
286+
jsonBytes, err := bson.MarshalExtJSONIndent(response, false, false, "", " ")
287+
if err != nil {
288+
return nil, fmt.Errorf("marshal failed: %w", err)
289+
}
290+
291+
return &Result{
292+
Rows: []string{string(jsonBytes)},
293+
RowCount: 1,
294+
}, nil
295+
}

internal/executor/executor.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,21 @@ func Execute(ctx context.Context, client *mongo.Client, database string, op *tra
6060
return executeFindOneAndReplace(ctx, client, database, op)
6161
case translator.OpFindOneAndDelete:
6262
return executeFindOneAndDelete(ctx, client, database, op)
63+
// M3: Administrative Operations
64+
case translator.OpCreateIndex:
65+
return executeCreateIndex(ctx, client, database, op)
66+
case translator.OpDropIndex:
67+
return executeDropIndex(ctx, client, database, op)
68+
case translator.OpDropIndexes:
69+
return executeDropIndexes(ctx, client, database, op)
70+
case translator.OpDrop:
71+
return executeDrop(ctx, client, database, op)
72+
case translator.OpCreateCollection:
73+
return executeCreateCollection(ctx, client, database, op)
74+
case translator.OpDropDatabase:
75+
return executeDropDatabase(ctx, client, database)
76+
case translator.OpRenameCollection:
77+
return executeRenameCollection(ctx, client, database, op)
6378
default:
6479
return nil, fmt.Errorf("unsupported operation: %s", statement)
6580
}

internal/testutil/container.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,19 @@ func RunOnAllDBs(t *testing.T, testFn func(t *testing.T, db TestDB)) {
224224
})
225225
}
226226
}
227+
228+
// RunOnMongoDBOnly runs a test function only on MongoDB databases (not DocumentDB).
229+
// Use this for tests that require MongoDB-specific features like renameCollection.
230+
func RunOnMongoDBOnly(t *testing.T, testFn func(t *testing.T, db TestDB)) {
231+
t.Helper()
232+
dbs := GetAllClients(t)
233+
for _, db := range dbs {
234+
// Skip DocumentDB as some operations like renameCollection may not be supported
235+
if db.Name == "documentdb" {
236+
continue
237+
}
238+
t.Run(db.Name, func(t *testing.T) {
239+
testFn(t, db)
240+
})
241+
}
242+
}

0 commit comments

Comments
 (0)