service backend
This commit is contained in:
@@ -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}}
|
||||
}
|
||||
102
api/pkg/db/internal/mongo/repositoryimp/builderimp/alias.go
Normal file
102
api/pkg/db/internal/mongo/repositoryimp/builderimp/alias.go
Normal 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}
|
||||
}
|
||||
27
api/pkg/db/internal/mongo/repositoryimp/builderimp/array.go
Normal file
27
api/pkg/db/internal/mongo/repositoryimp/builderimp/array.go
Normal 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 sub‐expression
|
||||
arr[i] = expr.Build()
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
// NewArray constructs a new array expression from the given sub‐expressions.
|
||||
func NewArray(exprs ...builder.Expression) *arrayImp {
|
||||
return &arrayImp{elements: exprs}
|
||||
}
|
||||
108
api/pkg/db/internal/mongo/repositoryimp/builderimp/expression.go
Normal file
108
api/pkg/db/internal/mongo/repositoryimp/builderimp/expression.go
Normal 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}
|
||||
}
|
||||
71
api/pkg/db/internal/mongo/repositoryimp/builderimp/field.go
Normal file
71
api/pkg/db/internal/mongo/repositoryimp/builderimp/field.go
Normal 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")
|
||||
}
|
||||
137
api/pkg/db/internal/mongo/repositoryimp/builderimp/func.go
Normal file
137
api/pkg/db/internal/mongo/repositoryimp/builderimp/func.go
Normal 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)
|
||||
}
|
||||
@@ -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(),
|
||||
}}
|
||||
}
|
||||
60
api/pkg/db/internal/mongo/repositoryimp/builderimp/patch.go
Normal file
60
api/pkg/db/internal/mongo/repositoryimp/builderimp/patch.go
Normal 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{}}
|
||||
}
|
||||
131
api/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline.go
Normal file
131
api/pkg/db/internal/mongo/repositoryimp/builderimp/pipeline.go
Normal 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{},
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
156
api/pkg/db/internal/mongo/repositoryimp/builderimp/query.go
Normal file
156
api/pkg/db/internal/mongo/repositoryimp/builderimp/query.go
Normal 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{},
|
||||
}
|
||||
}
|
||||
17
api/pkg/db/internal/mongo/repositoryimp/builderimp/value.go
Normal file
17
api/pkg/db/internal/mongo/repositoryimp/builderimp/value.go
Normal 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}
|
||||
}
|
||||
50
api/pkg/db/internal/mongo/repositoryimp/index.go
Normal file
50
api/pkg/db/internal/mongo/repositoryimp/index.go
Normal 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
|
||||
}
|
||||
250
api/pkg/db/internal/mongo/repositoryimp/repository.go
Normal file
250
api/pkg/db/internal/mongo/repositoryimp/repository.go
Normal 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()
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
233
api/pkg/db/internal/mongo/repositoryimp/repository_patch_test.go
Normal file
233
api/pkg/db/internal/mongo/repositoryimp/repository_patch_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
188
api/pkg/db/internal/mongo/repositoryimp/repository_test.go
Normal file
188
api/pkg/db/internal/mongo/repositoryimp/repository_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user