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

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

View File

@@ -0,0 +1,78 @@
package repository
import (
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// AccountBoundFilter provides factory methods for creating account-bound filters
type AccountBoundFilter struct{}
// NewAccountBoundFilter creates a new AccountBoundFilter instance
func NewAccountBoundFilter() *AccountBoundFilter {
return &AccountBoundFilter{}
}
// WithoutOrg creates a filter for account-bound objects without organization filter
// This filter finds objects where:
// - accountRef matches the provided accountRef, OR
// - accountRef is nil/null, OR
// - accountRef field doesn't exist
func (f *AccountBoundFilter) WithoutOrg(accountRef primitive.ObjectID) builder.Query {
return Query().Or(
AccountFilter(accountRef),
Filter(model.AccountRefField, nil),
Exists(AccountField(), false),
)
}
// WithOrg creates a filter for account-bound objects with organization filter
// This filter finds objects where:
// - accountRef matches the provided accountRef, OR
// - accountRef is nil/null, OR
// - accountRef field doesn't exist
// AND combines with organization filter
func (f *AccountBoundFilter) WithOrg(accountRef, organizationRef primitive.ObjectID) builder.Query {
return Query().And(
OrgFilter(organizationRef),
f.WithoutOrg(accountRef),
)
}
// WithQuery creates a filter for account-bound objects with additional query and organization filter
func (f *AccountBoundFilter) WithQuery(accountRef, organizationRef primitive.ObjectID, additionalQuery builder.Query) builder.Query {
accountQuery := f.WithOrg(accountRef, organizationRef)
return additionalQuery.And(accountQuery)
}
// WithQueryNoOrg creates a filter for account-bound objects with additional query but no org filter
func (f *AccountBoundFilter) WithQueryNoOrg(accountRef primitive.ObjectID, additionalQuery builder.Query) builder.Query {
accountQuery := f.WithoutOrg(accountRef)
return additionalQuery.And(accountQuery)
}
// Global instance for convenience
var DefaultAccountBoundFilter = NewAccountBoundFilter()
// Convenience functions that use the global factory instance
// WithOrg is a convenience function that uses the default factory
func WithOrg(accountRef, organizationRef primitive.ObjectID) builder.Query {
return DefaultAccountBoundFilter.WithOrg(accountRef, organizationRef)
}
// WithoutOrg is a convenience function that uses the default factory
func WithoutOrg(accountRef primitive.ObjectID) builder.Query {
return DefaultAccountBoundFilter.WithoutOrg(accountRef)
}
// WithQuery is a convenience function that uses the default factory
func WithQuery(accountRef, organizationRef primitive.ObjectID, additionalQuery builder.Query) builder.Query {
return DefaultAccountBoundFilter.WithQuery(accountRef, organizationRef, additionalQuery)
}
// WithQueryNoOrg is a convenience function that uses the default factory
func WithQueryNoOrg(accountRef primitive.ObjectID, additionalQuery builder.Query) builder.Query {
return DefaultAccountBoundFilter.WithQueryNoOrg(accountRef, additionalQuery)
}

View File

@@ -0,0 +1,11 @@
package builder
import "go.mongodb.org/mongo-driver/bson"
type Accumulator interface {
Build() bson.D
}
type GroupAccumulator interface {
Build() bson.D
}

View File

@@ -0,0 +1,8 @@
package builder
import "go.mongodb.org/mongo-driver/bson"
type Alias interface {
Field() Field
Build() bson.D
}

View File

@@ -0,0 +1,7 @@
package builder
import "go.mongodb.org/mongo-driver/bson"
type Array interface {
Build() bson.A
}

View File

@@ -0,0 +1,5 @@
package builder
type Expression interface {
Build() any
}

View File

@@ -0,0 +1,7 @@
package builder
type Field interface {
Dot(field string) Field
CopyWith(field string) Field
Build() string
}

View File

@@ -0,0 +1,16 @@
package builder
type MongoKeyword string
const (
MKAs MongoKeyword = "as"
MKForeignField MongoKeyword = "foreignField"
MKFrom MongoKeyword = "from"
MKIncludeArrayIndex MongoKeyword = "includeArrayIndex"
MKLet MongoKeyword = "let"
MKLocalField MongoKeyword = "localField"
MKPath MongoKeyword = "path"
MKPipeline MongoKeyword = "pipeline"
MKPreserveNullAndEmptyArrays MongoKeyword = "preserveNullAndEmptyArrays"
MKNewRoot MongoKeyword = "newRoot"
)

View File

@@ -0,0 +1,57 @@
package builder
type MongoOperation string
const (
// Comparison operators
Gt MongoOperation = "$gt"
Lt MongoOperation = "$lt"
Gte MongoOperation = "$gte"
Lte MongoOperation = "$lte"
Eq MongoOperation = "$eq"
Ne MongoOperation = "$ne"
In MongoOperation = "$in"
NotIn MongoOperation = "$nin"
Exists MongoOperation = "$exists"
// Logical operators
And MongoOperation = "$and"
Or MongoOperation = "$or"
Not MongoOperation = "$not"
AddToSet MongoOperation = "$addToSet"
Avg MongoOperation = "$avg"
Pull MongoOperation = "$pull"
Count MongoOperation = "$count"
Cond MongoOperation = "$cond"
Each MongoOperation = "$each"
Expr MongoOperation = "$expr"
First MongoOperation = "$first"
Group MongoOperation = "$group"
IfNull MongoOperation = "$ifNull"
Limit MongoOperation = "$limit"
Literal MongoOperation = "$literal"
Lookup MongoOperation = "$lookup"
Match MongoOperation = "$match"
Max MongoOperation = "$max"
Min MongoOperation = "$min"
Push MongoOperation = "$push"
Project MongoOperation = "$project"
Set MongoOperation = "$set"
Inc MongoOperation = "$inc"
Unset MongoOperation = "$unset"
Rename MongoOperation = "$rename"
ReplaceRoot MongoOperation = "$replaceRoot"
SetUnion MongoOperation = "$setUnion"
Size MongoOperation = "$size"
Sort MongoOperation = "$sort"
Skip MongoOperation = "$skip"
Sum MongoOperation = "$sum"
Type MongoOperation = "$type"
Unwind MongoOperation = "$unwind"
Add MongoOperation = "$add"
Subtract MongoOperation = "$subtract"
Multiply MongoOperation = "$multiply"
Divide MongoOperation = "$divide"
)

View File

@@ -0,0 +1,16 @@
package builder
import "go.mongodb.org/mongo-driver/bson"
// Patch defines operations for constructing partial update documents.
// Each builder method returns the same Patch instance to allow chaining.
type Patch interface {
Set(field Field, value any) Patch
Inc(field Field, value any) Patch
Unset(field Field) Patch
Rename(field Field, newName string) Patch
Push(field Field, value any) Patch
Pull(field Field, value any) Patch
AddToSet(field Field, value any) Patch
Build() bson.D
}

View File

@@ -0,0 +1,24 @@
package builder
import (
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/mongo"
)
type Pipeline interface {
Match(filter Query) Pipeline
Lookup(from mservice.Type, localField, foreignField, as Field) Pipeline
LookupWithPipeline(
from mservice.Type,
pipeline Pipeline, // your nested pipeline
as Field,
let *map[string]Field, // optional e.g. {"projRef": Field("$_id")}
) Pipeline
// unwind with functional options
Unwind(path Field, opts ...UnwindOption) Pipeline
Count(field Field) Pipeline
Group(groupBy Alias, accumulators ...GroupAccumulator) Pipeline
Project(projections ...Projection) Pipeline
ReplaceRoot(newRoot Expression) Pipeline
Build() mongo.Pipeline
}

View File

@@ -0,0 +1,7 @@
package builder
import "go.mongodb.org/mongo-driver/bson"
type Projection interface {
Build() bson.D
}

View File

@@ -0,0 +1,24 @@
package builder
import (
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
type Query interface {
Filter(field Field, value any) Query
And(filters ...Query) Query
Or(filters ...Query) Query
Expression(value Expression) Query
Comparison(field Field, operator MongoOperation, value any) Query
RegEx(field Field, pattern, options string) Query
In(field Field, values ...any) Query
NotIn(field Field, values ...any) Query
Sort(field Field, ascending bool) Query
Limit(limit *int64) Query
Offset(offset *int64) Query
Archived(isArchived *bool) Query
BuildPipeline() bson.D
BuildQuery() bson.D
BuildOptions() *options.FindOptions
}

View File

@@ -0,0 +1,23 @@
package builder
// UnwindOption is a functional option for configuring the $unwind stage.
type UnwindOption func(*UnwindOpts)
type UnwindOpts struct {
PreserveNullAndEmptyArrays bool
IncludeArrayIndex string
}
// WithPreserveNullAndEmptyArrays tells $unwind to keep docs where the array is null/empty.
func WithPreserveNullAndEmptyArrays() UnwindOption {
return func(o *UnwindOpts) {
o.PreserveNullAndEmptyArrays = true
}
}
// WithIncludeArrayIndex adds an arrayindex field named idxField to each unwound doc.
func WithIncludeArrayIndex(idxField string) UnwindOption {
return func(o *UnwindOpts) {
o.IncludeArrayIndex = idxField
}
}

View File

@@ -0,0 +1,5 @@
package builder
type Value interface {
Build() any
}

View File

@@ -0,0 +1,273 @@
package repository
import (
"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/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func Query() builder.Query {
return builderimp.NewQueryImp()
}
func Filter(field string, value any) builder.Query {
return Query().Filter(Field(field), value)
}
func Field(baseName string) builder.Field {
return builderimp.NewFieldImp(baseName)
}
func Ref(field builder.Field) builder.Field {
return builderimp.NewRefFieldImp(field)
}
func RootRef() builder.Field {
return builderimp.NewRootRef()
}
func RemoveRef() builder.Field {
return builderimp.NewRemoveRef()
}
func Pipeline() builder.Pipeline {
return builderimp.NewPipelineImp()
}
func IDField() builder.Field {
return Field(storable.IDField)
}
func NameField() builder.Field {
return Field(model.NameField)
}
func DescrtiptionField() builder.Field {
return Field(model.DescriptionField)
}
func IsArchivedField() builder.Field {
return Field(storable.IsArchivedField)
}
func IDFilter(ref primitive.ObjectID) builder.Query {
return Query().Filter(IDField(), ref)
}
func ArchivedFilter() builder.Query {
return IsArchivedFilter(true)
}
func NotArchivedFilter() builder.Query {
return IsArchivedFilter(false)
}
func IsArchivedFilter(isArchived bool) builder.Query {
return Query().Filter(IsArchivedField(), isArchived)
}
func OrgField() builder.Field {
return Field(storable.OrganizationRefField)
}
func OrgFilter(ref primitive.ObjectID) builder.Query {
return Query().Filter(OrgField(), ref)
}
func ProjectField() builder.Field {
return Field("projectRef")
}
func ProjectFilter(ref primitive.ObjectID) builder.Query {
return Query().Filter(ProjectField(), ref)
}
func AccountField() builder.Field {
return Field(model.AccountRefField)
}
func AccountFilter(ref primitive.ObjectID) builder.Query {
return Query().Filter(AccountField(), ref)
}
func StatusRefField() builder.Field {
return Field("statusRef")
}
func StatusRefFilter(ref primitive.ObjectID) builder.Query {
return Query().Filter(StatusRefField(), ref)
}
func PriorityRefField() builder.Field {
return Field("priorityRef")
}
func PriorityRefFilter(ref primitive.ObjectID) builder.Query {
return Query().Filter(PriorityRefField(), ref)
}
func IndexField() builder.Field {
return Field("index")
}
func IndexFilter(index int) builder.Query {
return Query().Filter(IndexField(), index)
}
func TagRefsField() builder.Field {
return Field(model.TagRefsField)
}
func IndexOpFilter(index int, operation builder.MongoOperation) builder.Query {
return Query().Comparison(IndexField(), operation, index)
}
func Patch() builder.Patch {
return builderimp.NewPatchImp()
}
func Accumulator(operator builder.MongoOperation, value any) builder.Accumulator {
return builderimp.NewAccumulator(operator, value)
}
func GroupAccumulator(field builder.Field, acc builder.Accumulator) builder.GroupAccumulator {
return builderimp.NewGroupAccumulator(field, acc)
}
func Literal(value any) builder.Expression {
return builderimp.NewLiteralExpression(value)
}
func Projection(alias builder.Alias) builder.Projection {
return builderimp.NewAliasProjection(alias)
}
func IncludeField(field builder.Field) builder.Projection {
return builderimp.IncludeField(field)
}
func ExcludeField(field builder.Field) builder.Projection {
return builderimp.ExcludeField(field)
}
func ProjectionExpr(field builder.Field, expr builder.Expression) builder.Projection {
return builderimp.NewProjectionExpr(field, expr)
}
func NullAlias(lhs builder.Field) builder.Alias {
return builderimp.NewNullAlias(lhs)
}
func SimpleAlias(lhs, rhs builder.Field) builder.Alias {
return builderimp.NewSimpleAlias(lhs, rhs)
}
func ComplexAlias(lhs builder.Field, rhs []builder.Alias) builder.Alias {
return builderimp.NewComplexAlias(lhs, rhs)
}
func Aliases(aliases ...builder.Alias) builder.Alias {
return builderimp.NewAliases(aliases...)
}
func AddToSet(value builder.Expression) builder.Expression {
return builderimp.AddToSet(value)
}
func Size(value builder.Expression) builder.Expression {
return builderimp.Size(value)
}
func InRef(value builder.Field) builder.Expression {
return builderimp.InRef(value)
}
func In(values ...any) builder.Expression {
return builderimp.In(values)
}
func Cond(condition builder.Expression, ifTrue, ifFalse any) builder.Expression {
return builderimp.NewCond(condition, ifTrue, ifFalse)
}
func And(exprs ...builder.Expression) builder.Expression {
return builderimp.NewAnd(exprs...)
}
func Or(exprs ...builder.Expression) builder.Expression {
return builderimp.NewOr(exprs...)
}
func Type(expr builder.Expression) builder.Expression {
return builderimp.NewType(expr)
}
func Not(expression builder.Expression) builder.Expression {
return builderimp.NewNot(expression)
}
func Sum(expression builder.Expression) builder.Expression {
return builderimp.NewSum(expression)
}
func Assign(field builder.Field, expression builder.Expression) builder.Projection {
return builderimp.NewAssignment(field, expression)
}
func SetUnion(exprs ...builder.Expression) builder.Expression {
return builderimp.NewSetUnion(exprs...)
}
func Eq(left, right builder.Expression) builder.Expression {
return builderimp.Eq(left, right)
}
func Gt(left, right builder.Expression) builder.Expression {
return builderimp.Gt(left, right)
}
func Lt(left, right builder.Expression) builder.Expression {
return builderimp.NewLt(left, right)
}
func Array(expressions ...builder.Expression) builder.Array {
return builderimp.NewArray(expressions...)
}
func IfNull(cond, replacement builder.Expression) builder.Expression {
return builderimp.NewIfNull(cond, replacement)
}
func Each(exprs ...builder.Expression) builder.Expression {
return builderimp.NewEach(exprs...)
}
func Push(expression builder.Expression) builder.Expression {
return builderimp.NewPush(expression)
}
func Min(expression builder.Expression) builder.Expression {
return builderimp.NewMin(expression)
}
func Ne(left, right builder.Expression) builder.Expression {
return builderimp.Ne(left, right)
}
func Compute(field builder.Field, expression builder.Expression) builder.Expression {
return builderimp.NewCompute(field, expression)
}
func First(expr builder.Expression) builder.Expression {
return builderimp.First(expr)
}
func Value(value any) builder.Value {
return builderimp.NewValue(value)
}
func Exists(field builder.Field, exists bool) builder.Query {
return Query().Comparison(field, builder.Exists, exists)
}

View File

@@ -0,0 +1,19 @@
package repository
import (
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/model"
)
// ApplyCursor adds pagination and archival filters to the provided query.
func ApplyCursor(query builder.Query, cursor *model.ViewCursor) builder.Query {
if cursor == nil {
return query
}
query = query.Limit(cursor.Limit)
query = query.Offset(cursor.Offset)
query = query.Archived(cursor.IsArchived)
return query
}

View File

@@ -0,0 +1,5 @@
package repository
import "go.mongodb.org/mongo-driver/mongo"
type DecodingFunc = func(r *mongo.Cursor) error

View File

@@ -0,0 +1,93 @@
package repository
import (
"testing"
"github.com/stretchr/testify/assert"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func TestAccountBoundFilter_WithOrg(t *testing.T) {
factory := NewAccountBoundFilter()
accountRef := primitive.NewObjectID()
orgRef := primitive.NewObjectID()
query := factory.WithOrg(accountRef, orgRef)
// Test that the query is not nil
assert.NotNil(t, query)
}
func TestAccountBoundFilter_WithoutOrg(t *testing.T) {
factory := NewAccountBoundFilter()
accountRef := primitive.NewObjectID()
query := factory.WithoutOrg(accountRef)
// Test that the query is not nil
assert.NotNil(t, query)
}
func TestAccountBoundFilter_WithQuery(t *testing.T) {
factory := NewAccountBoundFilter()
accountRef := primitive.NewObjectID()
orgRef := primitive.NewObjectID()
additionalQuery := Query().Filter(Field("status"), "active")
query := factory.WithQuery(accountRef, orgRef, additionalQuery)
// Test that the query is not nil
assert.NotNil(t, query)
}
func TestAccountBoundFilter_WithQueryNoOrg(t *testing.T) {
factory := NewAccountBoundFilter()
accountRef := primitive.NewObjectID()
additionalQuery := Query().Filter(Field("status"), "active")
query := factory.WithQueryNoOrg(accountRef, additionalQuery)
// Test that the query is not nil
assert.NotNil(t, query)
}
func TestDefaultAccountBoundFilter(t *testing.T) {
// Test that the default factory is not nil
assert.NotNil(t, DefaultAccountBoundFilter)
// Test that it's the correct type
assert.IsType(t, &AccountBoundFilter{}, DefaultAccountBoundFilter)
}
func TestConvenienceFunctions(t *testing.T) {
accountRef := primitive.NewObjectID()
orgRef := primitive.NewObjectID()
additionalQuery := Query().Filter(Field("status"), "active")
// Test convenience functions
query1 := WithOrg(accountRef, orgRef)
assert.NotNil(t, query1)
query2 := WithoutOrg(accountRef)
assert.NotNil(t, query2)
query3 := WithQuery(accountRef, orgRef, additionalQuery)
assert.NotNil(t, query3)
query4 := WithQueryNoOrg(accountRef, additionalQuery)
assert.NotNil(t, query4)
}
func TestFilterFactoryConsistency(t *testing.T) {
factory := NewAccountBoundFilter()
accountRef := primitive.NewObjectID()
orgRef := primitive.NewObjectID()
// Test that factory methods and convenience functions produce the same result
query1 := factory.WithOrg(accountRef, orgRef)
query2 := WithOrg(accountRef, orgRef)
// Both should be valid queries
assert.NotNil(t, query1)
assert.NotNil(t, query2)
}

View File

@@ -0,0 +1,21 @@
package repository
type Sort int8
const (
Asc Sort = 1
Desc Sort = -1
)
type Key struct {
Field string
Sort Sort // 1 or -1. 0 means “use Type”.
Type IndexType // optional: "text", "2dsphere", ...
}
type Definition struct {
Keys []Key // mandatory, at least one element
Unique bool // unique constraint?
TTL *int32 // seconds; nil means “no TTL”
Name string // optional explicit name
}

View File

@@ -0,0 +1,36 @@
package repository
// IndexType represents a supported MongoDB index type.
type IndexType string
const (
// IndexTypeNotSet is a default index type
IndexTypeNotSet IndexType = ""
// IndexTypeSingleField is a single-field index.
IndexTypeSingleField IndexType = "single"
// IndexTypeCompound is a compound index on multiple fields.
IndexTypeCompound IndexType = "compound"
// IndexTypeMultikey is an index on array fields (created automatically when needed).
IndexTypeMultikey IndexType = "multikey"
// IndexTypeText is a text index for full-text search.
IndexTypeText IndexType = "text"
// IndexTypeGeo2D is a legacy 2D geospatial index for planar geometry.
IndexTypeGeo2D IndexType = "2d"
// IndexTypeGeo2DSphere is a 2dsphere geospatial index for GeoJSON data.
IndexTypeGeo2DSphere IndexType = "2dsphere"
// IndexTypeHashed is a hashed index for sharding and efficient equality queries.
IndexTypeHashed IndexType = "hashed"
// IndexTypeWildcard is a wildcard index to index all fields or subpaths.
IndexTypeWildcard IndexType = "wildcard"
// IndexTypeClustered is a clustered index that orders the collection on the index key.
IndexTypeClustered IndexType = "clustered"
)

View File

@@ -0,0 +1,46 @@
package repository
import (
"context"
"github.com/tech/sendico/pkg/db/internal/mongo/repositoryimp"
"github.com/tech/sendico/pkg/db/repository/builder"
rd "github.com/tech/sendico/pkg/db/repository/decoder"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
)
type (
// FilterQuery selects documents to operate on.
FilterQuery = builder.Query
// PatchDoc defines field/value modifications for partial updates.
PatchDoc = builder.Patch
)
type Repository interface {
Aggregate(ctx context.Context, builder builder.Pipeline, decoder rd.DecodingFunc) error
Insert(ctx context.Context, obj storable.Storable, getFilter builder.Query) error
InsertMany(ctx context.Context, objects []storable.Storable) error
Get(ctx context.Context, id primitive.ObjectID, result storable.Storable) error
FindOneByFilter(ctx context.Context, builder builder.Query, result storable.Storable) error
FindManyByFilter(ctx context.Context, builder builder.Query, decoder rd.DecodingFunc) error
Update(ctx context.Context, obj storable.Storable) error
// Patch applies partial updates defined by patch to the document identified by id.
Patch(ctx context.Context, id primitive.ObjectID, patch PatchDoc) error
// PatchMany applies partial updates defined by patch to all documents matching filter and returns the number of updated documents.
PatchMany(ctx context.Context, filter FilterQuery, patch PatchDoc) (int, error)
Delete(ctx context.Context, id primitive.ObjectID) error
DeleteMany(ctx context.Context, query builder.Query) error
CreateIndex(def *ri.Definition) error
ListIDs(ctx context.Context, query builder.Query) ([]primitive.ObjectID, error)
ListPermissionBound(ctx context.Context, query builder.Query) ([]model.PermissionBoundStorable, error)
ListAccountBound(ctx context.Context, query builder.Query) ([]model.AccountBoundStorable, error)
Collection() string
}
func CreateMongoRepository(db *mongo.Database, collection string) Repository {
return repositoryimp.NewMongoRepository(db, collection)
}