service backend
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful

This commit is contained in:
Stephan D
2025-11-07 18:35:26 +01:00
parent 20e8f9acc4
commit 62a6631b9a
537 changed files with 48453 additions and 0 deletions

View File

@@ -0,0 +1,90 @@
package builderimp
import (
"github.com/tech/sendico/pkg/db/repository/builder"
"go.mongodb.org/mongo-driver/bson"
)
type literalAccumulatorImp struct {
op builder.MongoOperation
value any
}
func (a *literalAccumulatorImp) Build() bson.D {
return bson.D{{Key: string(a.op), Value: a.value}}
}
func NewAccumulator(op builder.MongoOperation, value any) builder.Accumulator {
return &literalAccumulatorImp{op: op, value: value}
}
func AddToSet(value builder.Expression) builder.Expression {
return newUnaryExpression(builder.AddToSet, value)
}
func Size(value builder.Expression) builder.Expression {
return newUnaryExpression(builder.Size, value)
}
func Ne(left, right builder.Expression) builder.Expression {
return newBinaryExpression(builder.Ne, left, right)
}
func Sum(value any) builder.Accumulator {
return NewAccumulator(builder.Sum, value)
}
func Avg(value any) builder.Accumulator {
return NewAccumulator(builder.Avg, value)
}
func Min(value any) builder.Accumulator {
return NewAccumulator(builder.Min, value)
}
func Max(value any) builder.Accumulator {
return NewAccumulator(builder.Max, value)
}
func Eq(left, right builder.Expression) builder.Expression {
return newBinaryExpression(builder.Eq, left, right)
}
func Gt(left, right builder.Expression) builder.Expression {
return newBinaryExpression(builder.Gt, left, right)
}
func Add(left, right builder.Accumulator) builder.Accumulator {
return newBinaryAccumulator(builder.Add, left, right)
}
func Subtract(left, right builder.Accumulator) builder.Accumulator {
return newBinaryAccumulator(builder.Subtract, left, right)
}
func Multiply(left, right builder.Accumulator) builder.Accumulator {
return newBinaryAccumulator(builder.Multiply, left, right)
}
func Divide(left, right builder.Accumulator) builder.Accumulator {
return newBinaryAccumulator(builder.Divide, left, right)
}
type binaryAccumulator struct {
op builder.MongoOperation
left builder.Accumulator
right builder.Accumulator
}
func newBinaryAccumulator(op builder.MongoOperation, left, right builder.Accumulator) builder.Accumulator {
return &binaryAccumulator{
op: op,
left: left,
right: right,
}
}
func (b *binaryAccumulator) Build() bson.D {
args := []any{b.left.Build(), b.right.Build()}
return bson.D{{Key: string(b.op), Value: args}}
}

View File

@@ -0,0 +1,102 @@
package builderimp
import (
"github.com/tech/sendico/pkg/db/repository/builder"
"go.mongodb.org/mongo-driver/bson"
)
type aliasImp struct {
lhs builder.Field
rhs any
}
func (a *aliasImp) Field() builder.Field {
return a.lhs
}
func (a *aliasImp) Build() bson.D {
return bson.D{{Key: a.lhs.Build(), Value: a.rhs}}
}
// 1. Null alias (_id: null)
func NewNullAlias(lhs builder.Field) builder.Alias {
return &aliasImp{lhs: lhs, rhs: nil}
}
func NewAlias(lhs builder.Field, rhs any) builder.Alias {
return &aliasImp{lhs: lhs, rhs: rhs}
}
// 2. Simple alias (_id: "$taskRef")
func NewSimpleAlias(lhs, rhs builder.Field) builder.Alias {
return &aliasImp{lhs: lhs, rhs: rhs.Build()}
}
// 3. Complex alias (_id: { aliasName: "$originalField", ... })
type ComplexAlias struct {
lhs builder.Field
rhs []builder.Alias // Correcting handling of slice of aliases
}
func (a *ComplexAlias) Field() builder.Field {
return a.lhs
}
func (a *ComplexAlias) Build() bson.D {
fieldMap := bson.M{}
for _, alias := range a.rhs {
// Each alias.Build() still returns a bson.D
aliasDoc := alias.Build()
// 1. Marshal the ordered D into raw BSON bytes
raw, err := bson.Marshal(aliasDoc)
if err != nil {
panic("Failed to marshal alias document: " + err.Error())
}
// 2. Unmarshal those bytes into an unordered M
var docM bson.M
if err := bson.Unmarshal(raw, &docM); err != nil {
panic("Failed to unmarshal alias document: " + err.Error())
}
// Merge into our accumulator
for k, v := range docM {
fieldMap[k] = v
}
}
return bson.D{{Key: a.lhs.Build(), Value: fieldMap}}
}
func NewComplexAlias(lhs builder.Field, rhs []builder.Alias) builder.Alias {
return &ComplexAlias{lhs: lhs, rhs: rhs}
}
type aliasesImp struct {
aliases []builder.Alias
}
func (a *aliasesImp) Field() builder.Field {
if len(a.aliases) > 0 {
return a.aliases[0].Field()
}
return NewFieldImp("")
}
func (a *aliasesImp) Build() bson.D {
results := make([]bson.D, 0)
for _, alias := range a.aliases {
results = append(results, alias.Build())
}
aliases := bson.D{}
for _, r := range results {
aliases = append(aliases, r...)
}
return aliases
}
func NewAliases(aliases ...builder.Alias) builder.Alias {
return &aliasesImp{aliases: aliases}
}

View File

@@ -0,0 +1,27 @@
package builderimp
import (
"github.com/tech/sendico/pkg/db/repository/builder"
"go.mongodb.org/mongo-driver/bson"
)
type arrayImp struct {
elements []builder.Expression
}
// Build renders the literal array:
//
// [ <expr1>, <expr2>, … ]
func (b *arrayImp) Build() bson.A {
arr := make(bson.A, len(b.elements))
for i, expr := range b.elements {
// each expr.Build() returns the raw value or subexpression
arr[i] = expr.Build()
}
return arr
}
// NewArray constructs a new array expression from the given subexpressions.
func NewArray(exprs ...builder.Expression) *arrayImp {
return &arrayImp{elements: exprs}
}

View File

@@ -0,0 +1,108 @@
package builderimp
import (
"reflect"
"github.com/tech/sendico/pkg/db/repository/builder"
"go.mongodb.org/mongo-driver/bson"
)
type literalExpression struct {
value any
}
func NewLiteralExpression(value any) builder.Expression {
return &literalExpression{value: value}
}
func (e *literalExpression) Build() any {
return bson.D{{Key: string(builder.Literal), Value: e.value}}
}
type variadicExpression struct {
op builder.MongoOperation
parts []builder.Expression
}
func (e *variadicExpression) Build() any {
args := make([]any, 0, len(e.parts))
for _, p := range e.parts {
args = append(args, p.Build())
}
return bson.D{{Key: string(e.op), Value: args}}
}
func newVariadicExpression(op builder.MongoOperation, exprs ...builder.Expression) builder.Expression {
return &variadicExpression{
op: op,
parts: exprs,
}
}
func newBinaryExpression(op builder.MongoOperation, left, right builder.Expression) builder.Expression {
return &variadicExpression{
op: op,
parts: []builder.Expression{left, right},
}
}
type unaryExpression struct {
op builder.MongoOperation
rhs builder.Expression
}
func (e *unaryExpression) Build() any {
return bson.D{{Key: string(e.op), Value: e.rhs.Build()}}
}
func newUnaryExpression(op builder.MongoOperation, right builder.Expression) builder.Expression {
return &unaryExpression{
op: op,
rhs: right,
}
}
type matchExpression struct {
op builder.MongoOperation
rhs builder.Expression
}
func (e *matchExpression) Build() any {
return bson.E{Key: string(e.op), Value: e.rhs.Build()}
}
func newMatchExpression(op builder.MongoOperation, right builder.Expression) builder.Expression {
return &matchExpression{
op: op,
rhs: right,
}
}
func InRef(value builder.Field) builder.Expression {
return newMatchExpression(builder.In, NewValue(NewRefFieldImp(value).Build()))
}
type inImpl struct {
values []any
}
func (e *inImpl) Build() any {
return bson.D{{Key: string(builder.In), Value: e.values}}
}
func In(values ...any) builder.Expression {
var flattenedValues []any
for _, v := range values {
switch reflect.TypeOf(v).Kind() {
case reflect.Slice:
slice := reflect.ValueOf(v)
for i := range slice.Len() {
flattenedValues = append(flattenedValues, slice.Index(i).Interface())
}
default:
flattenedValues = append(flattenedValues, v)
}
}
return &inImpl{values: flattenedValues}
}

View File

@@ -0,0 +1,71 @@
package builderimp
import (
"strings"
"github.com/tech/sendico/pkg/db/repository/builder"
)
type FieldImp struct {
fields []string
}
func (b *FieldImp) Dot(field string) builder.Field {
newFields := make([]string, len(b.fields), len(b.fields)+1)
copy(newFields, b.fields)
newFields = append(newFields, field)
return &FieldImp{fields: newFields}
}
func (b *FieldImp) CopyWith(field string) builder.Field {
copiedFields := make([]string, 0, len(b.fields)+1)
copiedFields = append(copiedFields, b.fields...)
copiedFields = append(copiedFields, field)
return &FieldImp{
fields: copiedFields,
}
}
func (b *FieldImp) Build() string {
return strings.Join(b.fields, ".")
}
func NewFieldImp(baseName string) builder.Field {
return &FieldImp{
fields: []string{baseName},
}
}
type RefField struct {
imp builder.Field
}
func (b *RefField) Build() string {
return "$" + b.imp.Build()
}
func (b *RefField) CopyWith(field string) builder.Field {
return &RefField{
imp: b.imp.CopyWith(field),
}
}
func (b *RefField) Dot(field string) builder.Field {
return &RefField{
imp: b.imp.Dot(field),
}
}
func NewRefFieldImp(field builder.Field) builder.Field {
return &RefField{
imp: field,
}
}
func NewRootRef() builder.Field {
return NewFieldImp("$$ROOT")
}
func NewRemoveRef() builder.Field {
return NewFieldImp("$$REMOVE")
}

View File

@@ -0,0 +1,137 @@
package builderimp
import (
"github.com/tech/sendico/pkg/db/repository/builder"
"go.mongodb.org/mongo-driver/bson"
)
type condImp struct {
condition builder.Expression
ifTrue any
ifFalse any
}
func (c *condImp) Build() any {
return bson.D{
{Key: string(builder.Cond), Value: bson.D{
{Key: "if", Value: c.condition.Build()},
{Key: "then", Value: c.ifTrue},
{Key: "else", Value: c.ifFalse},
}},
}
}
func NewCond(condition builder.Expression, ifTrue, ifFalse any) builder.Expression {
return &condImp{
condition: condition,
ifTrue: ifTrue,
ifFalse: ifFalse,
}
}
// setUnionImp implements builder.Expression but takes only builder.Array inputs.
type setUnionImp struct {
inputs []builder.Expression
}
// Build renders the $setUnion stage:
//
// { $setUnion: [ <array1>, <array2>, … ] }
func (s *setUnionImp) Build() any {
arr := make(bson.A, len(s.inputs))
for i, arrayExpr := range s.inputs {
arr[i] = arrayExpr.Build()
}
return bson.D{
{Key: string(builder.SetUnion), Value: arr},
}
}
// NewSetUnion constructs a new $setUnion expression from the given Arrays.
func NewSetUnion(arrays ...builder.Expression) builder.Expression {
return &setUnionImp{inputs: arrays}
}
type assignmentImp struct {
field builder.Field
expression builder.Expression
}
func (a *assignmentImp) Build() bson.D {
// Assign it to the given field name
return bson.D{
{Key: a.field.Build(), Value: a.expression.Build()},
}
}
// NewAssignment creates a projection assignment of the form:
//
// <field>: <expression>
func NewAssignment(field builder.Field, expression builder.Expression) builder.Projection {
return &assignmentImp{
field: field,
expression: expression,
}
}
type computeImp struct {
field builder.Field
expression builder.Expression
}
func (a *computeImp) Build() any {
return bson.D{
{Key: string(a.field.Build()), Value: a.expression.Build()},
}
}
func NewCompute(field builder.Field, expression builder.Expression) builder.Expression {
return &computeImp{
field: field,
expression: expression,
}
}
func NewIfNull(expression, replacement builder.Expression) builder.Expression {
return newBinaryExpression(builder.IfNull, expression, replacement)
}
func NewPush(expression builder.Expression) builder.Expression {
return newUnaryExpression(builder.Push, expression)
}
func NewAnd(exprs ...builder.Expression) builder.Expression {
return newVariadicExpression(builder.And, exprs...)
}
func NewOr(exprs ...builder.Expression) builder.Expression {
return newVariadicExpression(builder.Or, exprs...)
}
func NewEach(exprs ...builder.Expression) builder.Expression {
return newVariadicExpression(builder.Each, exprs...)
}
func NewLt(left, right builder.Expression) builder.Expression {
return newBinaryExpression(builder.Lt, left, right)
}
func NewNot(expression builder.Expression) builder.Expression {
return newUnaryExpression(builder.Not, expression)
}
func NewSum(expression builder.Expression) builder.Expression {
return newUnaryExpression(builder.Sum, expression)
}
func NewMin(expression builder.Expression) builder.Expression {
return newUnaryExpression(builder.Min, expression)
}
func First(expr builder.Expression) builder.Expression {
return newUnaryExpression(builder.First, expr)
}
func NewType(expr builder.Expression) builder.Expression {
return newUnaryExpression(builder.Type, expr)
}

View File

@@ -0,0 +1,35 @@
package builderimp
import (
"github.com/tech/sendico/pkg/db/repository/builder"
"go.mongodb.org/mongo-driver/bson"
)
type groupAccumulatorImp struct {
field builder.Field
acc builder.Accumulator
}
// NewGroupAccumulator creates a new GroupAccumulator for the given field using the specified operator and value.
func NewGroupAccumulator(field builder.Field, acc builder.Accumulator) builder.GroupAccumulator {
return &groupAccumulatorImp{
field: field,
acc: acc,
}
}
func (g *groupAccumulatorImp) Field() builder.Field {
return g.field
}
func (g *groupAccumulatorImp) Accumulator() builder.Accumulator {
return g.acc
}
// Build returns a bson.E element for this group accumulator.
func (g *groupAccumulatorImp) Build() bson.D {
return bson.D{{
Key: g.field.Build(),
Value: g.acc.Build(),
}}
}

View File

@@ -0,0 +1,60 @@
package builderimp
import (
"time"
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/db/storable"
"go.mongodb.org/mongo-driver/bson"
)
type patchBuilder struct {
updates bson.D
}
func set(field builder.Field, value any) bson.E {
return bson.E{Key: string(builder.Set), Value: bson.D{{Key: field.Build(), Value: value}}}
}
func (u *patchBuilder) Set(field builder.Field, value any) builder.Patch {
u.updates = append(u.updates, set(field, value))
return u
}
func (u *patchBuilder) Inc(field builder.Field, value any) builder.Patch {
u.updates = append(u.updates, bson.E{Key: string(builder.Inc), Value: bson.D{{Key: field.Build(), Value: value}}})
return u
}
func (u *patchBuilder) Unset(field builder.Field) builder.Patch {
u.updates = append(u.updates, bson.E{Key: string(builder.Unset), Value: bson.D{{Key: field.Build(), Value: ""}}})
return u
}
func (u *patchBuilder) Rename(field builder.Field, newName string) builder.Patch {
u.updates = append(u.updates, bson.E{Key: string(builder.Rename), Value: bson.D{{Key: field.Build(), Value: newName}}})
return u
}
func (u *patchBuilder) Push(field builder.Field, value any) builder.Patch {
u.updates = append(u.updates, bson.E{Key: string(builder.Push), Value: bson.D{{Key: field.Build(), Value: value}}})
return u
}
func (u *patchBuilder) Pull(field builder.Field, value any) builder.Patch {
u.updates = append(u.updates, bson.E{Key: string(builder.Pull), Value: bson.D{{Key: field.Build(), Value: value}}})
return u
}
func (u *patchBuilder) AddToSet(field builder.Field, value any) builder.Patch {
u.updates = append(u.updates, bson.E{Key: string(builder.AddToSet), Value: bson.D{{Key: field.Build(), Value: value}}})
return u
}
func (u *patchBuilder) Build() bson.D {
return append(u.updates, set(NewFieldImp(storable.UpdatedAtField), time.Now()))
}
func NewPatchImp() builder.Patch {
return &patchBuilder{updates: bson.D{}}
}

View File

@@ -0,0 +1,131 @@
package builderimp
import (
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
)
type unwindOpts = builder.UnwindOpts
// UnwindOption is the same type defined in the builder package.
type UnwindOption = builder.UnwindOption
// NewUnwindOpts applies all UnwindOption's to a fresh unwindOpts.
func NewUnwindOpts(opts ...UnwindOption) *unwindOpts {
cfg := &unwindOpts{}
for _, opt := range opts {
opt(cfg)
}
return cfg
}
type PipelineImp struct {
pipeline mongo.Pipeline
}
func (b *PipelineImp) Match(filter builder.Query) builder.Pipeline {
b.pipeline = append(b.pipeline, filter.BuildPipeline())
return b
}
func (b *PipelineImp) Lookup(from mservice.Type, localField, foreignField, as builder.Field) builder.Pipeline {
b.pipeline = append(b.pipeline, bson.D{{Key: string(builder.Lookup), Value: bson.D{
{Key: string(builder.MKFrom), Value: from},
{Key: string(builder.MKLocalField), Value: localField.Build()},
{Key: string(builder.MKForeignField), Value: foreignField.Build()},
{Key: string(builder.MKAs), Value: as.Build()},
}}})
return b
}
func (b *PipelineImp) LookupWithPipeline(
from mservice.Type,
nested builder.Pipeline,
as builder.Field,
let *map[string]builder.Field,
) builder.Pipeline {
lookupStage := bson.D{
{Key: string(builder.MKFrom), Value: from},
{Key: string(builder.MKPipeline), Value: nested.Build()},
{Key: string(builder.MKAs), Value: as.Build()},
}
// only add "let" if provided and not empty
if let != nil && len(*let) > 0 {
letDoc := bson.D{}
for varName, fld := range *let {
letDoc = append(letDoc, bson.E{Key: varName, Value: fld.Build()})
}
lookupStage = append(lookupStage, bson.E{Key: string(builder.MKLet), Value: letDoc})
}
b.pipeline = append(b.pipeline, bson.D{{Key: string(builder.Lookup), Value: lookupStage}})
return b
}
func (b *PipelineImp) Unwind(path builder.Field, opts ...UnwindOption) builder.Pipeline {
cfg := NewUnwindOpts(opts...)
var stageValue interface{}
// if no options, shorthand
if !cfg.PreserveNullAndEmptyArrays && cfg.IncludeArrayIndex == "" {
stageValue = path.Build()
} else {
d := bson.D{{Key: string(builder.MKPath), Value: path.Build()}}
if cfg.PreserveNullAndEmptyArrays {
d = append(d, bson.E{Key: string(builder.MKPreserveNullAndEmptyArrays), Value: true})
}
if cfg.IncludeArrayIndex != "" {
d = append(d, bson.E{Key: string(builder.MKIncludeArrayIndex), Value: cfg.IncludeArrayIndex})
}
stageValue = d
}
b.pipeline = append(b.pipeline, bson.D{{Key: string(builder.Unwind), Value: stageValue}})
return b
}
func (b *PipelineImp) Count(field builder.Field) builder.Pipeline {
b.pipeline = append(b.pipeline, bson.D{{Key: string(builder.Count), Value: field.Build()}})
return b
}
func (b *PipelineImp) Group(groupBy builder.Alias, accumulators ...builder.GroupAccumulator) builder.Pipeline {
groupDoc := groupBy.Build()
for _, acc := range accumulators {
groupDoc = append(groupDoc, acc.Build()...)
}
b.pipeline = append(b.pipeline, bson.D{
{Key: string(builder.Group), Value: groupDoc},
})
return b
}
func (b *PipelineImp) Project(projections ...builder.Projection) builder.Pipeline {
projDoc := bson.D{}
for _, pr := range projections {
projDoc = append(projDoc, pr.Build()...)
}
b.pipeline = append(b.pipeline, bson.D{{Key: string(builder.Project), Value: projDoc}})
return b
}
func (b *PipelineImp) ReplaceRoot(newRoot builder.Expression) builder.Pipeline {
b.pipeline = append(b.pipeline, bson.D{{Key: string(builder.ReplaceRoot), Value: bson.D{
{Key: string(builder.MKNewRoot), Value: newRoot.Build()},
}}})
return b
}
func (b *PipelineImp) Build() mongo.Pipeline {
return b.pipeline
}
func NewPipelineImp() builder.Pipeline {
return &PipelineImp{
pipeline: mongo.Pipeline{},
}
}

View File

@@ -0,0 +1,563 @@
package builderimp
import (
"testing"
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/mservice"
"github.com/stretchr/testify/assert"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
func TestNewPipelineImp(t *testing.T) {
pipeline := NewPipelineImp()
assert.NotNil(t, pipeline)
assert.IsType(t, &PipelineImp{}, pipeline)
// Build should return empty pipeline initially
built := pipeline.Build()
assert.NotNil(t, built)
assert.Len(t, built, 0)
}
func TestPipelineImp_Match(t *testing.T) {
pipeline := NewPipelineImp()
mockQuery := &MockQuery{
buildPipeline: bson.D{{Key: "$match", Value: bson.D{{Key: "field", Value: "value"}}}},
}
result := pipeline.Match(mockQuery)
// Should return self for chaining
assert.Same(t, pipeline, result)
built := pipeline.Build()
assert.Len(t, built, 1)
assert.Equal(t, bson.D{{Key: "$match", Value: bson.D{{Key: "field", Value: "value"}}}}, built[0])
}
func TestPipelineImp_Lookup(t *testing.T) {
pipeline := NewPipelineImp()
mockLocalField := &MockField{build: "localField"}
mockForeignField := &MockField{build: "foreignField"}
mockAsField := &MockField{build: "asField"}
result := pipeline.Lookup(mservice.Projects, mockLocalField, mockForeignField, mockAsField)
// Should return self for chaining
assert.Same(t, pipeline, result)
built := pipeline.Build()
assert.Len(t, built, 1)
expected := bson.D{{Key: string(builder.Lookup), Value: bson.D{
{Key: string(builder.MKFrom), Value: mservice.Projects},
{Key: string(builder.MKLocalField), Value: "localField"},
{Key: string(builder.MKForeignField), Value: "foreignField"},
{Key: string(builder.MKAs), Value: "asField"},
}}}
assert.Equal(t, expected, built[0])
}
func TestPipelineImp_LookupWithPipeline_WithoutLet(t *testing.T) {
pipeline := NewPipelineImp()
mockNestedPipeline := &MockPipeline{
build: mongo.Pipeline{bson.D{{Key: "$match", Value: bson.D{{Key: "nested", Value: true}}}}},
}
mockAsField := &MockField{build: "asField"}
result := pipeline.LookupWithPipeline(mservice.Tasks, mockNestedPipeline, mockAsField, nil)
// Should return self for chaining
assert.Same(t, pipeline, result)
built := pipeline.Build()
assert.Len(t, built, 1)
expected := bson.D{{Key: string(builder.Lookup), Value: bson.D{
{Key: string(builder.MKFrom), Value: mservice.Tasks},
{Key: string(builder.MKPipeline), Value: mockNestedPipeline.build},
{Key: string(builder.MKAs), Value: "asField"},
}}}
assert.Equal(t, expected, built[0])
}
func TestPipelineImp_LookupWithPipeline_WithLet(t *testing.T) {
pipeline := NewPipelineImp()
mockNestedPipeline := &MockPipeline{
build: mongo.Pipeline{bson.D{{Key: "$match", Value: bson.D{{Key: "nested", Value: true}}}}},
}
mockAsField := &MockField{build: "asField"}
mockLetField := &MockField{build: "$_id"}
letVars := map[string]builder.Field{
"projRef": mockLetField,
}
result := pipeline.LookupWithPipeline(mservice.Tasks, mockNestedPipeline, mockAsField, &letVars)
// Should return self for chaining
assert.Same(t, pipeline, result)
built := pipeline.Build()
assert.Len(t, built, 1)
expected := bson.D{{Key: string(builder.Lookup), Value: bson.D{
{Key: string(builder.MKFrom), Value: mservice.Tasks},
{Key: string(builder.MKPipeline), Value: mockNestedPipeline.build},
{Key: string(builder.MKAs), Value: "asField"},
{Key: string(builder.MKLet), Value: bson.D{{Key: "projRef", Value: "$_id"}}},
}}}
assert.Equal(t, expected, built[0])
}
func TestPipelineImp_LookupWithPipeline_WithEmptyLet(t *testing.T) {
pipeline := NewPipelineImp()
mockNestedPipeline := &MockPipeline{
build: mongo.Pipeline{bson.D{{Key: "$match", Value: bson.D{{Key: "nested", Value: true}}}}},
}
mockAsField := &MockField{build: "asField"}
emptyLetVars := map[string]builder.Field{}
pipeline.LookupWithPipeline(mservice.Tasks, mockNestedPipeline, mockAsField, &emptyLetVars)
built := pipeline.Build()
assert.Len(t, built, 1)
// Should not include let field when empty
expected := bson.D{{Key: string(builder.Lookup), Value: bson.D{
{Key: string(builder.MKFrom), Value: mservice.Tasks},
{Key: string(builder.MKPipeline), Value: mockNestedPipeline.build},
{Key: string(builder.MKAs), Value: "asField"},
}}}
assert.Equal(t, expected, built[0])
}
func TestPipelineImp_Unwind_Simple(t *testing.T) {
pipeline := NewPipelineImp()
mockField := &MockField{build: "$array"}
result := pipeline.Unwind(mockField)
// Should return self for chaining
assert.Same(t, pipeline, result)
built := pipeline.Build()
assert.Len(t, built, 1)
expected := bson.D{{Key: string(builder.Unwind), Value: "$array"}}
assert.Equal(t, expected, built[0])
}
func TestPipelineImp_Unwind_WithPreserveNullAndEmptyArrays(t *testing.T) {
pipeline := NewPipelineImp()
mockField := &MockField{build: "$array"}
// Mock the UnwindOption function
preserveOpt := func(opts *builder.UnwindOpts) {
opts.PreserveNullAndEmptyArrays = true
}
pipeline.Unwind(mockField, preserveOpt)
built := pipeline.Build()
assert.Len(t, built, 1)
expected := bson.D{{Key: string(builder.Unwind), Value: bson.D{
{Key: string(builder.MKPath), Value: "$array"},
{Key: string(builder.MKPreserveNullAndEmptyArrays), Value: true},
}}}
assert.Equal(t, expected, built[0])
}
func TestPipelineImp_Unwind_WithIncludeArrayIndex(t *testing.T) {
pipeline := NewPipelineImp()
mockField := &MockField{build: "$array"}
// Mock the UnwindOption function
indexOpt := func(opts *builder.UnwindOpts) {
opts.IncludeArrayIndex = "arrayIndex"
}
pipeline.Unwind(mockField, indexOpt)
built := pipeline.Build()
assert.Len(t, built, 1)
expected := bson.D{{Key: string(builder.Unwind), Value: bson.D{
{Key: string(builder.MKPath), Value: "$array"},
{Key: string(builder.MKIncludeArrayIndex), Value: "arrayIndex"},
}}}
assert.Equal(t, expected, built[0])
}
func TestPipelineImp_Unwind_WithBothOptions(t *testing.T) {
pipeline := NewPipelineImp()
mockField := &MockField{build: "$array"}
// Mock the UnwindOption functions
preserveOpt := func(opts *builder.UnwindOpts) {
opts.PreserveNullAndEmptyArrays = true
}
indexOpt := func(opts *builder.UnwindOpts) {
opts.IncludeArrayIndex = "arrayIndex"
}
pipeline.Unwind(mockField, preserveOpt, indexOpt)
built := pipeline.Build()
assert.Len(t, built, 1)
expected := bson.D{{Key: string(builder.Unwind), Value: bson.D{
{Key: string(builder.MKPath), Value: "$array"},
{Key: string(builder.MKPreserveNullAndEmptyArrays), Value: true},
{Key: string(builder.MKIncludeArrayIndex), Value: "arrayIndex"},
}}}
assert.Equal(t, expected, built[0])
}
func TestPipelineImp_Count(t *testing.T) {
pipeline := NewPipelineImp()
mockField := &MockField{build: "totalCount"}
result := pipeline.Count(mockField)
// Should return self for chaining
assert.Same(t, pipeline, result)
built := pipeline.Build()
assert.Len(t, built, 1)
expected := bson.D{{Key: string(builder.Count), Value: "totalCount"}}
assert.Equal(t, expected, built[0])
}
func TestPipelineImp_Group(t *testing.T) {
pipeline := NewPipelineImp()
mockAlias := &MockAlias{
build: bson.D{{Key: "_id", Value: "$field"}},
field: &MockField{build: "_id"},
}
mockAccumulator := &MockGroupAccumulator{
build: bson.D{{Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}},
}
result := pipeline.Group(mockAlias, mockAccumulator)
// Should return self for chaining
assert.Same(t, pipeline, result)
built := pipeline.Build()
assert.Len(t, built, 1)
expected := bson.D{{Key: string(builder.Group), Value: bson.D{
{Key: "_id", Value: "$field"},
{Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}},
}}}
assert.Equal(t, expected, built[0])
}
func TestPipelineImp_Group_MultipleAccumulators(t *testing.T) {
pipeline := NewPipelineImp()
mockAlias := &MockAlias{
build: bson.D{{Key: "_id", Value: "$field"}},
field: &MockField{build: "_id"},
}
mockAccumulator1 := &MockGroupAccumulator{
build: bson.D{{Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}},
}
mockAccumulator2 := &MockGroupAccumulator{
build: bson.D{{Key: "total", Value: bson.D{{Key: "$sum", Value: "$amount"}}}},
}
pipeline.Group(mockAlias, mockAccumulator1, mockAccumulator2)
built := pipeline.Build()
assert.Len(t, built, 1)
expected := bson.D{{Key: string(builder.Group), Value: bson.D{
{Key: "_id", Value: "$field"},
{Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}},
{Key: "total", Value: bson.D{{Key: "$sum", Value: "$amount"}}},
}}}
assert.Equal(t, expected, built[0])
}
func TestPipelineImp_Project(t *testing.T) {
pipeline := NewPipelineImp()
mockProjection := &MockProjection{
build: bson.D{{Key: "field1", Value: 1}},
}
result := pipeline.Project(mockProjection)
// Should return self for chaining
assert.Same(t, pipeline, result)
built := pipeline.Build()
assert.Len(t, built, 1)
expected := bson.D{{Key: string(builder.Project), Value: bson.D{
{Key: "field1", Value: 1},
}}}
assert.Equal(t, expected, built[0])
}
func TestPipelineImp_Project_MultipleProjections(t *testing.T) {
pipeline := NewPipelineImp()
mockProjection1 := &MockProjection{
build: bson.D{{Key: "field1", Value: 1}},
}
mockProjection2 := &MockProjection{
build: bson.D{{Key: "field2", Value: 0}},
}
pipeline.Project(mockProjection1, mockProjection2)
built := pipeline.Build()
assert.Len(t, built, 1)
expected := bson.D{{Key: string(builder.Project), Value: bson.D{
{Key: "field1", Value: 1},
{Key: "field2", Value: 0},
}}}
assert.Equal(t, expected, built[0])
}
func TestPipelineImp_ChainedOperations(t *testing.T) {
pipeline := NewPipelineImp()
// Create mocks
mockQuery := &MockQuery{
buildPipeline: bson.D{{Key: "$match", Value: bson.D{{Key: "status", Value: "active"}}}},
}
mockLocalField := &MockField{build: "userId"}
mockForeignField := &MockField{build: "_id"}
mockAsField := &MockField{build: "user"}
mockUnwindField := &MockField{build: "$user"}
mockProjection := &MockProjection{
build: bson.D{{Key: "name", Value: "$user.name"}},
}
// Chain operations
result := pipeline.
Match(mockQuery).
Lookup(mservice.Accounts, mockLocalField, mockForeignField, mockAsField).
Unwind(mockUnwindField).
Project(mockProjection)
// Should return self for chaining
assert.Same(t, pipeline, result)
built := pipeline.Build()
assert.Len(t, built, 4)
// Verify each stage
assert.Equal(t, bson.D{{Key: "$match", Value: bson.D{{Key: "status", Value: "active"}}}}, built[0])
expectedLookup := bson.D{{Key: string(builder.Lookup), Value: bson.D{
{Key: string(builder.MKFrom), Value: mservice.Accounts},
{Key: string(builder.MKLocalField), Value: "userId"},
{Key: string(builder.MKForeignField), Value: "_id"},
{Key: string(builder.MKAs), Value: "user"},
}}}
assert.Equal(t, expectedLookup, built[1])
assert.Equal(t, bson.D{{Key: string(builder.Unwind), Value: "$user"}}, built[2])
expectedProject := bson.D{{Key: string(builder.Project), Value: bson.D{
{Key: "name", Value: "$user.name"},
}}}
assert.Equal(t, expectedProject, built[3])
}
func TestNewUnwindOpts(t *testing.T) {
t.Run("NoOptions", func(t *testing.T) {
opts := NewUnwindOpts()
assert.NotNil(t, opts)
assert.False(t, opts.PreserveNullAndEmptyArrays)
assert.Empty(t, opts.IncludeArrayIndex)
})
t.Run("WithPreserveOption", func(t *testing.T) {
preserveOpt := func(opts *builder.UnwindOpts) {
opts.PreserveNullAndEmptyArrays = true
}
opts := NewUnwindOpts(preserveOpt)
assert.True(t, opts.PreserveNullAndEmptyArrays)
assert.Empty(t, opts.IncludeArrayIndex)
})
t.Run("WithIndexOption", func(t *testing.T) {
indexOpt := func(opts *builder.UnwindOpts) {
opts.IncludeArrayIndex = "index"
}
opts := NewUnwindOpts(indexOpt)
assert.False(t, opts.PreserveNullAndEmptyArrays)
assert.Equal(t, "index", opts.IncludeArrayIndex)
})
t.Run("WithBothOptions", func(t *testing.T) {
preserveOpt := func(opts *builder.UnwindOpts) {
opts.PreserveNullAndEmptyArrays = true
}
indexOpt := func(opts *builder.UnwindOpts) {
opts.IncludeArrayIndex = "index"
}
opts := NewUnwindOpts(preserveOpt, indexOpt)
assert.True(t, opts.PreserveNullAndEmptyArrays)
assert.Equal(t, "index", opts.IncludeArrayIndex)
})
}
// Mock implementations for testing
type MockQuery struct {
buildPipeline bson.D
}
func (m *MockQuery) And(filters ...builder.Query) builder.Query { return m }
func (m *MockQuery) Or(filters ...builder.Query) builder.Query { return m }
func (m *MockQuery) Filter(field builder.Field, value any) builder.Query { return m }
func (m *MockQuery) Expression(value builder.Expression) builder.Query { return m }
func (m *MockQuery) Comparison(field builder.Field, operator builder.MongoOperation, value any) builder.Query {
return m
}
func (m *MockQuery) RegEx(field builder.Field, pattern, options string) builder.Query { return m }
func (m *MockQuery) In(field builder.Field, values ...any) builder.Query { return m }
func (m *MockQuery) NotIn(field builder.Field, values ...any) builder.Query { return m }
func (m *MockQuery) Sort(field builder.Field, ascending bool) builder.Query { return m }
func (m *MockQuery) Limit(limit *int64) builder.Query { return m }
func (m *MockQuery) Offset(offset *int64) builder.Query { return m }
func (m *MockQuery) Archived(isArchived *bool) builder.Query { return m }
func (m *MockQuery) BuildPipeline() bson.D { return m.buildPipeline }
func (m *MockQuery) BuildQuery() bson.D { return bson.D{} }
func (m *MockQuery) BuildOptions() *options.FindOptions { return &options.FindOptions{} }
type MockField struct {
build string
}
func (m *MockField) Dot(field string) builder.Field { return &MockField{build: m.build + "." + field} }
func (m *MockField) CopyWith(field string) builder.Field { return &MockField{build: field} }
func (m *MockField) Build() string { return m.build }
type MockPipeline struct {
build mongo.Pipeline
}
func (m *MockPipeline) Match(filter builder.Query) builder.Pipeline { return m }
func (m *MockPipeline) Lookup(from mservice.Type, localField, foreignField, as builder.Field) builder.Pipeline {
return m
}
func (m *MockPipeline) LookupWithPipeline(from mservice.Type, pipeline builder.Pipeline, as builder.Field, let *map[string]builder.Field) builder.Pipeline {
return m
}
func (m *MockPipeline) Unwind(path builder.Field, opts ...UnwindOption) builder.Pipeline { return m }
func (m *MockPipeline) Count(field builder.Field) builder.Pipeline { return m }
func (m *MockPipeline) Group(groupBy builder.Alias, accumulators ...builder.GroupAccumulator) builder.Pipeline {
return m
}
func (m *MockPipeline) Project(projections ...builder.Projection) builder.Pipeline { return m }
func (m *MockPipeline) ReplaceRoot(newRoot builder.Expression) builder.Pipeline { return m }
func (m *MockPipeline) Build() mongo.Pipeline { return m.build }
type MockAlias struct {
build bson.D
field builder.Field
}
func (m *MockAlias) Field() builder.Field { return m.field }
func (m *MockAlias) Build() bson.D { return m.build }
type MockGroupAccumulator struct {
build bson.D
}
func (m *MockGroupAccumulator) Build() bson.D { return m.build }
type MockProjection struct {
build bson.D
}
func (m *MockProjection) Build() bson.D { return m.build }
func TestPipelineImp_ReplaceRoot(t *testing.T) {
pipeline := NewPipelineImp()
mockExpr := &MockExpression{build: "$newRoot"}
result := pipeline.ReplaceRoot(mockExpr)
// Should return self for chaining
assert.Same(t, pipeline, result)
built := pipeline.Build()
assert.Len(t, built, 1)
expected := bson.D{{Key: string(builder.ReplaceRoot), Value: bson.D{
{Key: string(builder.MKNewRoot), Value: "$newRoot"},
}}}
assert.Equal(t, expected, built[0])
}
func TestPipelineImp_ReplaceRoot_WithNestedField(t *testing.T) {
pipeline := NewPipelineImp()
mockExpr := &MockExpression{build: "$document.data"}
pipeline.ReplaceRoot(mockExpr)
built := pipeline.Build()
assert.Len(t, built, 1)
expected := bson.D{{Key: string(builder.ReplaceRoot), Value: bson.D{
{Key: string(builder.MKNewRoot), Value: "$document.data"},
}}}
assert.Equal(t, expected, built[0])
}
func TestPipelineImp_ReplaceRoot_WithExpression(t *testing.T) {
pipeline := NewPipelineImp()
// Mock a complex expression like { $mergeObjects: [...] }
mockExpr := &MockExpression{build: bson.D{{Key: "$mergeObjects", Value: bson.A{"$field1", "$field2"}}}}
pipeline.ReplaceRoot(mockExpr)
built := pipeline.Build()
assert.Len(t, built, 1)
expected := bson.D{{Key: string(builder.ReplaceRoot), Value: bson.D{
{Key: string(builder.MKNewRoot), Value: bson.D{{Key: "$mergeObjects", Value: bson.A{"$field1", "$field2"}}}},
}}}
assert.Equal(t, expected, built[0])
}
type MockExpression struct {
build any
}
func (m *MockExpression) Build() any { return m.build }

View File

@@ -0,0 +1,97 @@
package builderimp
import (
"github.com/tech/sendico/pkg/db/repository/builder"
"go.mongodb.org/mongo-driver/bson"
)
// projectionExprImp is a concrete implementation of builder.Projection
// that projects a field using a custom expression.
type projectionExprImp struct {
expr builder.Expression // The expression for this projection.
field builder.Field // The field name for the projected field.
}
// Field returns the field being projected.
func (p *projectionExprImp) Field() builder.Field {
return p.field
}
// Expression returns the expression for the projection.
func (p *projectionExprImp) Expression() builder.Expression {
return p.expr
}
// Build returns the built expression. If no expression is provided, returns 1.
func (p *projectionExprImp) Build() bson.D {
if p.expr == nil {
return bson.D{{Key: p.field.Build(), Value: 1}}
}
return bson.D{{Key: p.field.Build(), Value: p.expr.Build()}}
}
// NewProjectionExpr creates a new Projection for a given field and expression.
func NewProjectionExpr(field builder.Field, expr builder.Expression) builder.Projection {
return &projectionExprImp{field: field, expr: expr}
}
// aliasProjectionImp is a concrete implementation of builder.Projection
// that projects an alias (renaming a field or expression).
type aliasProjectionImp struct {
alias builder.Alias // The alias for this projection.
}
// Field returns the field being projected (via the alias).
func (p *aliasProjectionImp) Field() builder.Field {
return p.alias.Field()
}
// Expression returns no additional expression for an alias projection.
func (p *aliasProjectionImp) Expression() builder.Expression {
return nil
}
// Build returns the built alias expression.
func (p *aliasProjectionImp) Build() bson.D {
return p.alias.Build()
}
// NewAliasProjection creates a new Projection that renames or wraps an existing field or expression.
func NewAliasProjection(alias builder.Alias) builder.Projection {
return &aliasProjectionImp{alias: alias}
}
// sinkProjectionImp is a simple include/exclude projection (0 or 1).
type sinkProjectionImp struct {
field builder.Field // The field name for the projected field.
val int // 1 to include, 0 to exclude.
}
// Expression returns no expression for a sink projection.
func (p *sinkProjectionImp) Expression() builder.Expression {
return nil
}
// Build returns the include/exclude projection.
func (p *sinkProjectionImp) Build() bson.D {
return bson.D{{Key: p.field.Build(), Value: p.val}}
}
// NewSinkProjection creates a new Projection that includes (true) or excludes (false) a field.
func NewSinkProjection(field builder.Field, include bool) builder.Projection {
val := 0
if include {
val = 1
}
return &sinkProjectionImp{field: field, val: val}
}
// IncludeField returns a projection including the given field.
func IncludeField(field builder.Field) builder.Projection {
return NewSinkProjection(field, true)
}
// ExcludeField returns a projection excluding the given field.
func ExcludeField(field builder.Field) builder.Projection {
return NewSinkProjection(field, false)
}

View File

@@ -0,0 +1,156 @@
package builderimp
import (
"reflect"
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/db/storable"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
type QueryImp struct {
filter bson.D
sort bson.D
limit *int64
offset *int64
}
func (b *QueryImp) Filter(field builder.Field, value any) builder.Query {
b.filter = append(b.filter, bson.E{Key: field.Build(), Value: value})
return b
}
func (b *QueryImp) And(filters ...builder.Query) builder.Query {
andFilters := bson.A{}
for _, f := range filters {
andFilters = append(andFilters, f.BuildQuery())
}
b.filter = append(b.filter, bson.E{Key: string(builder.And), Value: andFilters})
return b
}
func (b *QueryImp) Or(filters ...builder.Query) builder.Query {
orFilters := bson.A{}
for _, f := range filters {
orFilters = append(orFilters, f.BuildQuery())
}
b.filter = append(b.filter, bson.E{Key: string(builder.Or), Value: orFilters})
return b
}
func (b *QueryImp) Comparison(field builder.Field, operator builder.MongoOperation, value any) builder.Query {
b.filter = append(b.filter, bson.E{Key: field.Build(), Value: bson.M{string(operator): value}})
return b
}
func (b *QueryImp) Expression(value builder.Expression) builder.Query {
b.filter = append(b.filter, bson.E{Key: string(builder.Expr), Value: value.Build()})
return b
}
func (b *QueryImp) RegEx(field builder.Field, pattern, options string) builder.Query {
b.filter = append(b.filter, bson.E{Key: field.Build(), Value: primitive.Regex{Pattern: pattern, Options: options}})
return b
}
func (b *QueryImp) opIn(field builder.Field, op builder.MongoOperation, values ...any) builder.Query {
var flattenedValues []any
for _, v := range values {
switch reflect.TypeOf(v).Kind() {
case reflect.Slice:
slice := reflect.ValueOf(v)
for i := range slice.Len() {
flattenedValues = append(flattenedValues, slice.Index(i).Interface())
}
default:
flattenedValues = append(flattenedValues, v)
}
}
b.filter = append(b.filter, bson.E{Key: field.Build(), Value: bson.M{string(op): flattenedValues}})
return b
}
func (b *QueryImp) NotIn(field builder.Field, values ...any) builder.Query {
return b.opIn(field, builder.NotIn, values...)
}
func (b *QueryImp) In(field builder.Field, values ...any) builder.Query {
return b.opIn(field, builder.In, values...)
}
func (b *QueryImp) Archived(isArchived *bool) builder.Query {
if isArchived == nil {
return b
}
return b.And(NewQueryImp().Filter(NewFieldImp(storable.IsArchivedField), *isArchived))
}
func (b *QueryImp) Sort(field builder.Field, ascending bool) builder.Query {
order := 1
if !ascending {
order = -1
}
b.sort = append(b.sort, bson.E{Key: field.Build(), Value: order})
return b
}
func (b *QueryImp) BuildPipeline() bson.D {
query := bson.D{}
if len(b.filter) > 0 {
query = append(query, bson.E{Key: string(builder.Match), Value: b.filter})
}
if len(b.sort) > 0 {
query = append(query, bson.E{Key: string(builder.Sort), Value: b.sort})
}
if b.limit != nil {
query = append(query, bson.E{Key: string(builder.Limit), Value: *b.limit})
}
if b.offset != nil {
query = append(query, bson.E{Key: string(builder.Skip), Value: *b.offset})
}
return query
}
func (b *QueryImp) BuildQuery() bson.D {
return b.filter
}
func (b *QueryImp) Limit(limit *int64) builder.Query {
b.limit = limit
return b
}
func (b *QueryImp) Offset(offset *int64) builder.Query {
b.offset = offset
return b
}
func (b *QueryImp) BuildOptions() *options.FindOptions {
opts := options.Find()
if b.limit != nil {
opts.SetLimit(*b.limit)
}
if b.offset != nil {
opts.SetSkip(*b.offset)
}
if len(b.sort) > 0 {
opts.SetSort(b.sort)
}
return opts
}
func NewQueryImp() builder.Query {
return &QueryImp{
filter: bson.D{},
sort: bson.D{},
}
}

View File

@@ -0,0 +1,17 @@
package builderimp
import (
"github.com/tech/sendico/pkg/db/repository/builder"
)
type valueImp struct {
value any
}
func (v *valueImp) Build() any {
return v.value
}
func NewValue(value any) builder.Value {
return &valueImp{value: value}
}

View File

@@ -0,0 +1,50 @@
package repositoryimp
import (
"context"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
func (r *MongoRepository) CreateIndex(def *ri.Definition) error {
if r.collection == nil {
return merrors.NoData("data collection is not set")
}
if len(def.Keys) == 0 {
return merrors.InvalidArgument("Index definition has no keys")
}
// ----- build BSON keys --------------------------------------------------
keys := bson.D{}
for _, k := range def.Keys {
var value any
switch {
case k.Type != "":
value = k.Type // text, 2dsphere, …
case k.Sort == ri.Desc:
value = int8(-1)
default:
value = int8(1) // default to Asc
}
keys = append(keys, bson.E{Key: k.Field, Value: value})
}
opts := options.Index().
SetUnique(def.Unique)
if def.TTL != nil {
opts.SetExpireAfterSeconds(*def.TTL)
}
if def.Name != "" {
opts.SetName(def.Name)
}
_, err := r.collection.Indexes().CreateOne(
context.Background(),
mongo.IndexModel{Keys: keys, Options: opts},
)
return err
}

View File

@@ -0,0 +1,250 @@
package repositoryimp
import (
"context"
"errors"
"fmt"
"github.com/tech/sendico/pkg/db/repository/builder"
rd "github.com/tech/sendico/pkg/db/repository/decoder"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"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"
)
type MongoRepository struct {
collectionName string
collection *mongo.Collection
}
func idFilter(id primitive.ObjectID) bson.D {
return bson.D{
{Key: storable.IDField, Value: id},
}
}
func NewMongoRepository(db *mongo.Database, collection string) *MongoRepository {
return &MongoRepository{
collectionName: collection,
collection: db.Collection(collection),
}
}
func (r *MongoRepository) Collection() string {
return r.collectionName
}
func (r *MongoRepository) Insert(ctx context.Context, obj storable.Storable, getFilter builder.Query) error {
if (obj.GetID() == nil) || (obj.GetID().IsZero()) {
obj.SetID(primitive.NewObjectID())
}
obj.Update()
_, err := r.collection.InsertOne(ctx, obj)
if mongo.IsDuplicateKeyError(err) {
if getFilter != nil {
if err = r.FindOneByFilter(ctx, getFilter, obj); err != nil {
return err
}
}
return merrors.DataConflict("duplicate_key")
}
return err
}
func (r *MongoRepository) InsertMany(ctx context.Context, objects []storable.Storable) error {
if len(objects) == 0 {
return nil
}
docs := make([]interface{}, len(objects))
for i, obj := range objects {
if (obj.GetID() == nil) || (obj.GetID().IsZero()) {
obj.SetID(primitive.NewObjectID())
}
obj.Update()
docs[i] = obj
}
_, err := r.collection.InsertMany(ctx, docs)
return err
}
func (r *MongoRepository) findOneByFilterImp(ctx context.Context, filter bson.D, errMessage string, result storable.Storable) error {
err := r.collection.FindOne(ctx, filter).Decode(result)
if errors.Is(err, mongo.ErrNoDocuments) {
return merrors.NoData(errMessage)
}
return err
}
func (r *MongoRepository) Get(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
if id.IsZero() {
return merrors.InvalidArgument("zero id provided while fetching " + result.Collection())
}
return r.findOneByFilterImp(ctx, idFilter(id), fmt.Sprintf("%s with ID = %s not found", result.Collection(), id.Hex()), result)
}
type QueryFunc func(ctx context.Context, collection *mongo.Collection) (*mongo.Cursor, error)
func (r *MongoRepository) executeQuery(ctx context.Context, queryFunc QueryFunc, decoder rd.DecodingFunc) error {
cursor, err := queryFunc(ctx, r.collection)
if errors.Is(err, mongo.ErrNoDocuments) {
return merrors.NoData("no_items_in_array")
}
if err != nil {
return err
}
defer cursor.Close(ctx)
for cursor.Next(ctx) {
if err = decoder(cursor); err != nil {
return err
}
}
return nil
}
func (r *MongoRepository) Aggregate(ctx context.Context, pipeline builder.Pipeline, decoder rd.DecodingFunc) error {
queryFunc := func(ctx context.Context, collection *mongo.Collection) (*mongo.Cursor, error) {
return collection.Aggregate(ctx, pipeline.Build())
}
return r.executeQuery(ctx, queryFunc, decoder)
}
func (r *MongoRepository) FindManyByFilter(ctx context.Context, query builder.Query, decoder rd.DecodingFunc) error {
queryFunc := func(ctx context.Context, collection *mongo.Collection) (*mongo.Cursor, error) {
return collection.Find(ctx, query.BuildQuery(), query.BuildOptions())
}
return r.executeQuery(ctx, queryFunc, decoder)
}
func (r *MongoRepository) FindOneByFilter(ctx context.Context, query builder.Query, result storable.Storable) error {
return r.findOneByFilterImp(ctx, query.BuildQuery(), result.Collection()+" not found by filter", result)
}
func (r *MongoRepository) Update(ctx context.Context, obj storable.Storable) error {
obj.Update()
return r.collection.FindOneAndReplace(ctx, idFilter(*obj.GetID()), obj).Err()
}
func (r *MongoRepository) Patch(ctx context.Context, id primitive.ObjectID, patch builder.Patch) error {
if id.IsZero() {
return merrors.InvalidArgument("zero id provided while patching")
}
_, err := r.collection.UpdateByID(ctx, id, patch.Build())
return err
}
func (r *MongoRepository) PatchMany(ctx context.Context, query builder.Query, patch builder.Patch) (int, error) {
result, err := r.collection.UpdateMany(ctx, query.BuildQuery(), patch.Build())
if err != nil {
return 0, err
}
return int(result.ModifiedCount), nil
}
func (r *MongoRepository) ListIDs(ctx context.Context, query builder.Query) ([]primitive.ObjectID, error) {
filter := query.BuildQuery()
findOptions := options.Find().SetProjection(bson.M{storable.IDField: 1})
cursor, err := r.collection.Find(ctx, filter, findOptions)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var ids []primitive.ObjectID
for cursor.Next(ctx) {
var doc struct {
ID primitive.ObjectID `bson:"_id"`
}
if err := cursor.Decode(&doc); err != nil {
return nil, err
}
ids = append(ids, doc.ID)
}
if err := cursor.Err(); err != nil {
return nil, err
}
return ids, nil
}
func (r *MongoRepository) ListPermissionBound(ctx context.Context, query builder.Query) ([]model.PermissionBoundStorable, error) {
filter := query.BuildQuery()
findOptions := options.Find().SetProjection(bson.M{
storable.IDField: 1,
storable.PermissionRefField: 1,
storable.OrganizationRefField: 1,
})
cursor, err := r.collection.Find(ctx, filter, findOptions)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
result := make([]model.PermissionBoundStorable, 0)
for cursor.Next(ctx) {
var doc model.PermissionBound
if err := cursor.Decode(&doc); err != nil {
return nil, err
}
result = append(result, &doc)
}
if err := cursor.Err(); err != nil {
return nil, err
}
return result, nil
}
func (r *MongoRepository) ListAccountBound(ctx context.Context, query builder.Query) ([]model.AccountBoundStorable, error) {
filter := query.BuildQuery()
findOptions := options.Find().SetProjection(bson.M{
storable.IDField: 1,
model.AccountRefField: 1,
model.OrganizationRefField: 1,
})
cursor, err := r.collection.Find(ctx, filter, findOptions)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
result := make([]model.AccountBoundStorable, 0)
for cursor.Next(ctx) {
var doc model.AccountBoundBase
if err := cursor.Decode(&doc); err != nil {
return nil, err
}
result = append(result, &doc)
}
if err := cursor.Err(); err != nil {
return nil, err
}
return result, nil
}
func (r *MongoRepository) Delete(ctx context.Context, id primitive.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, idFilter(id))
return err
}
func (r *MongoRepository) DeleteMany(ctx context.Context, query builder.Query) error {
_, err := r.collection.DeleteMany(ctx, query.BuildQuery())
return err
}
func (r *MongoRepository) Name() string {
return r.collection.Name()
}

View File

@@ -0,0 +1,577 @@
//go:build integration
// +build integration
package repositoryimp_test
import (
"context"
"errors"
"testing"
"time"
"github.com/tech/sendico/pkg/db/internal/mongo/repositoryimp"
"github.com/tech/sendico/pkg/db/internal/mongo/repositoryimp/builderimp"
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/merrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"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"
)
func TestMongoRepository_Insert(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(ctx, clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("Insert_WithoutID", func(t *testing.T) {
testObj := &TestObject{Name: "testInsert"}
// ID should be nil/zero initially
assert.True(t, testObj.GetID().IsZero())
err := repository.Insert(ctx, testObj, nil)
require.NoError(t, err)
// ID should be assigned after insert
assert.False(t, testObj.GetID().IsZero())
assert.NotEmpty(t, testObj.CreatedAt)
assert.NotEmpty(t, testObj.UpdatedAt)
})
t.Run("Insert_WithExistingID", func(t *testing.T) {
existingID := primitive.NewObjectID()
testObj := &TestObject{Name: "testInsertWithID"}
testObj.SetID(existingID)
err := repository.Insert(ctx, testObj, nil)
require.NoError(t, err)
// ID should remain the same
assert.Equal(t, existingID, *testObj.GetID())
})
t.Run("Insert_DuplicateKey", func(t *testing.T) {
// Insert first object
testObj1 := &TestObject{Name: "duplicate"}
err := repository.Insert(ctx, testObj1, nil)
require.NoError(t, err)
// Try to insert object with same ID
testObj2 := &TestObject{Name: "duplicate2"}
testObj2.SetID(*testObj1.GetID())
err = repository.Insert(ctx, testObj2, nil)
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrDataConflict))
})
t.Run("Insert_DuplicateKeyWithGetFilter", func(t *testing.T) {
// Insert first object
testObj1 := &TestObject{Name: "duplicateWithFilter"}
err := repository.Insert(ctx, testObj1, nil)
require.NoError(t, err)
// Try to insert object with same ID, but with getFilter
testObj2 := &TestObject{Name: "duplicateWithFilter2"}
testObj2.SetID(*testObj1.GetID())
getFilter := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("_id"), builder.Eq, *testObj1.GetID())
err = repository.Insert(ctx, testObj2, getFilter)
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrDataConflict))
// But testObj2 should be populated with the existing object data
assert.Equal(t, testObj1.Name, testObj2.Name)
})
}
func TestMongoRepository_Update(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(ctx, clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("Update_ExistingObject", func(t *testing.T) {
// Insert object first
testObj := &TestObject{Name: "originalName"}
err := repository.Insert(ctx, testObj, nil)
require.NoError(t, err)
originalUpdatedAt := testObj.UpdatedAt
// Update the object
testObj.Name = "updatedName"
time.Sleep(10 * time.Millisecond) // Ensure time difference
err = repository.Update(ctx, testObj)
require.NoError(t, err)
// Verify the object was updated
result := &TestObject{}
err = repository.Get(ctx, *testObj.GetID(), result)
require.NoError(t, err)
assert.Equal(t, "updatedName", result.Name)
assert.True(t, result.UpdatedAt.After(originalUpdatedAt))
})
t.Run("Update_NonExistentObject", func(t *testing.T) {
nonExistentID := primitive.NewObjectID()
testObj := &TestObject{Name: "nonExistent"}
testObj.SetID(nonExistentID)
err := repository.Update(ctx, testObj)
assert.Error(t, err)
assert.True(t, errors.Is(err, mongo.ErrNoDocuments))
})
}
func TestMongoRepository_Delete(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(ctx, clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("Delete_ExistingObject", func(t *testing.T) {
// Insert object first
testObj := &TestObject{Name: "toDelete"}
err := repository.Insert(ctx, testObj, nil)
require.NoError(t, err)
// Delete the object
err = repository.Delete(ctx, *testObj.GetID())
require.NoError(t, err)
// Verify the object was deleted
result := &TestObject{}
err = repository.Get(ctx, *testObj.GetID(), result)
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrNoData))
})
t.Run("Delete_NonExistentObject", func(t *testing.T) {
nonExistentID := primitive.NewObjectID()
err := repository.Delete(ctx, nonExistentID)
// Delete should not return error even if object doesn't exist
assert.NoError(t, err)
})
}
func TestMongoRepository_FindOneByFilter(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(ctx, clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("FindOneByFilter_MatchingFilter", func(t *testing.T) {
// Insert test objects
testObjs := []*TestObject{
{Name: "findMe"},
{Name: "dontFindMe"},
{Name: "findMeToo"},
}
for _, obj := range testObjs {
err := repository.Insert(ctx, obj, nil)
require.NoError(t, err)
}
// Find by filter
query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("name"), builder.Eq, "findMe")
result := &TestObject{}
err := repository.FindOneByFilter(ctx, query, result)
require.NoError(t, err)
assert.Equal(t, "findMe", result.Name)
})
t.Run("FindOneByFilter_NoMatch", func(t *testing.T) {
query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("name"), builder.Eq, "nonExistentName")
result := &TestObject{}
err := repository.FindOneByFilter(ctx, query, result)
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrNoData))
})
}
func TestMongoRepository_FindManyByFilter(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(ctx, clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("FindManyByFilter_MultipleResults", func(t *testing.T) {
// Insert test objects
testObjs := []*TestObject{
{Name: "findMany1"},
{Name: "findMany2"},
{Name: "dontFind"},
}
for _, obj := range testObjs {
err := repository.Insert(ctx, obj, nil)
require.NoError(t, err)
}
// Find objects with names starting with "findMany"
query := builderimp.NewQueryImp().RegEx(builderimp.NewFieldImp("name"), "^findMany", "")
var results []*TestObject
decoder := func(cursor *mongo.Cursor) error {
var obj TestObject
if err := cursor.Decode(&obj); err != nil {
return err
}
results = append(results, &obj)
return nil
}
err := repository.FindManyByFilter(ctx, query, decoder)
require.NoError(t, err)
assert.Len(t, results, 2)
names := make([]string, len(results))
for i, obj := range results {
names[i] = obj.Name
}
assert.Contains(t, names, "findMany1")
assert.Contains(t, names, "findMany2")
})
t.Run("FindManyByFilter_NoResults", func(t *testing.T) {
query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("name"), builder.Eq, "nonExistentPattern")
var results []*TestObject
decoder := func(cursor *mongo.Cursor) error {
var obj TestObject
if err := cursor.Decode(&obj); err != nil {
return err
}
results = append(results, &obj)
return nil
}
err := repository.FindManyByFilter(ctx, query, decoder)
require.NoError(t, err)
assert.Empty(t, results)
})
}
func TestMongoRepository_DeleteMany(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(ctx, clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("DeleteMany_MultipleDocuments", func(t *testing.T) {
// Insert test objects
testObjs := []*TestObject{
{Name: "deleteMany1"},
{Name: "deleteMany2"},
{Name: "keepMe"},
}
for _, obj := range testObjs {
err := repository.Insert(ctx, obj, nil)
require.NoError(t, err)
}
// Delete objects with names starting with "deleteMany"
query := builderimp.NewQueryImp().RegEx(builderimp.NewFieldImp("name"), "^deleteMany", "")
err := repository.DeleteMany(ctx, query)
require.NoError(t, err)
// Verify deletions
queryAll := builderimp.NewQueryImp()
var results []*TestObject
decoder := func(cursor *mongo.Cursor) error {
var obj TestObject
if err := cursor.Decode(&obj); err != nil {
return err
}
results = append(results, &obj)
return nil
}
err = repository.FindManyByFilter(ctx, queryAll, decoder)
require.NoError(t, err)
assert.Len(t, results, 1)
assert.Equal(t, "keepMe", results[0].Name)
})
}
func TestMongoRepository_Name(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(ctx, clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "mycollection")
t.Run("Name_ReturnsCollectionName", func(t *testing.T) {
name := repository.Name()
assert.Equal(t, "mycollection", name)
})
}
func TestMongoRepository_ListPermissionBound(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(ctx, clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("ListPermissionBound_WithData", func(t *testing.T) {
// Insert test objects with permission bound data
orgID := primitive.NewObjectID()
// Insert documents directly with permission bound fields
_, err := db.Collection("testcollection").InsertMany(ctx, []interface{}{
bson.M{
"_id": primitive.NewObjectID(),
"organizationRef": orgID,
"permissionRef": primitive.NewObjectID(),
},
bson.M{
"_id": primitive.NewObjectID(),
"organizationRef": orgID,
"permissionRef": primitive.NewObjectID(),
},
})
require.NoError(t, err)
// Query for permission bound objects
query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("organizationRef"), builder.Eq, orgID)
results, err := repository.ListPermissionBound(ctx, query)
require.NoError(t, err)
assert.Len(t, results, 2)
for _, result := range results {
assert.Equal(t, orgID, result.GetOrganizationRef())
assert.NotNil(t, result.GetPermissionRef())
}
})
t.Run("ListPermissionBound_EmptyResult", func(t *testing.T) {
nonExistentOrgID := primitive.NewObjectID()
query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("organizationRef"), builder.Eq, nonExistentOrgID)
results, err := repository.ListPermissionBound(ctx, query)
require.NoError(t, err)
assert.Empty(t, results)
})
}
func TestMongoRepository_UpdateTimestamp(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(ctx, clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("Update_Should_Update_Timestamp", func(t *testing.T) {
// Create test object
obj := &TestObject{
Name: "Test Object",
}
// Set ID and initial timestamps
obj.SetID(primitive.NewObjectID())
originalCreatedAt := obj.CreatedAt
originalUpdatedAt := obj.UpdatedAt
// Insert the object
err := repository.Insert(ctx, obj, nil)
require.NoError(t, err)
// Wait a moment to ensure timestamp difference
time.Sleep(10 * time.Millisecond)
// Update the object
obj.Name = "Updated Object"
err = repository.Update(ctx, obj)
require.NoError(t, err)
// Verify timestamps
assert.Equal(t, originalCreatedAt, obj.CreatedAt, "CreatedAt should not change")
assert.True(t, obj.UpdatedAt.After(originalUpdatedAt), "UpdatedAt should be updated")
// Verify the object was actually updated in the database
var retrieved TestObject
err = repository.Get(ctx, *obj.GetID(), &retrieved)
require.NoError(t, err)
assert.Equal(t, "Updated Object", retrieved.Name, "Name should be updated")
assert.WithinDuration(t, originalCreatedAt, retrieved.CreatedAt, time.Second, "CreatedAt should not change in DB")
assert.True(t, retrieved.UpdatedAt.After(originalUpdatedAt), "UpdatedAt should be updated in DB")
assert.WithinDuration(t, obj.UpdatedAt, retrieved.UpdatedAt, time.Second, "UpdatedAt should match between object and DB")
})
}

View File

@@ -0,0 +1,153 @@
//go:build integration
// +build integration
package repositoryimp_test
import (
"context"
"testing"
"time"
"github.com/tech/sendico/pkg/db/internal/mongo/repositoryimp"
"github.com/tech/sendico/pkg/db/storable"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
func TestMongoRepository_InsertMany(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(ctx, clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("InsertMany_Success", func(t *testing.T) {
objects := []storable.Storable{
&TestObject{Name: "test1"},
&TestObject{Name: "test2"},
&TestObject{Name: "test3"},
}
err := repository.InsertMany(ctx, objects)
require.NoError(t, err)
// Verify all objects were inserted and have IDs
for _, obj := range objects {
assert.NotNil(t, obj.GetID())
assert.False(t, obj.GetID().IsZero())
// Verify we can retrieve each object
result := &TestObject{}
err := repository.Get(ctx, *obj.GetID(), result)
require.NoError(t, err)
assert.Equal(t, obj.(*TestObject).Name, result.Name)
}
})
t.Run("InsertMany_EmptySlice", func(t *testing.T) {
objects := []storable.Storable{}
err := repository.InsertMany(ctx, objects)
require.NoError(t, err)
})
t.Run("InsertMany_WithExistingIDs", func(t *testing.T) {
id1 := primitive.NewObjectID()
id2 := primitive.NewObjectID()
objects := []storable.Storable{
&TestObject{Base: storable.Base{ID: id1}, Name: "preassigned1"},
&TestObject{Base: storable.Base{ID: id2}, Name: "preassigned2"},
}
err := repository.InsertMany(ctx, objects)
require.NoError(t, err)
// Verify objects were inserted with pre-assigned IDs
result1 := &TestObject{}
err = repository.Get(ctx, id1, result1)
require.NoError(t, err)
assert.Equal(t, "preassigned1", result1.Name)
result2 := &TestObject{}
err = repository.Get(ctx, id2, result2)
require.NoError(t, err)
assert.Equal(t, "preassigned2", result2.Name)
})
t.Run("InsertMany_MixedTypes", func(t *testing.T) {
objects := []storable.Storable{
&TestObject{Name: "test1"},
&AnotherObject{Description: "desc1"},
&TestObject{Name: "test2"},
}
err := repository.InsertMany(ctx, objects)
require.NoError(t, err)
// Verify all objects were inserted
for _, obj := range objects {
assert.NotNil(t, obj.GetID())
assert.False(t, obj.GetID().IsZero())
}
})
t.Run("InsertMany_DuplicateKey", func(t *testing.T) {
id := primitive.NewObjectID()
// Insert first object
obj1 := &TestObject{Base: storable.Base{ID: id}, Name: "original"}
err := repository.Insert(ctx, obj1, nil)
require.NoError(t, err)
// Try to insert multiple objects including one with duplicate ID
objects := []storable.Storable{
&TestObject{Name: "test1"},
&TestObject{Base: storable.Base{ID: id}, Name: "duplicate"},
}
err = repository.InsertMany(ctx, objects)
assert.Error(t, err)
assert.True(t, mongo.IsDuplicateKeyError(err))
})
t.Run("InsertMany_UpdateTimestamps", func(t *testing.T) {
objects := []storable.Storable{
&TestObject{Name: "test1"},
&TestObject{Name: "test2"},
}
err := repository.InsertMany(ctx, objects)
require.NoError(t, err)
// Verify timestamps were set
for _, obj := range objects {
testObj := obj.(*TestObject)
assert.NotZero(t, testObj.CreatedAt)
assert.NotZero(t, testObj.UpdatedAt)
}
})
}

View File

@@ -0,0 +1,233 @@
//go:build integration
// +build integration
package repositoryimp_test
import (
"context"
"testing"
"time"
"github.com/tech/sendico/pkg/db/internal/mongo/repositoryimp"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
func TestMongoRepository_PatchOperations(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(ctx, clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repo := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("Patch_SingleDocument", func(t *testing.T) {
obj := &TestObject{Name: "old"}
err := repo.Insert(ctx, obj, nil)
require.NoError(t, err)
original := obj.UpdatedAt
patch := repository.Patch().Set(repository.Field("name"), "new")
err = repo.Patch(ctx, *obj.GetID(), patch)
require.NoError(t, err)
var result TestObject
err = repo.Get(ctx, *obj.GetID(), &result)
require.NoError(t, err)
assert.Equal(t, "new", result.Name)
assert.True(t, result.UpdatedAt.After(original))
})
t.Run("PatchMany_MultipleDocuments", func(t *testing.T) {
objs := []*TestObject{{Name: "match"}, {Name: "match"}, {Name: "other"}}
for _, o := range objs {
err := repo.Insert(ctx, o, nil)
require.NoError(t, err)
}
query := repository.Query().Comparison(repository.Field("name"), builder.Eq, "match")
patch := repository.Patch().Set(repository.Field("name"), "patched")
modified, err := repo.PatchMany(ctx, query, patch)
require.NoError(t, err)
assert.Equal(t, 2, modified)
verify := repository.Query().Comparison(repository.Field("name"), builder.Eq, "patched")
var results []TestObject
decoder := func(cursor *mongo.Cursor) error {
var obj TestObject
if err := cursor.Decode(&obj); err != nil {
return err
}
results = append(results, obj)
return nil
}
err = repo.FindManyByFilter(ctx, verify, decoder)
require.NoError(t, err)
assert.Len(t, results, 2)
})
t.Run("Patch_PushArray", func(t *testing.T) {
obj := &TestObject{Name: "test", Tags: []string{"tag1"}}
err := repo.Insert(ctx, obj, nil)
require.NoError(t, err)
patch := repository.Patch().Push(repository.Field("tags"), "tag2")
err = repo.Patch(ctx, *obj.GetID(), patch)
require.NoError(t, err)
var result TestObject
err = repo.Get(ctx, *obj.GetID(), &result)
require.NoError(t, err)
assert.Equal(t, []string{"tag1", "tag2"}, result.Tags)
})
t.Run("Patch_PullArray", func(t *testing.T) {
obj := &TestObject{Name: "test", Tags: []string{"tag1", "tag2", "tag3"}}
err := repo.Insert(ctx, obj, nil)
require.NoError(t, err)
patch := repository.Patch().Pull(repository.Field("tags"), "tag2")
err = repo.Patch(ctx, *obj.GetID(), patch)
require.NoError(t, err)
var result TestObject
err = repo.Get(ctx, *obj.GetID(), &result)
require.NoError(t, err)
assert.Equal(t, []string{"tag1", "tag3"}, result.Tags)
})
t.Run("Patch_AddToSetArray", func(t *testing.T) {
obj := &TestObject{Name: "test", Tags: []string{"tag1"}}
err := repo.Insert(ctx, obj, nil)
require.NoError(t, err)
// Add new tag
patch := repository.Patch().AddToSet(repository.Field("tags"), "tag2")
err = repo.Patch(ctx, *obj.GetID(), patch)
require.NoError(t, err)
var result TestObject
err = repo.Get(ctx, *obj.GetID(), &result)
require.NoError(t, err)
assert.Equal(t, []string{"tag1", "tag2"}, result.Tags)
// Try to add duplicate tag - should not add
patch = repository.Patch().AddToSet(repository.Field("tags"), "tag1")
err = repo.Patch(ctx, *obj.GetID(), patch)
require.NoError(t, err)
err = repo.Get(ctx, *obj.GetID(), &result)
require.NoError(t, err)
assert.Equal(t, []string{"tag1", "tag2"}, result.Tags)
})
t.Run("Patch_PushToEmptyArray", func(t *testing.T) {
obj := &TestObject{Name: "test", Tags: []string{}}
err := repo.Insert(ctx, obj, nil)
require.NoError(t, err)
patch := repository.Patch().Push(repository.Field("tags"), "tag1")
err = repo.Patch(ctx, *obj.GetID(), patch)
require.NoError(t, err)
var result TestObject
err = repo.Get(ctx, *obj.GetID(), &result)
require.NoError(t, err)
assert.Equal(t, []string{"tag1"}, result.Tags)
})
t.Run("Patch_PullFromEmptyArray", func(t *testing.T) {
obj := &TestObject{Name: "test", Tags: []string{}}
err := repo.Insert(ctx, obj, nil)
require.NoError(t, err)
patch := repository.Patch().Pull(repository.Field("tags"), "nonexistent")
err = repo.Patch(ctx, *obj.GetID(), patch)
require.NoError(t, err)
var result TestObject
err = repo.Get(ctx, *obj.GetID(), &result)
require.NoError(t, err)
assert.Equal(t, []string{}, result.Tags)
})
t.Run("Patch_PullNonExistentElement", func(t *testing.T) {
obj := &TestObject{Name: "test", Tags: []string{"tag1", "tag2"}}
err := repo.Insert(ctx, obj, nil)
require.NoError(t, err)
patch := repository.Patch().Pull(repository.Field("tags"), "nonexistent")
err = repo.Patch(ctx, *obj.GetID(), patch)
require.NoError(t, err)
var result TestObject
err = repo.Get(ctx, *obj.GetID(), &result)
require.NoError(t, err)
assert.Equal(t, []string{"tag1", "tag2"}, result.Tags)
})
t.Run("Patch_ChainedArrayOperations", func(t *testing.T) {
obj := &TestObject{Name: "test", Tags: []string{"tag1"}}
err := repo.Insert(ctx, obj, nil)
require.NoError(t, err)
// Note: MongoDB doesn't allow multiple operations on the same array field in a single update
// This test demonstrates that chained array operations on the same field will fail
patch := repository.Patch().
Push(repository.Field("tags"), "tag2").
AddToSet(repository.Field("tags"), "tag3").
Pull(repository.Field("tags"), "tag1")
err = repo.Patch(ctx, *obj.GetID(), patch)
require.Error(t, err) // This should fail due to MongoDB's limitation
assert.Contains(t, err.Error(), "conflict")
})
t.Run("PatchMany_ArrayOperations", func(t *testing.T) {
objs := []*TestObject{
{Name: "obj1", Tags: []string{"tag1"}},
{Name: "obj2", Tags: []string{"tag2"}},
{Name: "obj3", Tags: []string{"tag3"}},
}
for _, o := range objs {
err := repo.Insert(ctx, o, nil)
require.NoError(t, err)
}
query := repository.Query().Comparison(repository.Field("name"), builder.In, []string{"obj1", "obj2"})
patch := repository.Patch().Push(repository.Field("tags"), "common")
modified, err := repo.PatchMany(ctx, query, patch)
require.NoError(t, err)
assert.Equal(t, 2, modified)
// Verify the changes
for _, name := range []string{"obj1", "obj2"} {
var result TestObject
err = repo.FindOneByFilter(ctx, repository.Query().Comparison(repository.Field("name"), builder.Eq, name), &result)
require.NoError(t, err)
assert.Contains(t, result.Tags, "common")
}
})
}

View File

@@ -0,0 +1,188 @@
//go:build integration
// +build integration
package repositoryimp_test
import (
"context"
"errors"
"testing"
"time"
"github.com/tech/sendico/pkg/db/internal/mongo/repositoryimp"
"github.com/tech/sendico/pkg/db/internal/mongo/repositoryimp/builderimp"
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
type TestObject struct {
storable.Base `bson:",inline" json:",inline"`
Name string `bson:"name"`
Tags []string `bson:"tags"`
}
func (t *TestObject) Collection() string {
return "testObject"
}
type AnotherObject struct {
storable.Base `bson:",inline" json:",inline"`
Description string `bson:"description"`
}
func (a *AnotherObject) Collection() string {
return "anotherObject"
}
func terminate(ctx context.Context, t *testing.T, container *mongodb.MongoDBContainer) {
err := container.Terminate(ctx)
require.NoError(t, err, "failed to terminate MongoDB container")
}
func disconnect(ctx context.Context, t *testing.T, client *mongo.Client) {
err := client.Disconnect(ctx)
require.NoError(t, err, "failed to disconnect from MongoDB")
}
func TestMongoRepository_Get(t *testing.T) {
// Use a context with timeout, so if container spinning or DB ops hang,
// the test won't run indefinitely.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(ctx, clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("Get_Success", func(t *testing.T) {
testObj := &TestObject{Name: "testName"}
err := repository.Insert(ctx, testObj, nil)
require.NoError(t, err)
result := &TestObject{}
err = repository.Get(ctx, testObj.ID, result)
require.NoError(t, err)
assert.Equal(t, testObj.Name, result.Name)
assert.Equal(t, testObj.ID, result.ID)
})
t.Run("Get_NotFound", func(t *testing.T) {
nonExistentID := primitive.NewObjectID()
result := &TestObject{}
err := repository.Get(ctx, nonExistentID, result)
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrNoData))
})
t.Run("Get_InvalidID", func(t *testing.T) {
invalidID := primitive.ObjectID{} // zero value
result := &TestObject{}
err := repository.Get(ctx, invalidID, result)
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("Get_DifferentTypes", func(t *testing.T) {
anotherObj := &AnotherObject{Description: "testDescription"}
err := repository.Insert(ctx, anotherObj, nil)
require.NoError(t, err)
result := &AnotherObject{}
err = repository.Get(ctx, anotherObj.ID, result)
require.NoError(t, err)
assert.Equal(t, anotherObj.Description, result.Description)
assert.Equal(t, anotherObj.ID, result.ID)
})
}
func TestMongoRepository_ListIDs(t *testing.T) {
// Use a context with timeout, so if container spinning or DB ops hang,
// the test won't run indefinitely.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(ctx, clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("ListIDs_Success", func(t *testing.T) {
// Insert test data
testObjs := []*TestObject{
{Name: "testName1"},
{Name: "testName2"},
{Name: "testName3"},
}
for _, obj := range testObjs {
err := repository.Insert(ctx, obj, nil)
require.NoError(t, err)
}
// Define a query to match all objects
query := builderimp.NewQueryImp()
// Call ListIDs
ids, err := repository.ListIDs(ctx, query)
require.NoError(t, err)
// Assert the IDs are correct
require.Len(t, ids, len(testObjs))
for _, obj := range testObjs {
assert.Contains(t, ids, obj.ID)
}
})
t.Run("ListIDs_EmptyResult", func(t *testing.T) {
// Define a query that matches no objects
query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("name"), builder.Eq, "nonExistentName")
// Call ListIDs
ids, err := repository.ListIDs(ctx, query)
require.NoError(t, err)
// Assert no IDs are returned
assert.Empty(t, ids)
})
}