Add cursor rules and initial configuration for Tender Management Go Backend
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
# MongoDB ORM for Go
|
||||
|
||||
A production-grade, high-performance generic ORM for MongoDB in Go, using the official MongoDB driver. This ORM provides a clean, type-safe interface for MongoDB operations with support for pagination, transactions, and advanced querying.
|
||||
|
||||
## Features
|
||||
|
||||
✅ **Generic Repository Pattern** - Type-safe operations for any struct
|
||||
✅ **Dual Pagination Support** - Both offset-based and cursor-based pagination
|
||||
✅ **Comprehensive CRUD Operations** - Create, Read, Update, Delete with bulk operations
|
||||
✅ **Advanced Querying** - Fluent query builder with complex conditions
|
||||
✅ **Aggregation Support** - Full MongoDB aggregation pipeline support
|
||||
✅ **Connection Management** - Connection pooling and lifecycle management
|
||||
✅ **Index Management** - Easy index creation and management
|
||||
✅ **Transaction Support** - ACID transactions for multi-document operations
|
||||
✅ **Structured Logging** - Comprehensive logging with structured fields
|
||||
✅ **Performance Optimized** - Efficient cursor-based pagination and connection pooling
|
||||
✅ **Type Safety** - Full Go generics support for compile-time type safety
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Define Your Model
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
Model
|
||||
Email string `bson:"email" json:"email"`
|
||||
FirstName string `bson:"first_name" json:"first_name"`
|
||||
LastName string `bson:"last_name" json:"last_name"`
|
||||
Age int `bson:"age" json:"age"`
|
||||
IsActive bool `bson:"is_active" json:"is_active"`
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Set Up Connection
|
||||
|
||||
```go
|
||||
// Create logger
|
||||
logger := &YourLogger{}
|
||||
|
||||
// Configure connection
|
||||
config := mongo.ConnectionConfig{
|
||||
URI: "mongodb://localhost:27017",
|
||||
Database: "your_database",
|
||||
}
|
||||
|
||||
// Create connection manager
|
||||
connManager, err := mongo.NewConnectionManager(config, logger)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer connManager.Close(context.Background())
|
||||
|
||||
// Create repository
|
||||
userRepo := mongo.NewRepository[User](connManager.GetCollection("users"), logger)
|
||||
```
|
||||
|
||||
### 3. Basic Operations
|
||||
|
||||
```go
|
||||
// Create
|
||||
user := &User{
|
||||
Email: "john@example.com",
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
Age: 30,
|
||||
IsActive: true,
|
||||
}
|
||||
err := userRepo.Create(context.Background(), user)
|
||||
|
||||
// Find by ID
|
||||
foundUser, err := userRepo.FindByID(context.Background(), user.ID)
|
||||
|
||||
// Update
|
||||
user.FirstName = "Jane"
|
||||
err = userRepo.Update(context.Background(), user)
|
||||
|
||||
// Delete
|
||||
err = userRepo.Delete(context.Background(), user.ID)
|
||||
```
|
||||
|
||||
## Pagination
|
||||
|
||||
### Offset-based Pagination
|
||||
|
||||
```go
|
||||
pagination := mongo.NewPaginationBuilder().
|
||||
Limit(10).
|
||||
Skip(20).
|
||||
SortDesc("created_at").
|
||||
Build()
|
||||
|
||||
results, err := userRepo.FindAll(context.Background(), bson.M{}, pagination)
|
||||
```
|
||||
|
||||
### Cursor-based Pagination (Recommended for Performance)
|
||||
|
||||
```go
|
||||
// First page
|
||||
pagination := mongo.NewPaginationBuilder().
|
||||
Limit(10).
|
||||
SortDesc("created_at").
|
||||
Build()
|
||||
|
||||
firstPage, err := userRepo.FindAll(context.Background(), bson.M{}, pagination)
|
||||
|
||||
// Next page using cursor
|
||||
if firstPage.NextCursor != "" {
|
||||
nextPagination := mongo.NewPaginationBuilder().
|
||||
Limit(10).
|
||||
SortDesc("created_at").
|
||||
Cursor(firstPage.NextCursor).
|
||||
Build()
|
||||
|
||||
nextPage, err := userRepo.FindAll(context.Background(), bson.M{}, nextPagination)
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Querying
|
||||
|
||||
### Using QueryBuilder
|
||||
|
||||
```go
|
||||
query := mongo.NewQueryBuilder().
|
||||
Where("is_active", true).
|
||||
WhereGreaterThan("age", 18).
|
||||
WhereIn("category", []interface{}{"admin", "user"}).
|
||||
WhereRegex("email", ".*@example\\.com", "i").
|
||||
Build()
|
||||
|
||||
results, err := userRepo.FindAll(context.Background(), query, pagination)
|
||||
```
|
||||
|
||||
### Complex Queries
|
||||
|
||||
```go
|
||||
// AND/OR conditions
|
||||
query := mongo.NewQueryBuilder().
|
||||
WhereAnd(
|
||||
bson.M{"is_active": true},
|
||||
bson.M{"age": bson.M{"$gte": 18}},
|
||||
).
|
||||
WhereOr(
|
||||
bson.M{"role": "admin"},
|
||||
bson.M{"role": "moderator"},
|
||||
).
|
||||
Build()
|
||||
```
|
||||
|
||||
## Aggregation
|
||||
|
||||
```go
|
||||
pipeline := mongo.Pipeline{
|
||||
{{Key: "$match", Value: bson.M{"is_active": true}}},
|
||||
{{Key: "$group", Value: bson.M{
|
||||
"_id": "$role",
|
||||
"count": bson.M{"$sum": 1},
|
||||
"avgAge": bson.M{"$avg": "$age"},
|
||||
}}},
|
||||
{{Key: "$sort", Value: bson.M{"count": -1}}},
|
||||
}
|
||||
|
||||
results, err := userRepo.Aggregate(context.Background(), pipeline)
|
||||
```
|
||||
|
||||
## Bulk Operations
|
||||
|
||||
```go
|
||||
// Bulk create
|
||||
users := []User{
|
||||
{Email: "user1@example.com", FirstName: "User1"},
|
||||
{Email: "user2@example.com", FirstName: "User2"},
|
||||
}
|
||||
err := userRepo.BulkCreate(context.Background(), users)
|
||||
|
||||
// Bulk update
|
||||
filter := bson.M{"is_active": false}
|
||||
update := bson.M{"$set": bson.M{"status": "inactive"}}
|
||||
err = userRepo.BulkUpdate(context.Background(), filter, update)
|
||||
|
||||
// Bulk delete
|
||||
filter := bson.M{"created_at": bson.M{"$lt": time.Now().AddDate(0, -1, 0).Unix()}}
|
||||
err = userRepo.BulkDelete(context.Background(), filter)
|
||||
```
|
||||
|
||||
## Index Management
|
||||
|
||||
### Create Indexes
|
||||
|
||||
```go
|
||||
// Create common indexes
|
||||
indexes := mongo.UserIndexes()
|
||||
err := connManager.CreateIndexes("users", indexes)
|
||||
|
||||
// Create custom indexes
|
||||
customIndexes := []mongo.Index{
|
||||
*mongo.CreateUniqueIndex("email_idx", bson.D{{Key: "email", Value: 1}}),
|
||||
*mongo.CreateTTLIndex("expires_at_idx", "expires_at", 3600),
|
||||
*mongo.CreateTextIndex("search_idx", "title", "description"),
|
||||
}
|
||||
|
||||
err = connManager.CreateIndexes("documents", customIndexes)
|
||||
```
|
||||
|
||||
### Index Types
|
||||
|
||||
```go
|
||||
// Unique index
|
||||
uniqueIdx := mongo.CreateUniqueIndex("email_idx", bson.D{{Key: "email", Value: 1}})
|
||||
|
||||
// TTL index
|
||||
ttlIdx := mongo.CreateTTLIndex("expires_at_idx", "expires_at", 3600)
|
||||
|
||||
// Text index
|
||||
textIdx := mongo.CreateTextIndex("search_idx", "title", "description")
|
||||
|
||||
// Compound index
|
||||
compoundIdx := mongo.CreateCompoundIndex("user_status_idx", map[string]int{
|
||||
"user_id": 1,
|
||||
"status": 1,
|
||||
})
|
||||
|
||||
// Geospatial index
|
||||
geoIdx := mongo.CreateGeospatialIndex("location_idx", "location")
|
||||
|
||||
// Hashed index
|
||||
hashIdx := mongo.CreateHashedIndex("user_id_hash_idx", "user_id")
|
||||
```
|
||||
|
||||
## Transactions
|
||||
|
||||
```go
|
||||
// Start session
|
||||
session, err := connManager.client.StartSession()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer session.EndSession(context.Background())
|
||||
|
||||
// Execute transaction
|
||||
_, err = session.WithTransaction(context.Background(), func(sessCtx mongo.SessionContext) (interface{}, error) {
|
||||
// Create user
|
||||
user := &User{Email: "transaction@example.com"}
|
||||
if err := userRepo.Create(sessCtx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create related document
|
||||
profile := &Profile{UserID: user.ID}
|
||||
if err := profileRepo.Create(sessCtx, profile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
})
|
||||
```
|
||||
|
||||
## Connection Configuration
|
||||
|
||||
```go
|
||||
config := mongo.ConnectionConfig{
|
||||
URI: "mongodb://localhost:27017",
|
||||
Database: "your_database",
|
||||
MaxPoolSize: 100,
|
||||
MinPoolSize: 5,
|
||||
MaxConnIdleTime: 30 * time.Minute,
|
||||
ConnectTimeout: 10 * time.Second,
|
||||
ServerSelectionTimeout: 5 * time.Second,
|
||||
}
|
||||
```
|
||||
|
||||
## Logger Interface
|
||||
|
||||
Implement the Logger interface for structured logging:
|
||||
|
||||
```go
|
||||
type YourLogger struct{}
|
||||
|
||||
func (l *YourLogger) Info(message string, fields map[string]interface{}) {
|
||||
log.Printf("[INFO] %s: %+v", message, fields)
|
||||
}
|
||||
|
||||
func (l *YourLogger) Error(message string, fields map[string]interface{}) {
|
||||
log.Printf("[ERROR] %s: %+v", message, fields)
|
||||
}
|
||||
|
||||
func (l *YourLogger) Debug(message string, fields map[string]interface{}) {
|
||||
log.Printf("[DEBUG] %s: %+v", message, fields)
|
||||
}
|
||||
|
||||
func (l *YourLogger) Warn(message string, fields map[string]interface{}) {
|
||||
log.Printf("[WARN] %s: %+v", message, fields)
|
||||
}
|
||||
```
|
||||
|
||||
## Model Interfaces
|
||||
|
||||
### Timestampable Interface
|
||||
|
||||
Implement for automatic timestamp management:
|
||||
|
||||
```go
|
||||
type YourModel struct {
|
||||
Model
|
||||
// Your fields...
|
||||
}
|
||||
|
||||
// Model already implements Timestampable
|
||||
// Timestamps are automatically set on Create/Update operations
|
||||
```
|
||||
|
||||
### Custom ID Management
|
||||
|
||||
```go
|
||||
type CustomModel struct {
|
||||
Model
|
||||
// Your fields...
|
||||
}
|
||||
|
||||
// Model already implements IDGetter and IDSetter
|
||||
// ID is automatically set after Create operations
|
||||
```
|
||||
|
||||
## Performance Best Practices
|
||||
|
||||
### 1. Use Cursor-based Pagination
|
||||
|
||||
Cursor-based pagination is more efficient than offset-based pagination for large datasets:
|
||||
|
||||
```go
|
||||
// Good - Cursor-based
|
||||
pagination := mongo.NewPaginationBuilder().
|
||||
Limit(10).
|
||||
SortDesc("created_at").
|
||||
Build()
|
||||
|
||||
// Avoid - Offset-based for large datasets
|
||||
pagination := mongo.NewPaginationBuilder().
|
||||
Limit(10).
|
||||
Skip(10000). // Expensive for large datasets
|
||||
Build()
|
||||
```
|
||||
|
||||
### 2. Create Appropriate Indexes
|
||||
|
||||
```go
|
||||
// Create indexes for frequently queried fields
|
||||
indexes := []mongo.Index{
|
||||
*mongo.CreateUniqueIndex("email_idx", bson.D{{Key: "email", Value: 1}}),
|
||||
*mongo.NewIndex("status_created_idx", bson.D{
|
||||
{Key: "status", Value: 1},
|
||||
{Key: "created_at", Value: -1},
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Use Projection for Large Documents
|
||||
|
||||
```go
|
||||
// Use FindMany with limit for large result sets
|
||||
users, err := userRepo.FindMany(context.Background(), filter, 1000)
|
||||
```
|
||||
|
||||
### 4. Connection Pooling
|
||||
|
||||
Configure appropriate connection pool settings:
|
||||
|
||||
```go
|
||||
config := mongo.ConnectionConfig{
|
||||
MaxPoolSize: 100,
|
||||
MinPoolSize: 5,
|
||||
MaxConnIdleTime: 30 * time.Minute,
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The ORM provides specific error types:
|
||||
|
||||
```go
|
||||
var (
|
||||
ErrDocumentNotFound = errors.New("document not found")
|
||||
ErrInvalidCursor = errors.New("invalid cursor")
|
||||
ErrInvalidPagination = errors.New("invalid pagination parameters")
|
||||
)
|
||||
|
||||
// Handle specific errors
|
||||
user, err := userRepo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, mongo.ErrDocumentNotFound) {
|
||||
// Handle not found
|
||||
return
|
||||
}
|
||||
// Handle other errors
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Testing with Mocks
|
||||
|
||||
```go
|
||||
// Create mock repository for testing
|
||||
type MockUserRepository struct {
|
||||
users map[string]*User
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) FindByID(ctx context.Context, id string) (*User, error) {
|
||||
if user, exists := m.users[id]; exists {
|
||||
return user, nil
|
||||
}
|
||||
return nil, mongo.ErrDocumentNotFound
|
||||
}
|
||||
|
||||
// Use in tests
|
||||
mockRepo := &MockUserRepository{users: make(map[string]*User)}
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
|
||||
```go
|
||||
func TestUserRepository(t *testing.T) {
|
||||
// Set up test database
|
||||
config := mongo.ConnectionConfig{
|
||||
URI: "mongodb://localhost:27017",
|
||||
Database: "test_database",
|
||||
}
|
||||
|
||||
connManager, err := mongo.NewConnectionManager(config, logger)
|
||||
require.NoError(t, err)
|
||||
defer connManager.Close(context.Background())
|
||||
|
||||
userRepo := mongo.NewRepository[User](connManager.GetCollection("users"), logger)
|
||||
|
||||
// Run tests...
|
||||
}
|
||||
```
|
||||
|
||||
## Migration from Existing Code
|
||||
|
||||
### From Manual MongoDB Operations
|
||||
|
||||
```go
|
||||
// Before
|
||||
collection := client.Database("db").Collection("users")
|
||||
filter := bson.M{"email": email}
|
||||
var user User
|
||||
err := collection.FindOne(ctx, filter).Decode(&user)
|
||||
|
||||
// After
|
||||
userRepo := mongo.NewRepository[User](collection, logger)
|
||||
user, err := userRepo.FindOne(ctx, bson.M{"email": email})
|
||||
```
|
||||
|
||||
### From Other ORMs
|
||||
|
||||
The generic repository pattern makes migration straightforward:
|
||||
|
||||
```go
|
||||
// Replace existing repository interface
|
||||
type UserRepository interface {
|
||||
FindByID(ctx context.Context, id string) (*User, error)
|
||||
Create(ctx context.Context, user *User) error
|
||||
// ... other methods
|
||||
}
|
||||
|
||||
// Implementation remains the same, just use mongo.NewRepository
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Add tests for new functionality
|
||||
4. Ensure all tests pass
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
@@ -0,0 +1,264 @@
|
||||
package mongo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
"go.mongodb.org/mongo-driver/mongo/readpref"
|
||||
)
|
||||
|
||||
// ConnectionConfig holds MongoDB connection configuration
|
||||
type ConnectionConfig struct {
|
||||
URI string `json:"uri"`
|
||||
Database string `json:"database"`
|
||||
MaxPoolSize uint64 `json:"maxPoolSize"`
|
||||
MinPoolSize uint64 `json:"minPoolSize"`
|
||||
MaxConnIdleTime time.Duration `json:"maxConnIdleTime"`
|
||||
ConnectTimeout time.Duration `json:"connectTimeout"`
|
||||
ServerSelectionTimeout time.Duration `json:"serverSelectionTimeout"`
|
||||
}
|
||||
|
||||
// ConnectionManager manages MongoDB connections and collections
|
||||
type ConnectionManager struct {
|
||||
client *mongo.Client
|
||||
database *mongo.Database
|
||||
logger Logger
|
||||
config ConnectionConfig
|
||||
}
|
||||
|
||||
// NewConnectionManager creates a new MongoDB connection manager
|
||||
func NewConnectionManager(config ConnectionConfig, logger Logger) (*ConnectionManager, error) {
|
||||
// Set default values
|
||||
if config.MaxPoolSize == 0 {
|
||||
config.MaxPoolSize = 100
|
||||
}
|
||||
if config.MinPoolSize == 0 {
|
||||
config.MinPoolSize = 5
|
||||
}
|
||||
if config.MaxConnIdleTime == 0 {
|
||||
config.MaxConnIdleTime = 30 * time.Minute
|
||||
}
|
||||
if config.ConnectTimeout == 0 {
|
||||
config.ConnectTimeout = 10 * time.Second
|
||||
}
|
||||
if config.ServerSelectionTimeout == 0 {
|
||||
config.ServerSelectionTimeout = 5 * time.Second
|
||||
}
|
||||
|
||||
// Create client options
|
||||
clientOptions := options.Client().
|
||||
ApplyURI(config.URI).
|
||||
SetMaxPoolSize(config.MaxPoolSize).
|
||||
SetMinPoolSize(config.MinPoolSize).
|
||||
SetMaxConnIdleTime(config.MaxConnIdleTime).
|
||||
SetConnectTimeout(config.ConnectTimeout).
|
||||
SetServerSelectionTimeout(config.ServerSelectionTimeout)
|
||||
|
||||
// Connect to MongoDB
|
||||
ctx, cancel := context.WithTimeout(context.Background(), config.ConnectTimeout)
|
||||
defer cancel()
|
||||
|
||||
client, err := mongo.Connect(ctx, clientOptions)
|
||||
if err != nil {
|
||||
logger.Error("Failed to connect to MongoDB", map[string]interface{}{
|
||||
"uri": config.URI,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to connect to MongoDB: %w", err)
|
||||
}
|
||||
|
||||
// Ping the database
|
||||
if err = client.Ping(ctx, readpref.Primary()); err != nil {
|
||||
logger.Error("Failed to ping MongoDB", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to ping MongoDB: %w", err)
|
||||
}
|
||||
|
||||
database := client.Database(config.Database)
|
||||
|
||||
logger.Info("Successfully connected to MongoDB", map[string]interface{}{
|
||||
"database": config.Database,
|
||||
"uri": config.URI,
|
||||
})
|
||||
|
||||
return &ConnectionManager{
|
||||
client: client,
|
||||
database: database,
|
||||
logger: logger,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCollection returns a MongoDB collection
|
||||
func (cm *ConnectionManager) GetCollection(name string) *mongo.Collection {
|
||||
return cm.database.Collection(name)
|
||||
}
|
||||
|
||||
// CreateRepository creates a new repository for a given collection
|
||||
func (cm *ConnectionManager) CreateRepository(collectionName string) *mongo.Collection {
|
||||
return cm.GetCollection(collectionName)
|
||||
}
|
||||
|
||||
// CreateIndexes creates indexes for a collection
|
||||
func (cm *ConnectionManager) CreateIndexes(collectionName string, indexes []Index) error {
|
||||
collection := cm.GetCollection(collectionName)
|
||||
|
||||
for _, index := range indexes {
|
||||
indexModel := mongo.IndexModel{
|
||||
Keys: index.Keys,
|
||||
Options: index.Options,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := collection.Indexes().CreateOne(ctx, indexModel)
|
||||
if err != nil {
|
||||
cm.logger.Error("Failed to create index", map[string]interface{}{
|
||||
"collection": collectionName,
|
||||
"index": index.Name,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return fmt.Errorf("failed to create index %s: %w", index.Name, err)
|
||||
}
|
||||
|
||||
cm.logger.Info("Index created successfully", map[string]interface{}{
|
||||
"collection": collectionName,
|
||||
"index": index.Name,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DropIndexes drops indexes from a collection
|
||||
func (cm *ConnectionManager) DropIndexes(collectionName string, indexNames []string) error {
|
||||
collection := cm.GetCollection(collectionName)
|
||||
|
||||
for _, indexName := range indexNames {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := collection.Indexes().DropOne(ctx, indexName)
|
||||
if err != nil {
|
||||
cm.logger.Error("Failed to drop index", map[string]interface{}{
|
||||
"collection": collectionName,
|
||||
"index": indexName,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return fmt.Errorf("failed to drop index %s: %w", indexName, err)
|
||||
}
|
||||
|
||||
cm.logger.Info("Index dropped successfully", map[string]interface{}{
|
||||
"collection": collectionName,
|
||||
"index": indexName,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListIndexes lists all indexes for a collection
|
||||
func (cm *ConnectionManager) ListIndexes(collectionName string) ([]bson.M, error) {
|
||||
collection := cm.GetCollection(collectionName)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cursor, err := collection.Indexes().List(ctx)
|
||||
if err != nil {
|
||||
cm.logger.Error("Failed to list indexes", map[string]interface{}{
|
||||
"collection": collectionName,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to list indexes: %w", err)
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var indexes []bson.M
|
||||
if err = cursor.All(ctx, &indexes); err != nil {
|
||||
cm.logger.Error("Failed to decode indexes", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to decode indexes: %w", err)
|
||||
}
|
||||
|
||||
return indexes, nil
|
||||
}
|
||||
|
||||
// Close closes the MongoDB connection
|
||||
func (cm *ConnectionManager) Close(ctx context.Context) error {
|
||||
if err := cm.client.Disconnect(ctx); err != nil {
|
||||
cm.logger.Error("Failed to disconnect from MongoDB", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return fmt.Errorf("failed to disconnect from MongoDB: %w", err)
|
||||
}
|
||||
|
||||
cm.logger.Info("Successfully disconnected from MongoDB", nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ping pings the MongoDB server
|
||||
func (cm *ConnectionManager) Ping(ctx context.Context) error {
|
||||
if err := cm.client.Ping(ctx, readpref.Primary()); err != nil {
|
||||
cm.logger.Error("Failed to ping MongoDB", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return fmt.Errorf("failed to ping MongoDB: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStats returns database statistics
|
||||
func (cm *ConnectionManager) GetStats(ctx context.Context) (bson.M, error) {
|
||||
stats := cm.database.RunCommand(ctx, bson.M{"dbStats": 1})
|
||||
if stats.Err() != nil {
|
||||
cm.logger.Error("Failed to get database stats", map[string]interface{}{
|
||||
"error": stats.Err().Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to get database stats: %w", stats.Err())
|
||||
}
|
||||
|
||||
var result bson.M
|
||||
if err := stats.Decode(&result); err != nil {
|
||||
cm.logger.Error("Failed to decode database stats", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to decode database stats: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetCollectionStats returns collection statistics
|
||||
func (cm *ConnectionManager) GetCollectionStats(ctx context.Context, collectionName string) (bson.M, error) {
|
||||
collection := cm.GetCollection(collectionName)
|
||||
|
||||
stats := collection.Database().RunCommand(ctx, bson.M{
|
||||
"collStats": collectionName,
|
||||
})
|
||||
if stats.Err() != nil {
|
||||
cm.logger.Error("Failed to get collection stats", map[string]interface{}{
|
||||
"collection": collectionName,
|
||||
"error": stats.Err().Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to get collection stats: %w", stats.Err())
|
||||
}
|
||||
|
||||
var result bson.M
|
||||
if err := stats.Decode(&result); err != nil {
|
||||
cm.logger.Error("Failed to decode collection stats", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to decode collection stats: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package mongo
|
||||
|
||||
// Logger defines the interface for logging operations
|
||||
type Logger interface {
|
||||
Info(message string, fields map[string]interface{})
|
||||
Error(message string, fields map[string]interface{})
|
||||
Debug(message string, fields map[string]interface{})
|
||||
Warn(message string, fields map[string]interface{})
|
||||
}
|
||||
|
||||
// Timestampable interface for models that support timestamps
|
||||
type Timestampable interface {
|
||||
SetCreatedAt(timestamp int64)
|
||||
SetUpdatedAt(timestamp int64)
|
||||
GetCreatedAt() int64
|
||||
GetUpdatedAt() int64
|
||||
}
|
||||
|
||||
// IDGetter interface for models that can provide their ID
|
||||
type IDGetter interface {
|
||||
GetID() string
|
||||
}
|
||||
|
||||
// IDSetter interface for models that can set their ID
|
||||
type IDSetter interface {
|
||||
SetID(id string)
|
||||
}
|
||||
|
||||
// Model provides a common base for MongoDB documents
|
||||
type Model struct {
|
||||
ID string `bson:"_id,omitempty" json:"id,omitempty"`
|
||||
CreatedAt int64 `bson:"created_at" json:"created_at"`
|
||||
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// GetID returns the document ID
|
||||
func (b *Model) GetID() string {
|
||||
return b.ID
|
||||
}
|
||||
|
||||
// SetID sets the document ID
|
||||
func (b *Model) SetID(id string) {
|
||||
b.ID = id
|
||||
}
|
||||
|
||||
// SetCreatedAt sets the creation timestamp
|
||||
func (b *Model) SetCreatedAt(timestamp int64) {
|
||||
b.CreatedAt = timestamp
|
||||
}
|
||||
|
||||
// SetUpdatedAt sets the update timestamp
|
||||
func (b *Model) SetUpdatedAt(timestamp int64) {
|
||||
b.UpdatedAt = timestamp
|
||||
}
|
||||
|
||||
// GetCreatedAt returns the creation timestamp
|
||||
func (b *Model) GetCreatedAt() int64 {
|
||||
return b.CreatedAt
|
||||
}
|
||||
|
||||
// GetUpdatedAt returns the update timestamp
|
||||
func (b *Model) GetUpdatedAt() int64 {
|
||||
return b.UpdatedAt
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
package mongo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
// Common errors
|
||||
var (
|
||||
ErrDocumentNotFound = errors.New("document not found")
|
||||
ErrInvalidCursor = errors.New("invalid cursor")
|
||||
ErrInvalidPagination = errors.New("invalid pagination parameters")
|
||||
)
|
||||
|
||||
// Pagination represents pagination parameters for queries
|
||||
type Pagination struct {
|
||||
Limit int `json:"limit"` // Number of documents to return
|
||||
Skip int `json:"skip"` // Number of documents to skip (for offset-based)
|
||||
Cursor string `json:"cursor"` // Base64 encoded cursor for cursor-based pagination
|
||||
SortField string `json:"sortField"` // Field to sort by (default: "_id")
|
||||
SortOrder int `json:"sortOrder"` // Sort order: 1 for ascending, -1 for descending
|
||||
}
|
||||
|
||||
// PaginatedResult represents a paginated result set
|
||||
type PaginatedResult[T any] struct {
|
||||
Items []T `json:"items"` // The actual documents
|
||||
TotalCount int64 `json:"totalCount"` // Total number of documents matching the filter
|
||||
NextCursor string `json:"nextCursor"` // Base64 encoded cursor for next page
|
||||
HasMore bool `json:"hasMore"` // Whether there are more documents
|
||||
}
|
||||
|
||||
// Repository defines the interface for MongoDB operations
|
||||
type Repository[T any] interface {
|
||||
// FindByID retrieves a document by its ID
|
||||
FindByID(ctx context.Context, id string) (*T, error)
|
||||
|
||||
// FindAll retrieves documents with optional filtering and pagination
|
||||
FindAll(ctx context.Context, filter bson.M, pagination Pagination) (*PaginatedResult[T], error)
|
||||
|
||||
// Create inserts a new document
|
||||
Create(ctx context.Context, model *T) error
|
||||
|
||||
// Update updates an existing document
|
||||
Update(ctx context.Context, model *T) error
|
||||
|
||||
// Delete removes a document by ID
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// Count returns the number of documents matching the filter
|
||||
Count(ctx context.Context, filter bson.M) (int64, error)
|
||||
|
||||
// Aggregate executes an aggregation pipeline
|
||||
Aggregate(ctx context.Context, pipeline mongo.Pipeline) ([]bson.M, error)
|
||||
|
||||
// FindOne retrieves a single document matching the filter
|
||||
FindOne(ctx context.Context, filter bson.M) (*T, error)
|
||||
|
||||
// FindMany retrieves multiple documents without pagination
|
||||
FindMany(ctx context.Context, filter bson.M, limit int) ([]T, error)
|
||||
|
||||
// BulkCreate inserts multiple documents
|
||||
BulkCreate(ctx context.Context, models []T) error
|
||||
|
||||
// BulkUpdate updates multiple documents
|
||||
BulkUpdate(ctx context.Context, filter bson.M, update bson.M) error
|
||||
|
||||
// BulkDelete removes multiple documents
|
||||
BulkDelete(ctx context.Context, filter bson.M) error
|
||||
}
|
||||
|
||||
// repository implements the Repository interface
|
||||
type repository[T any] struct {
|
||||
collection *mongo.Collection
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewRepository creates a new repository instance
|
||||
func NewRepository[T any](collection *mongo.Collection, logger Logger) Repository[T] {
|
||||
return &repository[T]{
|
||||
collection: collection,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// FindByID retrieves a document by its ID
|
||||
func (r *repository[T]) FindByID(ctx context.Context, id string) (*T, error) {
|
||||
objectID, err := primitive.ObjectIDFromHex(id)
|
||||
if err != nil {
|
||||
r.logger.Error("Invalid ObjectID format", map[string]interface{}{
|
||||
"id": id,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("invalid id format: %w", err)
|
||||
}
|
||||
|
||||
filter := bson.M{"_id": objectID}
|
||||
var result T
|
||||
|
||||
err = r.collection.FindOne(ctx, filter).Decode(&result)
|
||||
if err != nil {
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrDocumentNotFound
|
||||
}
|
||||
r.logger.Error("Failed to find document by ID", map[string]interface{}{
|
||||
"id": id,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to find document: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// FindAll retrieves documents with optional filtering and pagination
|
||||
func (r *repository[T]) FindAll(ctx context.Context, filter bson.M, pagination Pagination) (*PaginatedResult[T], error) {
|
||||
// Validate pagination parameters
|
||||
if err := r.validatePagination(pagination); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set default values
|
||||
if pagination.Limit <= 0 {
|
||||
pagination.Limit = 10
|
||||
}
|
||||
if pagination.SortField == "" {
|
||||
pagination.SortField = "_id"
|
||||
}
|
||||
if pagination.SortOrder == 0 {
|
||||
pagination.SortOrder = -1 // Default to descending
|
||||
}
|
||||
|
||||
// Build sort options
|
||||
sort := bson.D{{Key: pagination.SortField, Value: pagination.SortOrder}}
|
||||
|
||||
// Build find options
|
||||
findOptions := options.Find().
|
||||
SetSort(sort).
|
||||
SetLimit(int64(pagination.Limit + 1)) // +1 to check if there are more documents
|
||||
|
||||
// Handle cursor-based pagination
|
||||
if pagination.Cursor != "" {
|
||||
cursorFilter, err := r.buildCursorFilter(pagination.Cursor, pagination.SortField, pagination.SortOrder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Merge cursor filter with existing filter
|
||||
for key, value := range cursorFilter {
|
||||
filter[key] = value
|
||||
}
|
||||
} else if pagination.Skip > 0 {
|
||||
// Use offset-based pagination
|
||||
findOptions.SetSkip(int64(pagination.Skip))
|
||||
}
|
||||
|
||||
// Execute query
|
||||
cursor, err := r.collection.Find(ctx, filter, findOptions)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to execute find query", map[string]interface{}{
|
||||
"filter": filter,
|
||||
"pagination": pagination,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to execute query: %w", err)
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
// Decode results
|
||||
var items []T
|
||||
if err = cursor.All(ctx, &items); err != nil {
|
||||
r.logger.Error("Failed to decode query results", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to decode results: %w", err)
|
||||
}
|
||||
|
||||
// Check if there are more documents
|
||||
hasMore := false
|
||||
if len(items) > pagination.Limit {
|
||||
hasMore = true
|
||||
items = items[:pagination.Limit] // Remove the extra item
|
||||
}
|
||||
|
||||
// Get total count
|
||||
totalCount, err := r.Count(ctx, filter)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to get total count", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
// Don't fail the entire query, just set count to 0
|
||||
totalCount = 0
|
||||
}
|
||||
|
||||
// Generate next cursor
|
||||
var nextCursor string
|
||||
if hasMore && len(items) > 0 {
|
||||
nextCursor, err = r.generateCursor(items[len(items)-1], pagination.SortField)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to generate next cursor", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
// Don't fail the entire query, just leave cursor empty
|
||||
}
|
||||
}
|
||||
|
||||
return &PaginatedResult[T]{
|
||||
Items: items,
|
||||
TotalCount: totalCount,
|
||||
NextCursor: nextCursor,
|
||||
HasMore: hasMore,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Create inserts a new document
|
||||
func (r *repository[T]) Create(ctx context.Context, model *T) error {
|
||||
// Set timestamps if the model implements Timestampable
|
||||
if timestampable, ok := any(model).(Timestampable); ok {
|
||||
now := time.Now().Unix()
|
||||
timestampable.SetCreatedAt(now)
|
||||
timestampable.SetUpdatedAt(now)
|
||||
}
|
||||
|
||||
result, err := r.collection.InsertOne(ctx, model)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to create document", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return fmt.Errorf("failed to create document: %w", err)
|
||||
}
|
||||
|
||||
// Set the ID if the model implements IDSetter
|
||||
if idSetter, ok := any(model).(IDSetter); ok {
|
||||
if oid, ok := result.InsertedID.(primitive.ObjectID); ok {
|
||||
idSetter.SetID(oid.Hex())
|
||||
}
|
||||
}
|
||||
|
||||
r.logger.Info("Document created successfully", map[string]interface{}{
|
||||
"id": result.InsertedID,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates an existing document
|
||||
func (r *repository[T]) Update(ctx context.Context, model *T) error {
|
||||
// Set updated timestamp if the model implements Timestampable
|
||||
if timestampable, ok := any(model).(Timestampable); ok {
|
||||
timestampable.SetUpdatedAt(time.Now().Unix())
|
||||
}
|
||||
|
||||
// Get the ID from the model
|
||||
id, err := r.getModelID(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
objectID, err := primitive.ObjectIDFromHex(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid id format: %w", err)
|
||||
}
|
||||
|
||||
filter := bson.M{"_id": objectID}
|
||||
update := bson.M{"$set": model}
|
||||
|
||||
result, err := r.collection.UpdateOne(ctx, filter, update)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to update document", map[string]interface{}{
|
||||
"id": id,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return fmt.Errorf("failed to update document: %w", err)
|
||||
}
|
||||
|
||||
if result.MatchedCount == 0 {
|
||||
return ErrDocumentNotFound
|
||||
}
|
||||
|
||||
r.logger.Info("Document updated successfully", map[string]interface{}{
|
||||
"id": id,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a document by ID
|
||||
func (r *repository[T]) Delete(ctx context.Context, id string) error {
|
||||
objectID, err := primitive.ObjectIDFromHex(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid id format: %w", err)
|
||||
}
|
||||
|
||||
filter := bson.M{"_id": objectID}
|
||||
result, err := r.collection.DeleteOne(ctx, filter)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to delete document", map[string]interface{}{
|
||||
"id": id,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return fmt.Errorf("failed to delete document: %w", err)
|
||||
}
|
||||
|
||||
if result.DeletedCount == 0 {
|
||||
return ErrDocumentNotFound
|
||||
}
|
||||
|
||||
r.logger.Info("Document deleted successfully", map[string]interface{}{
|
||||
"id": id,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count returns the number of documents matching the filter
|
||||
func (r *repository[T]) Count(ctx context.Context, filter bson.M) (int64, error) {
|
||||
count, err := r.collection.CountDocuments(ctx, filter)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to count documents", map[string]interface{}{
|
||||
"filter": filter,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return 0, fmt.Errorf("failed to count documents: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Aggregate executes an aggregation pipeline
|
||||
func (r *repository[T]) Aggregate(ctx context.Context, pipeline mongo.Pipeline) ([]bson.M, error) {
|
||||
cursor, err := r.collection.Aggregate(ctx, pipeline)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to execute aggregation", map[string]interface{}{
|
||||
"pipeline": pipeline,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to execute aggregation: %w", err)
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var results []bson.M
|
||||
if err = cursor.All(ctx, &results); err != nil {
|
||||
r.logger.Error("Failed to decode aggregation results", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to decode aggregation results: %w", err)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// FindOne retrieves a single document matching the filter
|
||||
func (r *repository[T]) FindOne(ctx context.Context, filter bson.M) (*T, error) {
|
||||
var result T
|
||||
|
||||
err := r.collection.FindOne(ctx, filter).Decode(&result)
|
||||
if err != nil {
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrDocumentNotFound
|
||||
}
|
||||
r.logger.Error("Failed to find document", map[string]interface{}{
|
||||
"filter": filter,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to find document: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// FindMany retrieves multiple documents without pagination
|
||||
func (r *repository[T]) FindMany(ctx context.Context, filter bson.M, limit int) ([]T, error) {
|
||||
findOptions := options.Find()
|
||||
if limit > 0 {
|
||||
findOptions.SetLimit(int64(limit))
|
||||
}
|
||||
|
||||
cursor, err := r.collection.Find(ctx, filter, findOptions)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to execute find query", map[string]interface{}{
|
||||
"filter": filter,
|
||||
"limit": limit,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to execute query: %w", err)
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var results []T
|
||||
if err = cursor.All(ctx, &results); err != nil {
|
||||
r.logger.Error("Failed to decode query results", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("failed to decode results: %w", err)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// BulkCreate inserts multiple documents
|
||||
func (r *repository[T]) BulkCreate(ctx context.Context, models []T) error {
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set timestamps for all models
|
||||
now := time.Now().Unix()
|
||||
for i := range models {
|
||||
if timestampable, ok := any(&models[i]).(Timestampable); ok {
|
||||
timestampable.SetCreatedAt(now)
|
||||
timestampable.SetUpdatedAt(now)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to interface slice for bulk insert
|
||||
documents := make([]interface{}, len(models))
|
||||
for i, model := range models {
|
||||
documents[i] = model
|
||||
}
|
||||
|
||||
result, err := r.collection.InsertMany(ctx, documents)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to bulk create documents", map[string]interface{}{
|
||||
"count": len(models),
|
||||
"error": err.Error(),
|
||||
})
|
||||
return fmt.Errorf("failed to bulk create documents: %w", err)
|
||||
}
|
||||
|
||||
r.logger.Info("Documents bulk created successfully", map[string]interface{}{
|
||||
"count": len(result.InsertedIDs),
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BulkUpdate updates multiple documents
|
||||
func (r *repository[T]) BulkUpdate(ctx context.Context, filter bson.M, update bson.M) error {
|
||||
result, err := r.collection.UpdateMany(ctx, filter, update)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to bulk update documents", map[string]interface{}{
|
||||
"filter": filter,
|
||||
"update": update,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return fmt.Errorf("failed to bulk update documents: %w", err)
|
||||
}
|
||||
|
||||
r.logger.Info("Documents bulk updated successfully", map[string]interface{}{
|
||||
"matched": result.MatchedCount,
|
||||
"modified": result.ModifiedCount,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BulkDelete removes multiple documents
|
||||
func (r *repository[T]) BulkDelete(ctx context.Context, filter bson.M) error {
|
||||
result, err := r.collection.DeleteMany(ctx, filter)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to bulk delete documents", map[string]interface{}{
|
||||
"filter": filter,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return fmt.Errorf("failed to bulk delete documents: %w", err)
|
||||
}
|
||||
|
||||
r.logger.Info("Documents bulk deleted successfully", map[string]interface{}{
|
||||
"deleted": result.DeletedCount,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
// validatePagination validates pagination parameters
|
||||
func (r *repository[T]) validatePagination(pagination Pagination) error {
|
||||
if pagination.Limit < 0 {
|
||||
return fmt.Errorf("%w: limit cannot be negative", ErrInvalidPagination)
|
||||
}
|
||||
if pagination.Skip < 0 {
|
||||
return fmt.Errorf("%w: skip cannot be negative", ErrInvalidPagination)
|
||||
}
|
||||
if pagination.SortOrder != 0 && pagination.SortOrder != 1 && pagination.SortOrder != -1 {
|
||||
return fmt.Errorf("%w: sort order must be 1, -1, or 0", ErrInvalidPagination)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildCursorFilter builds a filter for cursor-based pagination
|
||||
func (r *repository[T]) buildCursorFilter(cursor, sortField string, sortOrder int) (bson.M, error) {
|
||||
// Decode cursor
|
||||
cursorBytes, err := base64.StdEncoding.DecodeString(cursor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid cursor encoding", ErrInvalidCursor)
|
||||
}
|
||||
|
||||
var cursorValue interface{}
|
||||
|
||||
// Try to parse as ObjectID first
|
||||
if sortField == "_id" {
|
||||
if objectID, err := primitive.ObjectIDFromHex(string(cursorBytes)); err == nil {
|
||||
cursorValue = objectID
|
||||
} else {
|
||||
return nil, fmt.Errorf("%w: invalid ObjectID in cursor", ErrInvalidCursor)
|
||||
}
|
||||
} else {
|
||||
// For other fields, try to parse as appropriate type
|
||||
// This is a simplified implementation - you might want to make this more sophisticated
|
||||
cursorValue = string(cursorBytes)
|
||||
}
|
||||
|
||||
// Build comparison operator based on sort order
|
||||
var operator string
|
||||
if sortOrder == 1 {
|
||||
operator = "$gt"
|
||||
} else {
|
||||
operator = "$lt"
|
||||
}
|
||||
|
||||
return bson.M{sortField: bson.M{operator: cursorValue}}, nil
|
||||
}
|
||||
|
||||
// generateCursor generates a cursor from a document
|
||||
func (r *repository[T]) generateCursor(doc T, sortField string) (string, error) {
|
||||
// Convert document to BSON
|
||||
docBytes, err := bson.Marshal(doc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal document: %w", err)
|
||||
}
|
||||
|
||||
var docMap bson.M
|
||||
if err = bson.Unmarshal(docBytes, &docMap); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal document: %w", err)
|
||||
}
|
||||
|
||||
// Get the sort field value
|
||||
sortValue, exists := docMap[sortField]
|
||||
if !exists {
|
||||
return "", fmt.Errorf("sort field %s not found in document", sortField)
|
||||
}
|
||||
|
||||
// Convert to string for encoding
|
||||
var cursorStr string
|
||||
switch v := sortValue.(type) {
|
||||
case primitive.ObjectID:
|
||||
cursorStr = v.Hex()
|
||||
case string:
|
||||
cursorStr = v
|
||||
case int64:
|
||||
cursorStr = fmt.Sprintf("%d", v)
|
||||
case float64:
|
||||
cursorStr = fmt.Sprintf("%f", v)
|
||||
default:
|
||||
cursorStr = fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
// Encode as base64
|
||||
return base64.StdEncoding.EncodeToString([]byte(cursorStr)), nil
|
||||
}
|
||||
|
||||
// getModelID extracts the ID from a model
|
||||
func (r *repository[T]) getModelID(model *T) (string, error) {
|
||||
if idGetter, ok := any(model).(IDGetter); ok {
|
||||
return idGetter.GetID(), nil
|
||||
}
|
||||
|
||||
// Fallback: try to get ID from BSON
|
||||
docBytes, err := bson.Marshal(model)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal model: %w", err)
|
||||
}
|
||||
|
||||
var docMap bson.M
|
||||
if err = bson.Unmarshal(docBytes, &docMap); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal model: %w", err)
|
||||
}
|
||||
|
||||
if id, exists := docMap["_id"]; exists {
|
||||
if oid, ok := id.(primitive.ObjectID); ok {
|
||||
return oid.Hex(), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("model does not have an ID field")
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
package mongo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
)
|
||||
|
||||
// TestUser represents a test user model
|
||||
type TestUser struct {
|
||||
Model
|
||||
Email string `bson:"email" json:"email"`
|
||||
FirstName string `bson:"first_name" json:"first_name"`
|
||||
LastName string `bson:"last_name" json:"last_name"`
|
||||
Age int `bson:"age" json:"age"`
|
||||
IsActive bool `bson:"is_active" json:"is_active"`
|
||||
}
|
||||
|
||||
// TestLogger implements Logger for testing
|
||||
type TestLogger struct{}
|
||||
|
||||
func (l *TestLogger) Info(message string, fields map[string]interface{}) {}
|
||||
func (l *TestLogger) Error(message string, fields map[string]interface{}) {}
|
||||
func (l *TestLogger) Debug(message string, fields map[string]interface{}) {}
|
||||
func (l *TestLogger) Warn(message string, fields map[string]interface{}) {}
|
||||
|
||||
// setupTestConnection creates a test connection manager
|
||||
func setupTestConnection(t *testing.T) *ConnectionManager {
|
||||
config := ConnectionConfig{
|
||||
URI: "mongodb://localhost:27017",
|
||||
Database: "test_database",
|
||||
}
|
||||
|
||||
logger := &TestLogger{}
|
||||
connManager, err := NewConnectionManager(config, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
return connManager
|
||||
}
|
||||
|
||||
// cleanupTestData cleans up test data
|
||||
func cleanupTestData(t *testing.T, connManager *ConnectionManager, collectionName string) {
|
||||
collection := connManager.GetCollection(collectionName)
|
||||
_, err := collection.DeleteMany(context.Background(), bson.M{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRepository_Create(t *testing.T) {
|
||||
connManager := setupTestConnection(t)
|
||||
defer connManager.Close(context.Background())
|
||||
defer cleanupTestData(t, connManager, "test_users")
|
||||
|
||||
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
|
||||
|
||||
user := &TestUser{
|
||||
Email: "test@example.com",
|
||||
FirstName: "Test",
|
||||
LastName: "User",
|
||||
Age: 25,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
err := repo.Create(context.Background(), user)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, user.ID)
|
||||
assert.NotZero(t, user.CreatedAt)
|
||||
assert.NotZero(t, user.UpdatedAt)
|
||||
}
|
||||
|
||||
func TestRepository_FindByID(t *testing.T) {
|
||||
connManager := setupTestConnection(t)
|
||||
defer connManager.Close(context.Background())
|
||||
defer cleanupTestData(t, connManager, "test_users")
|
||||
|
||||
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
|
||||
|
||||
// Create a user
|
||||
user := &TestUser{
|
||||
Email: "test@example.com",
|
||||
FirstName: "Test",
|
||||
LastName: "User",
|
||||
Age: 25,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
err := repo.Create(context.Background(), user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Find by ID
|
||||
foundUser, err := repo.FindByID(context.Background(), user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user.Email, foundUser.Email)
|
||||
assert.Equal(t, user.FirstName, foundUser.FirstName)
|
||||
|
||||
// Test not found
|
||||
_, err = repo.FindByID(context.Background(), "507f1f77bcf86cd799439011")
|
||||
assert.ErrorIs(t, err, ErrDocumentNotFound)
|
||||
}
|
||||
|
||||
func TestRepository_FindOne(t *testing.T) {
|
||||
connManager := setupTestConnection(t)
|
||||
defer connManager.Close(context.Background())
|
||||
defer cleanupTestData(t, connManager, "test_users")
|
||||
|
||||
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
|
||||
|
||||
// Create a user
|
||||
user := &TestUser{
|
||||
Email: "test@example.com",
|
||||
FirstName: "Test",
|
||||
LastName: "User",
|
||||
Age: 25,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
err := repo.Create(context.Background(), user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Find by email
|
||||
foundUser, err := repo.FindOne(context.Background(), bson.M{"email": "test@example.com"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user.Email, foundUser.Email)
|
||||
|
||||
// Test not found
|
||||
_, err = repo.FindOne(context.Background(), bson.M{"email": "nonexistent@example.com"})
|
||||
assert.ErrorIs(t, err, ErrDocumentNotFound)
|
||||
}
|
||||
|
||||
func TestRepository_Update(t *testing.T) {
|
||||
connManager := setupTestConnection(t)
|
||||
defer connManager.Close(context.Background())
|
||||
defer cleanupTestData(t, connManager, "test_users")
|
||||
|
||||
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
|
||||
|
||||
// Create a user
|
||||
user := &TestUser{
|
||||
Email: "test@example.com",
|
||||
FirstName: "Test",
|
||||
LastName: "User",
|
||||
Age: 25,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
err := repo.Create(context.Background(), user)
|
||||
require.NoError(t, err)
|
||||
|
||||
originalUpdatedAt := user.UpdatedAt
|
||||
|
||||
// Update user
|
||||
user.FirstName = "Updated"
|
||||
user.Age = 30
|
||||
time.Sleep(1 * time.Second) // Ensure timestamp difference
|
||||
|
||||
err = repo.Update(context.Background(), user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify update
|
||||
foundUser, err := repo.FindByID(context.Background(), user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Updated", foundUser.FirstName)
|
||||
assert.Equal(t, 30, foundUser.Age)
|
||||
assert.Greater(t, foundUser.UpdatedAt, originalUpdatedAt)
|
||||
}
|
||||
|
||||
func TestRepository_Delete(t *testing.T) {
|
||||
connManager := setupTestConnection(t)
|
||||
defer connManager.Close(context.Background())
|
||||
defer cleanupTestData(t, connManager, "test_users")
|
||||
|
||||
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
|
||||
|
||||
// Create a user
|
||||
user := &TestUser{
|
||||
Email: "test@example.com",
|
||||
FirstName: "Test",
|
||||
LastName: "User",
|
||||
Age: 25,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
err := repo.Create(context.Background(), user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete user
|
||||
err = repo.Delete(context.Background(), user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify deletion
|
||||
_, err = repo.FindByID(context.Background(), user.ID)
|
||||
assert.ErrorIs(t, err, ErrDocumentNotFound)
|
||||
}
|
||||
|
||||
func TestRepository_FindAll_OffsetPagination(t *testing.T) {
|
||||
connManager := setupTestConnection(t)
|
||||
defer connManager.Close(context.Background())
|
||||
defer cleanupTestData(t, connManager, "test_users")
|
||||
|
||||
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
|
||||
|
||||
// Create multiple users
|
||||
users := []TestUser{
|
||||
{Email: "user1@example.com", FirstName: "User1", Age: 25, IsActive: true},
|
||||
{Email: "user2@example.com", FirstName: "User2", Age: 30, IsActive: true},
|
||||
{Email: "user3@example.com", FirstName: "User3", Age: 35, IsActive: false},
|
||||
{Email: "user4@example.com", FirstName: "User4", Age: 40, IsActive: true},
|
||||
{Email: "user5@example.com", FirstName: "User5", Age: 45, IsActive: true},
|
||||
}
|
||||
|
||||
err := repo.BulkCreate(context.Background(), users)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test offset-based pagination
|
||||
pagination := NewPaginationBuilder().
|
||||
Limit(2).
|
||||
Skip(1).
|
||||
SortAsc("created_at").
|
||||
Build()
|
||||
|
||||
results, err := repo.FindAll(context.Background(), bson.M{}, pagination)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, results.Items, 2)
|
||||
assert.Equal(t, int64(5), results.TotalCount)
|
||||
assert.False(t, results.HasMore) // Should be false since we're using offset
|
||||
}
|
||||
|
||||
func TestRepository_FindAll_CursorPagination(t *testing.T) {
|
||||
connManager := setupTestConnection(t)
|
||||
defer connManager.Close(context.Background())
|
||||
defer cleanupTestData(t, connManager, "test_users")
|
||||
|
||||
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
|
||||
|
||||
// Create multiple users
|
||||
users := []TestUser{
|
||||
{Email: "user1@example.com", FirstName: "User1", Age: 25, IsActive: true},
|
||||
{Email: "user2@example.com", FirstName: "User2", Age: 30, IsActive: true},
|
||||
{Email: "user3@example.com", FirstName: "User3", Age: 35, IsActive: false},
|
||||
{Email: "user4@example.com", FirstName: "User4", Age: 40, IsActive: true},
|
||||
{Email: "user5@example.com", FirstName: "User5", Age: 45, IsActive: true},
|
||||
}
|
||||
|
||||
err := repo.BulkCreate(context.Background(), users)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test cursor-based pagination
|
||||
pagination := NewPaginationBuilder().
|
||||
Limit(2).
|
||||
SortDesc("created_at").
|
||||
Build()
|
||||
|
||||
firstPage, err := repo.FindAll(context.Background(), bson.M{}, pagination)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, firstPage.Items, 2)
|
||||
assert.Equal(t, int64(5), firstPage.TotalCount)
|
||||
assert.True(t, firstPage.HasMore)
|
||||
assert.NotEmpty(t, firstPage.NextCursor)
|
||||
|
||||
// Get second page
|
||||
secondPagePagination := NewPaginationBuilder().
|
||||
Limit(2).
|
||||
SortDesc("created_at").
|
||||
Cursor(firstPage.NextCursor).
|
||||
Build()
|
||||
|
||||
secondPage, err := repo.FindAll(context.Background(), bson.M{}, secondPagePagination)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, secondPage.Items, 2)
|
||||
assert.Equal(t, int64(5), secondPage.TotalCount)
|
||||
assert.True(t, secondPage.HasMore)
|
||||
assert.NotEmpty(t, secondPage.NextCursor)
|
||||
}
|
||||
|
||||
func TestRepository_Count(t *testing.T) {
|
||||
connManager := setupTestConnection(t)
|
||||
defer connManager.Close(context.Background())
|
||||
defer cleanupTestData(t, connManager, "test_users")
|
||||
|
||||
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
|
||||
|
||||
// Create users
|
||||
users := []TestUser{
|
||||
{Email: "user1@example.com", FirstName: "User1", Age: 25, IsActive: true},
|
||||
{Email: "user2@example.com", FirstName: "User2", Age: 30, IsActive: true},
|
||||
{Email: "user3@example.com", FirstName: "User3", Age: 35, IsActive: false},
|
||||
}
|
||||
|
||||
err := repo.BulkCreate(context.Background(), users)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Count all users
|
||||
totalCount, err := repo.Count(context.Background(), bson.M{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), totalCount)
|
||||
|
||||
// Count active users
|
||||
activeCount, err := repo.Count(context.Background(), bson.M{"is_active": true})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), activeCount)
|
||||
}
|
||||
|
||||
func TestRepository_BulkOperations(t *testing.T) {
|
||||
connManager := setupTestConnection(t)
|
||||
defer connManager.Close(context.Background())
|
||||
defer cleanupTestData(t, connManager, "test_users")
|
||||
|
||||
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
|
||||
|
||||
// Test bulk create
|
||||
users := []TestUser{
|
||||
{Email: "user1@example.com", FirstName: "User1", Age: 25, IsActive: true},
|
||||
{Email: "user2@example.com", FirstName: "User2", Age: 30, IsActive: true},
|
||||
{Email: "user3@example.com", FirstName: "User3", Age: 35, IsActive: false},
|
||||
}
|
||||
|
||||
err := repo.BulkCreate(context.Background(), users)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify bulk create
|
||||
count, err := repo.Count(context.Background(), bson.M{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), count)
|
||||
|
||||
// Test bulk update
|
||||
updateFilter := bson.M{"is_active": false}
|
||||
update := bson.M{"$set": bson.M{"age": 50}}
|
||||
|
||||
err = repo.BulkUpdate(context.Background(), updateFilter, update)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify bulk update
|
||||
updatedUser, err := repo.FindOne(context.Background(), bson.M{"email": "user3@example.com"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 50, updatedUser.Age)
|
||||
|
||||
// Test bulk delete
|
||||
deleteFilter := bson.M{"is_active": false}
|
||||
err = repo.BulkDelete(context.Background(), deleteFilter)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify bulk delete
|
||||
count, err = repo.Count(context.Background(), bson.M{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), count)
|
||||
}
|
||||
|
||||
func TestRepository_Aggregate(t *testing.T) {
|
||||
connManager := setupTestConnection(t)
|
||||
defer connManager.Close(context.Background())
|
||||
defer cleanupTestData(t, connManager, "test_users")
|
||||
|
||||
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
|
||||
|
||||
// Create users
|
||||
users := []TestUser{
|
||||
{Email: "user1@example.com", FirstName: "User1", Age: 25, IsActive: true},
|
||||
{Email: "user2@example.com", FirstName: "User2", Age: 30, IsActive: true},
|
||||
{Email: "user3@example.com", FirstName: "User3", Age: 35, IsActive: false},
|
||||
}
|
||||
|
||||
err := repo.BulkCreate(context.Background(), users)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test aggregation
|
||||
pipeline := mongo.Pipeline{
|
||||
{{Key: "$match", Value: bson.M{"is_active": true}}},
|
||||
{{Key: "$group", Value: bson.M{
|
||||
"_id": nil,
|
||||
"count": bson.M{"$sum": 1},
|
||||
"avgAge": bson.M{"$avg": "$age"},
|
||||
}}},
|
||||
}
|
||||
|
||||
results, err := repo.Aggregate(context.Background(), pipeline)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 1)
|
||||
|
||||
result := results[0]
|
||||
assert.Equal(t, int64(2), result["count"])
|
||||
assert.Equal(t, 27.5, result["avgAge"])
|
||||
}
|
||||
|
||||
func TestRepository_QueryBuilder(t *testing.T) {
|
||||
connManager := setupTestConnection(t)
|
||||
defer connManager.Close(context.Background())
|
||||
defer cleanupTestData(t, connManager, "test_users")
|
||||
|
||||
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
|
||||
|
||||
// Create users
|
||||
users := []TestUser{
|
||||
{Email: "user1@example.com", FirstName: "User1", Age: 25, IsActive: true},
|
||||
{Email: "user2@example.com", FirstName: "User2", Age: 30, IsActive: true},
|
||||
{Email: "user3@example.com", FirstName: "User3", Age: 35, IsActive: false},
|
||||
}
|
||||
|
||||
err := repo.BulkCreate(context.Background(), users)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test QueryBuilder
|
||||
query := NewQueryBuilder().
|
||||
Where("is_active", true).
|
||||
WhereGreaterThan("age", 20).
|
||||
WhereLessThan("age", 35).
|
||||
Build()
|
||||
|
||||
results, err := repo.FindMany(context.Background(), query, 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 2)
|
||||
|
||||
// Test complex query with AND/OR
|
||||
complexQuery := NewQueryBuilder().
|
||||
WhereAnd(
|
||||
bson.M{"is_active": true},
|
||||
bson.M{"age": bson.M{"$gte": 25}},
|
||||
).
|
||||
WhereOr(
|
||||
bson.M{"email": "user1@example.com"},
|
||||
bson.M{"email": "user2@example.com"},
|
||||
).
|
||||
Build()
|
||||
|
||||
complexResults, err := repo.FindMany(context.Background(), complexQuery, 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, complexResults, 2)
|
||||
}
|
||||
|
||||
func TestRepository_Validation(t *testing.T) {
|
||||
connManager := setupTestConnection(t)
|
||||
defer connManager.Close(context.Background())
|
||||
defer cleanupTestData(t, connManager, "test_users")
|
||||
|
||||
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
|
||||
|
||||
// Test invalid pagination
|
||||
invalidPagination := Pagination{
|
||||
Limit: -1, // Invalid
|
||||
SortOrder: 2, // Invalid
|
||||
}
|
||||
|
||||
_, err := repo.FindAll(context.Background(), bson.M{}, invalidPagination)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrInvalidPagination)
|
||||
|
||||
// Test invalid cursor
|
||||
invalidCursorPagination := NewPaginationBuilder().
|
||||
Limit(10).
|
||||
Cursor("invalid-base64").
|
||||
Build()
|
||||
|
||||
_, err = repo.FindAll(context.Background(), bson.M{}, invalidCursorPagination)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrInvalidCursor)
|
||||
}
|
||||
|
||||
func TestRepository_IndexManagement(t *testing.T) {
|
||||
connManager := setupTestConnection(t)
|
||||
defer connManager.Close(context.Background())
|
||||
|
||||
// Test creating indexes
|
||||
indexes := []Index{
|
||||
*CreateUniqueIndex("email_idx", bson.D{{Key: "email", Value: 1}}),
|
||||
*NewIndex("age_idx", bson.D{{Key: "age", Value: 1}}),
|
||||
}
|
||||
|
||||
err := connManager.CreateIndexes("test_users", indexes)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test listing indexes
|
||||
listIndexes, err := connManager.ListIndexes("test_users")
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(listIndexes), 3) // _id + created_at + our indexes
|
||||
|
||||
// Test dropping indexes
|
||||
err = connManager.DropIndexes("test_users", []string{"age_idx"})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRepository_ConnectionManagement(t *testing.T) {
|
||||
config := ConnectionConfig{
|
||||
URI: "mongodb://localhost:27017",
|
||||
Database: "test_database",
|
||||
}
|
||||
|
||||
logger := &TestLogger{}
|
||||
connManager, err := NewConnectionManager(config, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test ping
|
||||
err = connManager.Ping(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test get stats
|
||||
stats, err := connManager.GetStats(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, stats)
|
||||
|
||||
// Test collection stats
|
||||
collectionStats, err := connManager.GetCollectionStats(context.Background(), "test_users")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, collectionStats)
|
||||
|
||||
// Test close
|
||||
err = connManager.Close(context.Background())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRepository_ErrorHandling(t *testing.T) {
|
||||
connManager := setupTestConnection(t)
|
||||
defer connManager.Close(context.Background())
|
||||
defer cleanupTestData(t, connManager, "test_users")
|
||||
|
||||
repo := NewRepository[TestUser](connManager.GetCollection("test_users"), &TestLogger{})
|
||||
|
||||
// Test invalid ObjectID
|
||||
_, err := repo.FindByID(context.Background(), "invalid-id")
|
||||
assert.Error(t, err)
|
||||
|
||||
// Test update non-existent document
|
||||
nonExistentUser := &TestUser{
|
||||
Model: Model{ID: "507f1f77bcf86cd799439011"},
|
||||
Email: "nonexistent@example.com",
|
||||
}
|
||||
err = repo.Update(context.Background(), nonExistentUser)
|
||||
assert.ErrorIs(t, err, ErrDocumentNotFound)
|
||||
|
||||
// Test delete non-existent document
|
||||
err = repo.Delete(context.Background(), "507f1f77bcf86cd799439011")
|
||||
assert.ErrorIs(t, err, ErrDocumentNotFound)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package mongo
|
||||
|
||||
import (
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
// Index represents a MongoDB index
|
||||
type Index struct {
|
||||
Name string `json:"name"`
|
||||
Keys bson.D `json:"keys"`
|
||||
Options *options.IndexOptions `json:"options"`
|
||||
}
|
||||
|
||||
// NewIndex creates a new index with default options
|
||||
func NewIndex(name string, keys bson.D) *Index {
|
||||
return &Index{
|
||||
Name: name,
|
||||
Keys: keys,
|
||||
Options: options.Index(),
|
||||
}
|
||||
}
|
||||
|
||||
// WithUnique sets the unique option for the index
|
||||
func (i *Index) WithUnique(unique bool) *Index {
|
||||
i.Options.SetUnique(unique)
|
||||
return i
|
||||
}
|
||||
|
||||
// WithSparse sets the sparse option for the index
|
||||
func (i *Index) WithSparse(sparse bool) *Index {
|
||||
i.Options.SetSparse(sparse)
|
||||
return i
|
||||
}
|
||||
|
||||
// WithBackground sets the background option for the index
|
||||
func (i *Index) WithBackground(background bool) *Index {
|
||||
i.Options.SetBackground(background)
|
||||
return i
|
||||
}
|
||||
|
||||
// WithExpireAfterSeconds sets the TTL for the index
|
||||
func (i *Index) WithExpireAfterSeconds(seconds int32) *Index {
|
||||
i.Options.SetExpireAfterSeconds(seconds)
|
||||
return i
|
||||
}
|
||||
|
||||
// WithPartialFilterExpression sets the partial filter expression for the index
|
||||
func (i *Index) WithPartialFilterExpression(filter bson.M) *Index {
|
||||
i.Options.SetPartialFilterExpression(filter)
|
||||
return i
|
||||
}
|
||||
|
||||
// WithCollation sets the collation for the index
|
||||
func (i *Index) WithCollation(collation *options.Collation) *Index {
|
||||
i.Options.SetCollation(collation)
|
||||
return i
|
||||
}
|
||||
|
||||
// Common index builders
|
||||
|
||||
// CreateUniqueIndex creates a unique index
|
||||
func CreateUniqueIndex(name string, keys bson.D) *Index {
|
||||
return NewIndex(name, keys).WithUnique(true)
|
||||
}
|
||||
|
||||
// CreateTTLIndex creates a TTL index
|
||||
func CreateTTLIndex(name string, field string, expireAfterSeconds int32) *Index {
|
||||
return NewIndex(name, bson.D{{Key: field, Value: 1}}).WithExpireAfterSeconds(expireAfterSeconds)
|
||||
}
|
||||
|
||||
// CreateTextIndex creates a text index
|
||||
func CreateTextIndex(name string, fields ...string) *Index {
|
||||
keys := bson.D{}
|
||||
for _, field := range fields {
|
||||
keys = append(keys, bson.E{Key: field, Value: "text"})
|
||||
}
|
||||
return NewIndex(name, keys)
|
||||
}
|
||||
|
||||
// CreateCompoundIndex creates a compound index
|
||||
func CreateCompoundIndex(name string, fields map[string]int) *Index {
|
||||
keys := bson.D{}
|
||||
for field, direction := range fields {
|
||||
keys = append(keys, bson.E{Key: field, Value: direction})
|
||||
}
|
||||
return NewIndex(name, keys)
|
||||
}
|
||||
|
||||
// CreateGeospatialIndex creates a 2dsphere geospatial index
|
||||
func CreateGeospatialIndex(name string, field string) *Index {
|
||||
return NewIndex(name, bson.D{{Key: field, Value: "2dsphere"}})
|
||||
}
|
||||
|
||||
// CreateHashedIndex creates a hashed index
|
||||
func CreateHashedIndex(name string, field string) *Index {
|
||||
return NewIndex(name, bson.D{{Key: field, Value: "hashed"}})
|
||||
}
|
||||
|
||||
// Common index patterns
|
||||
|
||||
// DefaultIndexes returns common default indexes for most collections
|
||||
func DefaultIndexes() []Index {
|
||||
return []Index{
|
||||
*CreateUniqueIndex("_id_", bson.D{{Key: "_id", Value: 1}}),
|
||||
*NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
|
||||
*NewIndex("updated_at_idx", bson.D{{Key: "updated_at", Value: -1}}),
|
||||
}
|
||||
}
|
||||
|
||||
// UserIndexes returns common indexes for user collections
|
||||
func UserIndexes() []Index {
|
||||
return []Index{
|
||||
*CreateUniqueIndex("_id_", bson.D{{Key: "_id", Value: 1}}),
|
||||
*CreateUniqueIndex("email_idx", bson.D{{Key: "email", Value: 1}}),
|
||||
*NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
|
||||
*NewIndex("updated_at_idx", bson.D{{Key: "updated_at", Value: -1}}),
|
||||
*NewIndex("is_active_idx", bson.D{{Key: "is_active", Value: 1}}),
|
||||
}
|
||||
}
|
||||
|
||||
// TenderIndexes returns common indexes for tender collections
|
||||
func TenderIndexes() []Index {
|
||||
return []Index{
|
||||
*CreateUniqueIndex("_id_", bson.D{{Key: "_id", Value: 1}}),
|
||||
*NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
|
||||
*NewIndex("updated_at_idx", bson.D{{Key: "updated_at", Value: -1}}),
|
||||
*NewIndex("status_idx", bson.D{{Key: "status", Value: 1}}),
|
||||
*NewIndex("deadline_idx", bson.D{{Key: "deadline", Value: 1}}),
|
||||
*NewIndex("category_idx", bson.D{{Key: "category", Value: 1}}),
|
||||
*NewIndex("location_idx", bson.D{{Key: "location", Value: 1}}),
|
||||
}
|
||||
}
|
||||
|
||||
// CompanyIndexes returns common indexes for company collections
|
||||
func CompanyIndexes() []Index {
|
||||
return []Index{
|
||||
*CreateUniqueIndex("_id_", bson.D{{Key: "_id", Value: 1}}),
|
||||
*CreateUniqueIndex("name_idx", bson.D{{Key: "name", Value: 1}}),
|
||||
*NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
|
||||
*NewIndex("updated_at_idx", bson.D{{Key: "updated_at", Value: -1}}),
|
||||
*NewIndex("is_active_idx", bson.D{{Key: "is_active", Value: 1}}),
|
||||
}
|
||||
}
|
||||
|
||||
// QueryBuilder provides a fluent interface for building MongoDB queries
|
||||
type QueryBuilder struct {
|
||||
filter bson.M
|
||||
}
|
||||
|
||||
// NewQueryBuilder creates a new query builder
|
||||
func NewQueryBuilder() *QueryBuilder {
|
||||
return &QueryBuilder{
|
||||
filter: bson.M{},
|
||||
}
|
||||
}
|
||||
|
||||
// Where adds a simple equality condition
|
||||
func (qb *QueryBuilder) Where(field string, value interface{}) *QueryBuilder {
|
||||
qb.filter[field] = value
|
||||
return qb
|
||||
}
|
||||
|
||||
// WhereIn adds an $in condition
|
||||
func (qb *QueryBuilder) WhereIn(field string, values []interface{}) *QueryBuilder {
|
||||
qb.filter[field] = bson.M{"$in": values}
|
||||
return qb
|
||||
}
|
||||
|
||||
// WhereNotIn adds a $nin condition
|
||||
func (qb *QueryBuilder) WhereNotIn(field string, values []interface{}) *QueryBuilder {
|
||||
qb.filter[field] = bson.M{"$nin": values}
|
||||
return qb
|
||||
}
|
||||
|
||||
// WhereGreaterThan adds a $gt condition
|
||||
func (qb *QueryBuilder) WhereGreaterThan(field string, value interface{}) *QueryBuilder {
|
||||
qb.filter[field] = bson.M{"$gt": value}
|
||||
return qb
|
||||
}
|
||||
|
||||
// WhereGreaterThanOrEqual adds a $gte condition
|
||||
func (qb *QueryBuilder) WhereGreaterThanOrEqual(field string, value interface{}) *QueryBuilder {
|
||||
qb.filter[field] = bson.M{"$gte": value}
|
||||
return qb
|
||||
}
|
||||
|
||||
// WhereLessThan adds a $lt condition
|
||||
func (qb *QueryBuilder) WhereLessThan(field string, value interface{}) *QueryBuilder {
|
||||
qb.filter[field] = bson.M{"$lt": value}
|
||||
return qb
|
||||
}
|
||||
|
||||
// WhereLessThanOrEqual adds a $lte condition
|
||||
func (qb *QueryBuilder) WhereLessThanOrEqual(field string, value interface{}) *QueryBuilder {
|
||||
qb.filter[field] = bson.M{"$lte": value}
|
||||
return qb
|
||||
}
|
||||
|
||||
// WhereNotEqual adds a $ne condition
|
||||
func (qb *QueryBuilder) WhereNotEqual(field string, value interface{}) *QueryBuilder {
|
||||
qb.filter[field] = bson.M{"$ne": value}
|
||||
return qb
|
||||
}
|
||||
|
||||
// WhereExists adds an $exists condition
|
||||
func (qb *QueryBuilder) WhereExists(field string, exists bool) *QueryBuilder {
|
||||
qb.filter[field] = bson.M{"$exists": exists}
|
||||
return qb
|
||||
}
|
||||
|
||||
// WhereRegex adds a $regex condition
|
||||
func (qb *QueryBuilder) WhereRegex(field string, pattern string, options string) *QueryBuilder {
|
||||
qb.filter[field] = bson.M{"$regex": pattern, "$options": options}
|
||||
return qb
|
||||
}
|
||||
|
||||
// WhereText adds a $text condition
|
||||
func (qb *QueryBuilder) WhereText(search string) *QueryBuilder {
|
||||
qb.filter["$text"] = bson.M{"$search": search}
|
||||
return qb
|
||||
}
|
||||
|
||||
// WhereAnd adds an $and condition
|
||||
func (qb *QueryBuilder) WhereAnd(conditions ...bson.M) *QueryBuilder {
|
||||
qb.filter["$and"] = conditions
|
||||
return qb
|
||||
}
|
||||
|
||||
// WhereOr adds an $or condition
|
||||
func (qb *QueryBuilder) WhereOr(conditions ...bson.M) *QueryBuilder {
|
||||
qb.filter["$or"] = conditions
|
||||
return qb
|
||||
}
|
||||
|
||||
// WhereNor adds a $nor condition
|
||||
func (qb *QueryBuilder) WhereNor(conditions ...bson.M) *QueryBuilder {
|
||||
qb.filter["$nor"] = conditions
|
||||
return qb
|
||||
}
|
||||
|
||||
// Build returns the final filter
|
||||
func (qb *QueryBuilder) Build() bson.M {
|
||||
return qb.filter
|
||||
}
|
||||
|
||||
// PaginationBuilder provides a fluent interface for building pagination options
|
||||
type PaginationBuilder struct {
|
||||
pagination Pagination
|
||||
}
|
||||
|
||||
// NewPaginationBuilder creates a new pagination builder
|
||||
func NewPaginationBuilder() *PaginationBuilder {
|
||||
return &PaginationBuilder{
|
||||
pagination: Pagination{
|
||||
Limit: 10,
|
||||
SortField: "_id",
|
||||
SortOrder: -1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Limit sets the limit
|
||||
func (pb *PaginationBuilder) Limit(limit int) *PaginationBuilder {
|
||||
pb.pagination.Limit = limit
|
||||
return pb
|
||||
}
|
||||
|
||||
// Skip sets the skip value for offset-based pagination
|
||||
func (pb *PaginationBuilder) Skip(skip int) *PaginationBuilder {
|
||||
pb.pagination.Skip = skip
|
||||
return pb
|
||||
}
|
||||
|
||||
// Cursor sets the cursor for cursor-based pagination
|
||||
func (pb *PaginationBuilder) Cursor(cursor string) *PaginationBuilder {
|
||||
pb.pagination.Cursor = cursor
|
||||
return pb
|
||||
}
|
||||
|
||||
// SortBy sets the sort field and order
|
||||
func (pb *PaginationBuilder) SortBy(field string, order int) *PaginationBuilder {
|
||||
pb.pagination.SortField = field
|
||||
pb.pagination.SortOrder = order
|
||||
return pb
|
||||
}
|
||||
|
||||
// SortAsc sorts in ascending order
|
||||
func (pb *PaginationBuilder) SortAsc(field string) *PaginationBuilder {
|
||||
return pb.SortBy(field, 1)
|
||||
}
|
||||
|
||||
// SortDesc sorts in descending order
|
||||
func (pb *PaginationBuilder) SortDesc(field string) *PaginationBuilder {
|
||||
return pb.SortBy(field, -1)
|
||||
}
|
||||
|
||||
// Build returns the final pagination options
|
||||
func (pb *PaginationBuilder) Build() Pagination {
|
||||
return pb.pagination
|
||||
}
|
||||
Reference in New Issue
Block a user