Add cursor rules and initial configuration for Tender Management Go Backend

This commit is contained in:
n.nakhostin
2025-08-02 10:43:47 +03:30
parent ad9db7bcce
commit 5a1ceb0bd6
35 changed files with 4123 additions and 4550 deletions
+479
View File
@@ -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.
+264
View File
@@ -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
}
+64
View File
@@ -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
}
+593
View File
@@ -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")
}
+538
View File
@@ -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)
}
+301
View File
@@ -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
}