commit 2dd1fb91c7201fafaa4491f72b46c93df9f8988c Author: Anton Zadvorny Date: Sun Jan 7 08:40:41 2024 +0300 Extract leaf package diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..aa3c86b --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,52 @@ +run: + timeout: 5m + output: + format: tab + +linters-settings: + govet: + check-shadowing: true + golint: + min-confidence: 0.1 + maligned: + suggest-new: true + goconst: + min-len: 2 + min-occurrences: 2 + misspell: + locale: US + lll: + line-length: 140 + gocritic: + enabled-tags: + - performance + - style + - experimental + disabled-checks: + - unnamedResult + - paramTypeCombine + +linters: + enable: + - megacheck + - revive + - govet + - unconvert + - megacheck + - unused + - gas + - gocyclo + - dupl + - misspell + - unparam + - typecheck + - ineffassign + - stylecheck + - gochecknoinits + - exportloopref + - gocritic + - nakedret + - gosimple + - prealloc + fast: false + disable-all: true diff --git a/document.go b/document.go new file mode 100644 index 0000000..4409d63 --- /dev/null +++ b/document.go @@ -0,0 +1,37 @@ +package leaf + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type Document interface { + ID() primitive.ObjectID + SetID(id primitive.ObjectID) + + SetCreatedAt(ts time.Time) + SetUpdatedAt(ts time.Time) +} + +type Base struct { + ObjectID primitive.ObjectID `json:"id,omitempty" bson:"_id,omitempty" example:"507f1f77bcf86cd799439011"` + CreatedAt time.Time `json:"created_at" bson:"created_at" format:"dateTime" example:"2020-01-01T00:00:00Z"` + UpdatedAt time.Time `json:"updated_at" bson:"updated_at" format:"dateTime" example:"2020-01-01T00:00:00Z"` +} + +func (s *Base) ID() primitive.ObjectID { + return s.ObjectID +} + +func (s *Base) SetID(id primitive.ObjectID) { + s.ObjectID = id +} + +func (s *Base) SetCreatedAt(ts time.Time) { + s.CreatedAt = ts +} + +func (s *Base) SetUpdatedAt(ts time.Time) { + s.UpdatedAt = ts +} diff --git a/document_test.go b/document_test.go new file mode 100644 index 0000000..3b4519c --- /dev/null +++ b/document_test.go @@ -0,0 +1,51 @@ +package leaf + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func TestBaseInitialization(t *testing.T) { + id := primitive.NewObjectID() + now := time.Now() + + base := Base{} + base.SetID(id) + base.SetCreatedAt(now) + base.SetUpdatedAt(now) + + assert.Equal(t, id, base.ID()) + assert.Equal(t, id, base.ObjectID) + assert.True(t, base.CreatedAt.Equal(now)) + assert.True(t, base.UpdatedAt.Equal(now)) +} + +func TestBaseJSONMarshalling(t *testing.T) { + base := Base{} + base.SetID(primitive.NewObjectID()) + base.SetCreatedAt(time.Now()) + base.SetUpdatedAt(time.Now()) + + data, err := json.Marshal(base) + assert.NoError(t, err) + assert.True(t, json.Valid(data)) +} + +func TestBaseBSONMarshalling(t *testing.T) { + base := Base{} + base.SetID(primitive.NewObjectID()) + base.SetCreatedAt(time.Now()) + base.SetUpdatedAt(time.Now()) + + data, err := bson.Marshal(base) + assert.NoError(t, err) + + var m bson.M + err = bson.Unmarshal(data, &m) + assert.NoError(t, err) +} diff --git a/filter.go b/filter.go new file mode 100644 index 0000000..b030fa6 --- /dev/null +++ b/filter.go @@ -0,0 +1,84 @@ +package leaf + +var ( + FilterAll = FilterDocument{} +) + +const ( + OperatorEq Operator = "$eq" + OperatorNe Operator = "$ne" + OperatorGt Operator = "$gt" + OperatorGte Operator = "$gte" + OperatorLt Operator = "$lt" + OperatorLte Operator = "$lte" + OperatorIn Operator = "$in" + OperatorNin Operator = "$nin" + OperatorExists Operator = "$exists" + OperatorRegex Operator = "$regex" +) + +type M map[Operator]any +type Operator string +type FilterDocument map[string]M + +func Eq[T any](field string, value T) FilterDocument { + return createSelector(field, OperatorEq, value) +} + +func Ne[T any](field string, value T) FilterDocument { + return createSelector(field, OperatorNe, value) +} + +func Gt[T any](field string, value T) FilterDocument { + return createSelector(field, OperatorGt, value) +} + +func Gte[T any](field string, value T) FilterDocument { + return createSelector(field, OperatorGte, value) +} + +func Lt[T any](field string, value T) FilterDocument { + return createSelector(field, OperatorLt, value) +} + +func Lte[T any](field string, value T) FilterDocument { + return createSelector(field, OperatorLte, value) +} + +func In[T any](field string, value []T) FilterDocument { + return createSelector(field, OperatorIn, value) +} + +func Nin[T any](field string, value []T) FilterDocument { + return createSelector(field, OperatorNin, value) +} + +func Exists(field string, value bool) FilterDocument { + return createSelector(field, OperatorExists, value) +} + +func Regex(field string, value string) FilterDocument { + return createSelector(field, OperatorRegex, value) +} + +func createSelector(field string, operator Operator, value any) FilterDocument { + return FilterDocument{field: M{operator: value}} +} + +func Filter(docs ...FilterDocument) FilterDocument { + query := FilterDocument{} + for _, doc := range docs { + for field, operators := range doc { + if _, ok := query[field]; !ok { + query[field] = operators + continue + } + + for op, value := range operators { + query[field][op] = value + } + } + } + + return query +} diff --git a/filter_test.go b/filter_test.go new file mode 100644 index 0000000..4de96f2 --- /dev/null +++ b/filter_test.go @@ -0,0 +1,56 @@ +package leaf + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFilter(t *testing.T) { + t.Run("merge two filter documents", func(t *testing.T) { + doc1 := Filter( + Gt("field1", 1), + Lt("field2", 10), + ) + + doc2 := Filter( + Ne("field1", 2), + Eq("field3", "test"), + ) + + expected := FilterDocument{ + "field1": {"$gt": 1, "$ne": 2}, + "field2": {"$lt": 10}, + "field3": {"$eq": "test"}, + } + + assert.Equal(t, expected, Filter(doc1, doc2)) + }) + + t.Run("merge three filter documents", func(t *testing.T) { + doc1 := Filter( + Gt("field1", 1), + Lt("field2", 10), + ) + + doc2 := Filter( + Ne("field1", 2), + Eq("field3", "test"), + ) + + doc3 := Filter( + Gt("field1", 5), + Ne("field2", 5), + Eq("field4", "hello"), + ) + + expected := FilterDocument{ + "field1": {"$gt": 5, "$ne": 2}, + "field2": {"$lt": 10, "$ne": 5}, + "field3": {"$eq": "test"}, + "field4": {"$eq": "hello"}, + } + + assert.Equal(t, expected, Filter(doc1, doc2, doc3)) + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a97c8b7 --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module go.pkg.cx/leaf + +go 1.21 + +require ( + github.com/rs/zerolog v1.31.0 + github.com/stretchr/testify v1.8.4 + go.mongodb.org/mongo-driver v1.13.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/golang/snappy v0.0.1 // indirect + github.com/klauspost/compress v1.13.6 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect + golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d // indirect + golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect + golang.org/x/sys v0.12.0 // indirect + golang.org/x/text v0.7.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..da95aed --- /dev/null +++ b/go.sum @@ -0,0 +1,77 @@ +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A= +github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.13.1 h1:YIc7HTYsKndGK4RFzJ3covLz1byri52x0IoMB0Pt/vk= +go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/index.go b/index.go new file mode 100644 index 0000000..0553bc3 --- /dev/null +++ b/index.go @@ -0,0 +1,98 @@ +package leaf + +import ( + "context" + + "github.com/rs/zerolog" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + driver "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func ensureIndexes(ctx context.Context, collection *driver.Collection, models []driver.IndexModel, createIndexes bool, logger *zerolog.Logger) error { + existing, err := indexNames(ctx, collection) + if err != nil { + return err + } + + indexes := filterIndexes(models, existing, logger) + if len(indexes) < 1 { + return nil + } + + if !createIndexes { + for _, im := range indexes { + logger.Warn().Str("name", *im.Options.Name).Msg("index declared but not present") + } + + return nil + } + + _, err = collection.Indexes().CreateMany(ctx, indexes) + if err != nil { + return err + } + + for _, im := range indexes { + logger.Info().Str("name", *im.Options.Name).Msg("index created") + } + + return nil +} + +func indexNames(ctx context.Context, collection *driver.Collection) (map[string]struct{}, error) { + cursor, err := collection.Indexes().List(ctx) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + names := make(map[string]struct{}) + for cursor.Next(ctx) { + var idx bson.M + if err := cursor.Decode(&idx); err != nil { + continue + } + + name, ok := idx["name"].(string) + if !ok { + continue + } + + names[name] = struct{}{} + } + + return names, nil +} + +func filterIndexes(models []driver.IndexModel, names map[string]struct{}, logger *zerolog.Logger) []driver.IndexModel { + var filtered []driver.IndexModel + for _, im := range models { + if im.Options.Name == nil { + logger.Warn().Interface("keys", im.Keys).Msg("must specify index name explicitly") + continue + } + + if _, ok := names[*im.Options.Name]; !ok { + filtered = append(filtered, im) + } + } + + return filtered +} + +func withTimestampIndexes(models []driver.IndexModel) []driver.IndexModel { + tsModels := []driver.IndexModel{ + { + Keys: bson.D{primitive.E{Key: "created_at", Value: 1}}, + Options: options.Index().SetName("created_at_1"), + }, + { + Keys: bson.D{primitive.E{Key: "updated_at", Value: 1}}, + Options: options.Index().SetName("updated_at_1"), + }, + } + + return append(models, tsModels...) +} diff --git a/objectid.go b/objectid.go new file mode 100644 index 0000000..8d9cbd7 --- /dev/null +++ b/objectid.go @@ -0,0 +1,23 @@ +package leaf + +import ( + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func ObjectIDFromHex(hex string) primitive.ObjectID { + id, err := primitive.ObjectIDFromHex(hex) + if err != nil { + return primitive.NilObjectID + } + + return id +} + +func ObjectIDFromHexSlice(hexSlice []string) []primitive.ObjectID { + ids := make([]primitive.ObjectID, len(hexSlice)) + for i := range hexSlice { + ids[i] = ObjectIDFromHex(hexSlice[i]) + } + + return ids +} diff --git a/objectid_test.go b/objectid_test.go new file mode 100644 index 0000000..9213f10 --- /dev/null +++ b/objectid_test.go @@ -0,0 +1,40 @@ +package leaf + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func TestObjectIDFromHex(t *testing.T) { + t.Run("valid hex", func(t *testing.T) { + hex := "507f1f77bcf86cd799439011" + id, err := primitive.ObjectIDFromHex(hex) + + assert.NoError(t, err) + assert.Equal(t, id, ObjectIDFromHex(hex)) + }) + + t.Run("invalid hex", func(t *testing.T) { + hex := "invalidhex" + assert.Equal(t, primitive.NilObjectID, ObjectIDFromHex(hex)) + }) +} + +func TestObjectIDFromHexSlice(t *testing.T) { + t.Run("valid hex slice", func(t *testing.T) { + hex := []string{"507f1f77bcf86cd799439011", "507f1f77bcf86cd799439012"} + ids := make([]primitive.ObjectID, len(hex)) + for i := range hex { + ids[i], _ = primitive.ObjectIDFromHex(hex[i]) + } + + assert.Equal(t, ids, ObjectIDFromHexSlice(hex)) + }) + + t.Run("invalid hex slice", func(t *testing.T) { + hex := []string{"invalidhex", "invalidhex"} + assert.Equal(t, []primitive.ObjectID{primitive.NilObjectID, primitive.NilObjectID}, ObjectIDFromHexSlice(hex)) + }) +} diff --git a/repository.go b/repository.go new file mode 100644 index 0000000..cd9400a --- /dev/null +++ b/repository.go @@ -0,0 +1,118 @@ +package leaf + +import ( + "context" + "fmt" + "time" + + "github.com/rs/zerolog" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + driver "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type RepositoryInterface[T Document] interface { + Find(ctx context.Context, filter interface{}, opts ...*options.FindOptions) ([]T, error) + FindOne(ctx context.Context, filter interface{}, opts ...*options.FindOneOptions) (T, error) + FindByID(ctx context.Context, id string, opts ...*options.FindOneOptions) (T, error) + + Create(ctx context.Context, data T, opts ...*options.InsertOneOptions) (T, error) + + Update(ctx context.Context, data T, opts ...*options.UpdateOptions) (T, error) + UpdateOne(ctx context.Context, filter interface{}, data T, opts ...*options.UpdateOptions) (T, error) + + Delete(ctx context.Context, data T, opts ...*options.DeleteOptions) error + DeleteOne(ctx context.Context, filter interface{}, opts ...*options.DeleteOptions) error + + CountDocuments(ctx context.Context, filter interface{}, opts ...*options.CountOptions) (int, error) +} + +type Repository[T Document] struct { + collection *driver.Collection + indexes []driver.IndexModel +} + +func NewRepository[T Document](collection *driver.Collection, indexes []driver.IndexModel) *Repository[T] { + return &Repository[T]{collection: collection, indexes: withTimestampIndexes(indexes)} +} + +func (s *Repository[T]) Find(ctx context.Context, filter interface{}, opts ...*options.FindOptions) ([]T, error) { + cursor, err := s.collection.Find(ctx, filter, opts...) + if err != nil { + return nil, repositoryError(s.collection, "Find", err) + } + defer cursor.Close(ctx) + + var data []T + if err := cursor.All(ctx, &data); err != nil { + return nil, repositoryError(s.collection, "Find", err) + } + + return data, nil +} + +func (s *Repository[T]) FindOne(ctx context.Context, filter interface{}, opts ...*options.FindOneOptions) (T, error) { + var data T + if err := s.collection.FindOne(ctx, filter, opts...).Decode(&data); err != nil { + return data, repositoryError(s.collection, "FindOne", err) + } + + return data, nil +} + +func (s *Repository[T]) FindByID(ctx context.Context, id string, opts ...*options.FindOneOptions) (T, error) { + return s.FindOne(ctx, bson.M{"_id": ObjectIDFromHex(id)}, opts...) +} + +func (s *Repository[T]) Create(ctx context.Context, data T, opts ...*options.InsertOneOptions) (T, error) { + data.SetCreatedAt(time.Now().UTC()) + data.SetUpdatedAt(time.Now().UTC()) + + res, err := s.collection.InsertOne(ctx, data, opts...) + if err != nil { + return data, repositoryError(s.collection, "Create", err) + } + + data.SetID(res.InsertedID.(primitive.ObjectID)) + return data, nil +} + +func (s *Repository[T]) Update(ctx context.Context, data T, opts ...*options.UpdateOptions) (T, error) { + return s.UpdateOne(ctx, bson.M{"_id": data.ID()}, data, opts...) +} + +func (s *Repository[T]) UpdateOne(ctx context.Context, filter interface{}, data T, opts ...*options.UpdateOptions) (T, error) { + data.SetUpdatedAt(time.Now().UTC()) + + if _, err := s.collection.UpdateOne(ctx, filter, bson.M{"$set": data}, opts...); err != nil { + return data, repositoryError(s.collection, "UpdateOne", err) + } + + return data, nil +} + +func (s *Repository[T]) Delete(ctx context.Context, data T, opts ...*options.DeleteOptions) error { + return s.DeleteOne(ctx, bson.M{"_id": data.ID()}, opts...) +} + +func (s *Repository[T]) DeleteOne(ctx context.Context, filter interface{}, opts ...*options.DeleteOptions) error { + if _, err := s.collection.DeleteOne(ctx, filter, opts...); err != nil { + return repositoryError(s.collection, "DeleteOne", err) + } + + return nil +} + +func (s *Repository[T]) CountDocuments(ctx context.Context, filter interface{}, opts ...*options.CountOptions) (int, error) { + count, err := s.collection.CountDocuments(ctx, filter, opts...) + return int(count), err +} + +func (s *Repository[T]) EnsureIndexes(ctx context.Context, createIndexes bool, logger *zerolog.Logger) error { + return ensureIndexes(ctx, s.collection, s.indexes, createIndexes, logger) +} + +func repositoryError(collection *driver.Collection, op string, err error) error { + return fmt.Errorf("%s.%s: %v", collection.Name(), op, err) +}