├── document.go ├── driver_test.go ├── go.mod ├── structs.go ├── x └── tests │ └── new_document.go ├── query_test.go ├── go.sum ├── query.go ├── driver.go ├── output.txt ├── bingo_test.go ├── README.md └── collection.go /document.go: -------------------------------------------------------------------------------- 1 | package bingo 2 | 3 | type Document struct { 4 | ID string `json:"_id" bingo_json:"_id"` 5 | } 6 | 7 | func (d Document) Key() []byte { 8 | return []byte(d.ID) 9 | } 10 | 11 | func (d Document) WithId(id string) Document { 12 | d.ID = id 13 | return d 14 | } 15 | -------------------------------------------------------------------------------- /driver_test.go: -------------------------------------------------------------------------------- 1 | package bingo 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestIsErrDocumentExists(t *testing.T) { 8 | type args struct { 9 | err error 10 | } 11 | tests := []struct { 12 | name string 13 | args args 14 | want bool 15 | }{ 16 | {"TestIsErrDocumentExists", args{ErrDocumentExists}, true}, 17 | {"TestIsErrDocumentExists", args{ErrDocumentNotFound}, false}, 18 | } 19 | for _, tt := range tests { 20 | t.Run(tt.name, func(t *testing.T) { 21 | if got := IsErrDocumentExists(tt.args.err); got != tt.want { 22 | t.Errorf("IsErrDocumentExists() = %v, want %v", got, tt.want) 23 | } 24 | }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nokusukun/bingo 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/go-playground/validator/v10 v10.15.5 7 | github.com/json-iterator/go v1.1.12 8 | github.com/stretchr/testify v1.8.2 9 | go.etcd.io/bbolt v1.3.7 10 | ) 11 | 12 | require ( 13 | github.com/bwmarrin/snowflake v0.3.0 // indirect 14 | github.com/davecgh/go-spew v1.1.1 // indirect 15 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 16 | github.com/go-playground/locales v0.14.1 // indirect 17 | github.com/go-playground/universal-translator v0.18.1 // indirect 18 | github.com/leodido/go-urn v1.2.4 // indirect 19 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect 20 | github.com/modern-go/reflect2 v1.0.2 // indirect 21 | github.com/pmezard/go-difflib v1.0.0 // indirect 22 | golang.org/x/crypto v0.7.0 // indirect 23 | golang.org/x/net v0.8.0 // indirect 24 | golang.org/x/sys v0.6.0 // indirect 25 | golang.org/x/text v0.8.0 // indirect 26 | gopkg.in/yaml.v3 v3.0.1 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /structs.go: -------------------------------------------------------------------------------- 1 | package bingo 2 | 3 | import ( 4 | "reflect" 5 | "slices" 6 | "strings" 7 | ) 8 | 9 | type CustomMarshaller struct{} 10 | 11 | func (c CustomMarshaller) Marshal(v interface{}) ([]byte, error) { 12 | return MarshalIgnoreTags(v) 13 | } 14 | 15 | func getVariantStructValue(v reflect.Value, t reflect.Type) reflect.Value { 16 | sf := make([]reflect.StructField, 0) 17 | for i := 0; i < t.NumField(); i++ { 18 | sf = append(sf, t.Field(i)) 19 | 20 | if t.Field(i).Tag.Get("json") != "" { 21 | sf[i].Tag = `` 22 | } 23 | } 24 | newType := reflect.StructOf(sf) 25 | return v.Convert(newType) 26 | } 27 | 28 | func MarshalIgnoreTags(obj interface{}) ([]byte, error) { 29 | value := reflect.ValueOf(obj) 30 | t := value.Type() 31 | newValue := getVariantStructValue(value, t) 32 | return json.Marshal(newValue.Interface()) 33 | } 34 | 35 | func GetIndexesFromStruct(v interface{}) map[string]any { 36 | indexes := map[string]any{} 37 | t := reflect.TypeOf(v) 38 | val := reflect.ValueOf(v) 39 | for i := 0; i < t.NumField(); i++ { 40 | field := t.Field(i) 41 | if tag := field.Tag.Get("bingo"); tag == "" { 42 | continue 43 | } 44 | properties := strings.Split(field.Tag.Get("bingo"), ",") 45 | if slices.Contains(properties, "index") { 46 | if val.Field(i).IsValid() { 47 | indexes[field.Name] = val.Field(i).Interface() 48 | } 49 | } 50 | } 51 | return indexes 52 | } 53 | -------------------------------------------------------------------------------- /x/tests/new_document.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/nokusukun/bingo" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | type Platform string 11 | 12 | const ( 13 | Excel = "excel" 14 | Sheets = "sheets" 15 | ) 16 | 17 | type Function struct { 18 | bingo.Document 19 | Name string `json:"name,omitempty"` 20 | Category string `json:"category,omitempty"` 21 | Args []string `json:"args,omitempty"` 22 | Example string `json:"example,omitempty"` 23 | Description string `json:"description,omitempty"` 24 | URL string `json:"URL,omitempty"` 25 | Platform Platform `json:"platform,omitempty"` 26 | } 27 | 28 | func main() { 29 | driver, err := bingo.NewDriver(bingo.DriverConfiguration{ 30 | Filename: "clippy.db", 31 | }) 32 | if err != nil { 33 | panic(err) 34 | } 35 | 36 | defer func() { 37 | os.Remove("clippy.db") 38 | }() 39 | 40 | functionDB := bingo.CollectionFrom[Function](driver, "functions") 41 | 42 | // Registering a custom ID generator, 43 | // this will be called when a new document is inserted 44 | // and the if the document does not have a key/id set. 45 | functionDB.OnNewId = func(_ int, doc *Function) []byte { 46 | return []byte(strings.ToLower(doc.Name)) 47 | } 48 | 49 | // Inserting 50 | key, err := functionDB.Insert(Function{ 51 | Name: "SUM", 52 | Category: "Math", 53 | Args: []string{"a", "b"}, 54 | Example: "SUM(1, 2)", 55 | Description: "Adds two numbers together", 56 | URL: "https://support.google.com/docs/answer/3093669?hl=en", 57 | Platform: Sheets, 58 | }) 59 | if err != nil { 60 | panic(err) 61 | } 62 | 63 | fmt.Println("Inserted document with key", string(key)) 64 | 65 | searchQuery := "sum" 66 | platform := "sheets" 67 | // Querying 68 | query := functionDB.Query(bingo.Query[Function]{ 69 | Filter: func(doc Function) bool { 70 | return doc.Platform == Platform(platform) && strings.Contains(strings.ToLower(doc.Name), strings.ToLower(searchQuery)) 71 | }, 72 | Count: 3, 73 | }) 74 | if query.Error != nil { 75 | panic(query.Error) 76 | } 77 | 78 | if !query.Any() { 79 | panic("No documents found!") 80 | } 81 | 82 | fmt.Println("Found", query.Count(), "documents") 83 | for _, function := range query.Items { 84 | fmt.Printf("%s: %s\n", function.Name, function.Description) 85 | } 86 | 87 | sum, err := functionDB.FindByKey("sum") 88 | if err != nil { 89 | panic(err) 90 | } 91 | sum.Category = "Algebra" 92 | err = functionDB.UpdateOne(sum) 93 | if err != nil { 94 | panic(err) 95 | } 96 | 97 | newSum, _ := functionDB.FindByBytesKey(sum.Key()) 98 | fmt.Println("Updated SUM category to", newSum.Category) 99 | fmt.Println(newSum) 100 | } 101 | -------------------------------------------------------------------------------- /query_test.go: -------------------------------------------------------------------------------- 1 | package bingo 2 | 3 | import ( 4 | "errors" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | type MockDocument struct { 10 | ID string 11 | Name string 12 | } 13 | 14 | func (md MockDocument) Key() []byte { 15 | return []byte(md.ID) 16 | } 17 | 18 | func newMockDocument(id, name string) MockDocument { 19 | return MockDocument{ 20 | ID: id, 21 | Name: name, 22 | } 23 | } 24 | 25 | func TestQueryExecution(t *testing.T) { 26 | // Test setup 27 | collection := &Collection[MockDocument]{} 28 | 29 | t.Run("Query panic on both Key and Filter set", func(t *testing.T) { 30 | assert.Panics(t, func() { 31 | collection.Query(Query[MockDocument]{Keys: [][]byte{[]byte("1")}, Filter: func(doc MockDocument) bool { return true }}) 32 | }) 33 | }) 34 | 35 | t.Run("Query returns error on no Key or Filter", func(t *testing.T) { 36 | result := collection.Query(Query[MockDocument]{}) 37 | assert.Error(t, result.Error) 38 | }) 39 | 40 | // Add other tests based on the different logic paths in the `Query` method. 41 | } 42 | 43 | func TestQueryResultFunctions(t *testing.T) { 44 | mockDocuments := []*MockDocument{ 45 | &MockDocument{ID: "1", Name: "A"}, 46 | &MockDocument{ID: "2", Name: "B"}, 47 | } 48 | 49 | qr := &QueryResult[MockDocument]{Items: mockDocuments} 50 | 51 | t.Run("Count returns correct count", func(t *testing.T) { 52 | assert.Equal(t, 2, qr.Count()) 53 | }) 54 | 55 | t.Run("First returns first item", func(t *testing.T) { 56 | assert.Equal(t, mockDocuments[0], qr.First()) 57 | }) 58 | 59 | t.Run("Any returns true for non-empty result", func(t *testing.T) { 60 | assert.True(t, qr.Any()) 61 | }) 62 | 63 | t.Run("Iter stops on error", func(t *testing.T) { 64 | qr := &QueryResult[MockDocument]{Items: mockDocuments} 65 | err := errors.New("sample error") 66 | qr.Iter(func(doc *MockDocument) error { 67 | return err 68 | }) 69 | assert.Equal(t, err, qr.Error) 70 | }) 71 | 72 | t.Run("Filter returns filtered items", func(t *testing.T) { 73 | qr := &QueryResult[MockDocument]{Items: mockDocuments} 74 | newQr := qr.Filter(func(doc *MockDocument) bool { 75 | return doc.Name == "B" 76 | }) 77 | assert.Equal(t, 1, newQr.Count()) 78 | assert.Equal(t, "B", newQr.First().Name) 79 | }) 80 | 81 | // Similarly add tests for `Validate`, `Delete`, and `Update` functions. 82 | } 83 | 84 | // Implement additional tests for other functions like Delete(), Update() etc. 85 | 86 | func TestQueryResultJSONResponse(t *testing.T) { 87 | t.Run("Empty QueryResult returns empty JSON response", func(t *testing.T) { 88 | qr := &QueryResult[MockDocument]{} 89 | resp := qr.JSONResponse() 90 | assert.Equal(t, 0, resp["count"]) 91 | assert.Empty(t, resp["result"]) 92 | }) 93 | 94 | // Add other tests based on different states of QueryResult. 95 | } 96 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= 2 | github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= 7 | github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= 8 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 9 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 10 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 11 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 12 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 13 | github.com/go-playground/validator/v10 v10.15.5 h1:LEBecTWb/1j5TNY1YYG2RcOUN3R7NLylN+x8TTueE24= 14 | github.com/go-playground/validator/v10 v10.15.5/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 15 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 16 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 17 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 18 | github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= 19 | github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= 20 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= 21 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 22 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 23 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 24 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 25 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 26 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 27 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 28 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 29 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 30 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 31 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 32 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 33 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 34 | go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ= 35 | go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= 36 | golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= 37 | golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 38 | golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= 39 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 40 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 41 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 | golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= 43 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 44 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 45 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 46 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 47 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 48 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 49 | -------------------------------------------------------------------------------- /query.go: -------------------------------------------------------------------------------- 1 | package bingo 2 | 3 | import ( 4 | "fmt" 5 | "go.etcd.io/bbolt" 6 | ) 7 | 8 | // Query represents a query for filtering and retrieving documents in the collection. It provides flexible options for selecting documents based on various criteria. 9 | type Query[T DocumentSpec] struct { 10 | // Filter is a function that defines a filtering criteria. It should return true if a document matches the criteria and should be included in the result. 11 | Filter func(doc T) bool 12 | 13 | // Skip defines the number of documents to skip before the query starts returning results. Useful for implementing pagination. 14 | Skip int 15 | 16 | // Count specifies the maximum number of documents to be returned by the query. If set to a non-positive value, all matching documents are returned. 17 | Count int 18 | 19 | // Keys is a slice of document keys that can be used to directly retrieve specific documents from the collection. When provided, it takes precedence over the Filter function. 20 | Keys [][]byte 21 | // KeysStr is a slice of document keys that can be used to directly retrieve specific documents from the collection. When provided, it takes precedence over the Filter function. 22 | KeysStr []string 23 | } 24 | 25 | // QueryResult represents the result of a query operation in a collection. It contains the retrieved items, as well as metadata about the query. 26 | type QueryResult[T DocumentSpec] struct { 27 | // Collection is a reference to the collection to which this query result belongs. 28 | Collection *Collection[T] 29 | 30 | // Items is a slice of pointers to the documents that matched the query criteria. These documents are the results of the query. 31 | Items []*T 32 | 33 | // Keys corresponds to the keys of the documents that matched the query criteria. These documents are the results of the query. 34 | Keys [][]byte 35 | // Next is the index of the last item retrieved in the query result. It helps track the position in the collection. 36 | // It can be used to implement pagination by passing it as the Skip value in a subsequent query. 37 | Next int 38 | 39 | // Error is an error object that may contain any errors encountered during the query operation. It represents the overall query result status. 40 | Error error 41 | } 42 | 43 | // JSONResponse returns a map that can be used to generate a JSON response for the query result. 44 | func (qr *QueryResult[T]) JSONResponse() map[string]any { 45 | if !qr.Any() { 46 | return map[string]any{ 47 | "result": []any{}, 48 | "count": 0, 49 | "next": 0, 50 | } 51 | } 52 | return map[string]any{ 53 | "result": qr.Items, 54 | "count": len(qr.Items), 55 | "next": qr.Next, 56 | } 57 | } 58 | 59 | // Count returns the number of items in the query result. 60 | func (qr *QueryResult[T]) Count() int { 61 | return len(qr.Items) 62 | } 63 | 64 | // First returns the first item in the query result. If the query result is empty, it returns a nil. 65 | func (qr *QueryResult[T]) First() *T { 66 | if len(qr.Items) == 0 { 67 | return new(T) 68 | } 69 | return qr.Items[0] 70 | } 71 | 72 | // Any returns true if the query result contains any items. 73 | func (qr *QueryResult[T]) Any() bool { 74 | return len(qr.Items) > 0 75 | } 76 | 77 | // Iter iterates over the items in the query result and executes the provided function for each item. If an error is returned by the function, the iteration is stopped and the error is returned. 78 | func (qr *QueryResult[T]) Iter(f func(doc *T) error) *QueryResult[T] { 79 | if qr.Error != nil { 80 | return qr 81 | } 82 | for _, document := range qr.Items { 83 | err := f(document) 84 | if err != nil { 85 | qr.Error = err 86 | return qr 87 | } 88 | } 89 | return qr 90 | } 91 | 92 | // Filter filters the items in the query result using the provided function. The function should return true if the item should be included in the result, and false otherwise. 93 | func (qr *QueryResult[T]) Filter(f func(doc *T) bool) *QueryResult[T] { 94 | if qr.Error != nil { 95 | return qr 96 | } 97 | var items []*T 98 | for _, document := range qr.Items { 99 | if f(document) { 100 | items = append(items, document) 101 | } 102 | } 103 | qr.Items = items 104 | return qr 105 | } 106 | 107 | // Validate validates the query result using the provided function. If the function returns an error, the query result error is set to that error. 108 | func (qr *QueryResult[T]) Validate(f func(qr *QueryResult[T]) error) *QueryResult[T] { 109 | if qr.Error != nil { 110 | return qr 111 | } 112 | err := f(qr) 113 | if err != nil { 114 | qr.Error = err 115 | } 116 | return qr 117 | } 118 | 119 | // Delete deletes the items in the query result from the collection. 120 | func (qr *QueryResult[T]) Delete() error { 121 | if qr.Error != nil { 122 | return qr.Error 123 | } 124 | return qr.Collection.Driver.db.Update(func(tx *bbolt.Tx) error { 125 | bucket := tx.Bucket(qr.Collection.nameBytes) 126 | if bucket == nil { 127 | return fmt.Errorf("bucket %s not found", qr.Collection.Name) 128 | } 129 | 130 | for _, document := range qr.Items { 131 | if qr.Collection.beforeDelete != nil { 132 | err := qr.Collection.beforeDelete(document) 133 | if err != nil { 134 | return err 135 | } 136 | } 137 | 138 | err := bucket.Delete((*document).Key()) 139 | if err != nil { 140 | return err 141 | } 142 | 143 | if qr.Collection.afterDelete != nil { 144 | err := qr.Collection.afterDelete(document) 145 | if err != nil { 146 | return err 147 | } 148 | } 149 | } 150 | return nil 151 | }) 152 | } 153 | 154 | // Update updates the items in the query result in the collection. 155 | func (qr *QueryResult[T]) Update() error { 156 | if qr.Error != nil { 157 | return qr.Error 158 | } 159 | return qr.Collection.Driver.db.Update(func(tx *bbolt.Tx) error { 160 | bucket := tx.Bucket(qr.Collection.nameBytes) 161 | if bucket == nil { 162 | return fmt.Errorf("bucket %s not found", qr.Collection.Name) 163 | } 164 | 165 | for _, document := range qr.Items { 166 | if qr.Collection.beforeUpdate != nil { 167 | err := qr.Collection.beforeUpdate(document) 168 | if err != nil { 169 | return err 170 | } 171 | } 172 | 173 | data, err := Marshaller.Marshal(document) 174 | if err != nil { 175 | return err 176 | } 177 | 178 | err = bucket.Put((*document).Key(), data) 179 | if err != nil { 180 | return err 181 | } 182 | 183 | if qr.Collection.afterUpdate != nil { 184 | err := qr.Collection.afterUpdate(document) 185 | if err != nil { 186 | return err 187 | } 188 | } 189 | } 190 | return nil 191 | }) 192 | } 193 | -------------------------------------------------------------------------------- /driver.go: -------------------------------------------------------------------------------- 1 | package bingo 2 | 3 | import ( 4 | "fmt" 5 | "github.com/go-playground/validator/v10" 6 | jsoniter "github.com/json-iterator/go" 7 | "go.etcd.io/bbolt" 8 | "os" 9 | "reflect" 10 | "strings" 11 | ) 12 | 13 | const ( 14 | METADATA_COLLECTION_NAME = "__metadata" 15 | FIELDS_COLLECTION_NAME = "__fields:" 16 | FIELD_ALIAS_SEPARATOR = ";" 17 | ) 18 | 19 | var json = jsoniter.Config{ 20 | EscapeHTML: true, 21 | SortMapKeys: true, 22 | ValidateJsonRawMessage: true, 23 | TagKey: "bingo_json", 24 | }.Froze() 25 | 26 | type HasMarshal interface { 27 | Marshal(v interface{}) ([]byte, error) 28 | } 29 | 30 | type HasUnmarshal interface { 31 | Unmarshal(data []byte, v interface{}) error 32 | } 33 | 34 | var Marshaller HasMarshal = json 35 | var Unmarshaller HasUnmarshal = json 36 | 37 | var ( 38 | ErrDocumentNotFound = fmt.Errorf("document not found") 39 | ErrDocumentExists = fmt.Errorf("document already exists") 40 | ) 41 | 42 | type WrappedBucket struct { 43 | *bbolt.Bucket 44 | } 45 | 46 | func (b *WrappedBucket) ReverseIter(fn func(k, v []byte) error) error { 47 | if b.Tx().DB() == nil { 48 | return fmt.Errorf("tx is closed") 49 | } 50 | c := b.Cursor() 51 | for k, v := c.Last(); k != nil; k, v = c.Prev() { 52 | if err := fn(k, v); err != nil { 53 | return err 54 | } 55 | } 56 | return nil 57 | } 58 | 59 | // IsErrDocumentNotFound returns true if the error is an ErrDocumentNotFound error. 60 | func IsErrDocumentNotFound(err error) bool { 61 | return strings.Contains(err.Error(), ErrDocumentNotFound.Error()) 62 | } 63 | 64 | // IsErrDocumentExists returns true if the error is an ErrDocumentExists error. 65 | func IsErrDocumentExists(err error) bool { 66 | return strings.Contains(err.Error(), ErrDocumentExists.Error()) 67 | } 68 | 69 | // DriverConfiguration represents the configuration for a database driver. 70 | // DeleteNoVerify specifies whether to verify a Collection DROP operation before executing it. 71 | // Filename specifies the filename of the database file. 72 | type DriverConfiguration struct { 73 | DeleteNoVerify bool 74 | Filename string 75 | } 76 | 77 | // Driver represents a database driver that manages collections of documents. 78 | type Driver struct { 79 | db *bbolt.DB 80 | val *validator.Validate 81 | config *DriverConfiguration 82 | Closed bool 83 | } 84 | 85 | // NewDriver creates a new database driver with the specified configuration. 86 | func NewDriver(config DriverConfiguration) (*Driver, error) { 87 | db, err := bbolt.Open(config.Filename, 0600, nil) 88 | if err != nil { 89 | return nil, err 90 | } 91 | return &Driver{ 92 | db: db, 93 | val: validator.New(validator.WithRequiredStructEnabled()), 94 | config: &config, 95 | }, nil 96 | } 97 | 98 | // Close closes the database file. 99 | func (d *Driver) Close() error { 100 | d.Closed = true 101 | return d.db.Close() 102 | } 103 | 104 | // Update updates the database using the provided function. 105 | // This provides low level access to the underlying database. 106 | func (d *Driver) Update(update func(tx *bbolt.Tx) error) error { 107 | return d.db.Update(update) 108 | } 109 | 110 | // View updates the database using the provided function. 111 | // This provides low level access to the underlying database. 112 | func (d *Driver) View(update func(tx *bbolt.Tx) error) error { 113 | return d.db.View(update) 114 | } 115 | 116 | func (d *Driver) FieldsOf(name string) ([][]string, error) { 117 | r, err := d.ReadMetadata(FIELDS_COLLECTION_NAME + name) 118 | if err != nil { 119 | return nil, err 120 | } 121 | if v, ok := r.([]any); ok { 122 | result := [][]string{} 123 | for _, i := range v { 124 | if s, ok := i.(string); ok { 125 | result = append(result, strings.Split(s, FIELD_ALIAS_SEPARATOR)) 126 | } else { 127 | return nil, fmt.Errorf("unknown inner field structure: %v", i) 128 | } 129 | } 130 | return result, nil 131 | } 132 | return nil, fmt.Errorf("unknown field structure: %v", r) 133 | } 134 | 135 | // Drop drops the collection from the database. 136 | // If the environment variable BINGO_ALLOW_DROP_ is not set to true, an error is returned. 137 | // If Driver.config.DeleteNoVerify is set to true, the collection is dropped without any verification. 138 | func (c *Collection[DocumentType]) Drop() error { 139 | if !c.Driver.config.DeleteNoVerify { 140 | if r, _ := os.LookupEnv("BINGO_ALLOW_DROP_" + strings.ToUpper(c.Name)); r != "true" { 141 | return fmt.Errorf("delete not allowed, set environment variable BINGO_ALLOW_DROP_%s=true to allow", strings.ToUpper(c.Name)) 142 | } 143 | } 144 | _ = c.Driver.removeCollection(c.Name) 145 | return c.Driver.db.Update(func(tx *bbolt.Tx) error { 146 | return tx.DeleteBucket([]byte(c.Name)) 147 | }) 148 | } 149 | 150 | // CollectionFrom creates a new collection with the specified driver and name. 151 | func CollectionFrom[T DocumentSpec](driver *Driver, name string) *Collection[T] { 152 | var o T 153 | typ := reflect.TypeOf(o) 154 | if typ == nil { 155 | panic(fmt.Errorf("cannot use interface as type")) 156 | } 157 | if driver.Closed { 158 | panic(fmt.Errorf("driver is closed")) 159 | } 160 | 161 | if name == METADATA_COLLECTION_NAME { 162 | return &Collection[T]{ 163 | Driver: driver, 164 | Name: name, 165 | nameBytes: []byte(name), 166 | } 167 | } 168 | 169 | //We should only write the fields to the metadata if the type is a struct 170 | if typ.Kind() == reflect.Struct { 171 | var typeFields []string 172 | for i := 0; i < typ.NumField(); i++ { 173 | var names []string 174 | names = append(names, typ.Field(i).Name) 175 | if jtag := typ.Field(i).Tag.Get("json"); jtag != "" { 176 | names = append(names, strings.Split(jtag, ",")[0]) 177 | } 178 | typeFields = append(typeFields, strings.Join(names, FIELD_ALIAS_SEPARATOR)) 179 | } 180 | err := driver.WriteMetadata(FIELDS_COLLECTION_NAME+name, typeFields) 181 | if err != nil { 182 | panic(fmt.Sprintf("unable to write fields to metadata: %v", err)) 183 | } 184 | } 185 | 186 | if !reflect.ValueOf(o).FieldByName("ID").IsValid() { 187 | panic(fmt.Errorf("document type %v does not have a valid ID field", typ)) 188 | } 189 | 190 | err := driver.addCollection(name) 191 | if err != nil { 192 | panic(fmt.Sprintf("unable to add collection to metadata: %v", err)) 193 | } 194 | 195 | return &Collection[T]{ 196 | Driver: driver, 197 | Name: name, 198 | nameBytes: []byte(name), 199 | } 200 | } 201 | 202 | type Metadata struct { 203 | Document 204 | K string 205 | V any 206 | } 207 | 208 | func (m Metadata) Key() []byte { 209 | return []byte(m.K) 210 | } 211 | 212 | func (d *Driver) WriteMetadata(k string, v any) error { 213 | metadata := CollectionFrom[Metadata](d, "__metadata") 214 | _, err := metadata.Insert(Metadata{K: k, V: v}, Upsert) 215 | return err 216 | } 217 | 218 | func (d *Driver) ReadMetadata(k string) (any, error) { 219 | metadata := CollectionFrom[Metadata](d, "__metadata") 220 | r, err := metadata.FindOne(func(doc Metadata) bool { 221 | return doc.K == k 222 | }) 223 | if err != nil { 224 | return nil, err 225 | } 226 | return r.V, nil 227 | } 228 | 229 | func (d *Driver) addCollection(name string) error { 230 | return d.WriteMetadata(fmt.Sprintf("collection:%v", name), true) 231 | } 232 | 233 | func (d *Driver) removeCollection(name string) error { 234 | return d.WriteMetadata(fmt.Sprintf("collection:%v", name), false) 235 | } 236 | 237 | func (d *Driver) GetCollections() ([]string, error) { 238 | metadata := CollectionFrom[Metadata](d, "__metadata") 239 | result, err := metadata.Find(func(doc Metadata) bool { 240 | return strings.HasPrefix(doc.K, "collection:") && doc.V.(bool) 241 | }) 242 | if err != nil { 243 | return nil, err 244 | } 245 | var collections []string 246 | for _, doc := range result { 247 | collections = append(collections, strings.TrimPrefix(doc.K, "collection:")) 248 | } 249 | return collections, nil 250 | } 251 | -------------------------------------------------------------------------------- /output.txt: -------------------------------------------------------------------------------- 1 | 📂 Your project directory: 2 | 📂 Directory Structure: 3 | └ 📁 . 4 | ├ 📜 README.md 5 | ├ 📜 driver.go 6 | ├ 📜 go.mod 7 | ├ 📜 output.txt 8 | └ 📜 preprocessor.go 9 | 10 | 11 | 📂 Go Files: 12 | ┣ 📜 driver.go 13 | ```go 14 | package bingo 15 | 16 | import ( 17 | "errors" 18 | "fmt" 19 | "github.com/go-playground/validator/v10" 20 | jsoniter "github.com/json-iterator/go" 21 | "go.etcd.io/bbolt" 22 | "os" 23 | "strings" 24 | ) 25 | 26 | var json = jsoniter.ConfigCompatibleWithStandardLibrary 27 | var AllDocuments = -1 28 | 29 | var ( 30 | ErrDocumentNotFound = fmt.Errorf("document not found") 31 | ErrDocumentExists = fmt.Errorf("document already exists") 32 | ) 33 | 34 | type WrappedBucket struct { 35 | *bbolt.Bucket 36 | } 37 | 38 | func (b *WrappedBucket) ReverseIter(fn func(k, v []byte) error) error { 39 | if b.Tx().DB() == nil { 40 | return fmt.Errorf("tx is closed") 41 | } 42 | c := b.Cursor() 43 | for k, v := c.Last(); k != nil; k, v = c.Prev() { 44 | if err := fn(k, v); err != nil { 45 | return err 46 | } 47 | } 48 | return nil 49 | } 50 | 51 | func IsErrDocumentNotFound(err error) bool { 52 | return strings.Contains(err.Error(), ErrDocumentNotFound.Error()) 53 | } 54 | 55 | func IsErrDocumentExists(err error) bool { 56 | return strings.Contains(err.Error(), ErrDocumentExists.Error()) 57 | } 58 | 59 | type DriverConfiguration struct { 60 | DeleteNoVerify bool 61 | Filename string 62 | } 63 | 64 | type Driver struct { 65 | db *bbolt.DB 66 | val *validator.Validate 67 | config *DriverConfiguration 68 | } 69 | 70 | func NewDriver(config DriverConfiguration) (*Driver, error) { 71 | db, err := bbolt.Open(config.Filename, 0600, nil) 72 | if err != nil { 73 | return nil, err 74 | } 75 | return &Driver{ 76 | db: db, 77 | val: validator.New(validator.WithRequiredStructEnabled()), 78 | config: &config, 79 | }, nil 80 | } 81 | 82 | func (d *Collection[DocumentType]) Drop() error { 83 | if !d.Driver.config.DeleteNoVerify { 84 | if r, _ := os.LookupEnv("BINGO_ALLOW_DROP_" + strings.ToUpper(d.Name)); r != "true" { 85 | return fmt.Errorf("delete not allowed, set environment variable BINGO_ALLOW_DROP_%s=true to allow", strings.ToUpper(d.Name)) 86 | } 87 | } 88 | return d.Driver.db.Update(func(tx *bbolt.Tx) error { 89 | return tx.DeleteBucket([]byte(d.Name)) 90 | }) 91 | } 92 | 93 | type CollectionProps struct { 94 | Name string 95 | CacheSize int 96 | } 97 | 98 | type DocumentSpec interface { 99 | Key() []byte 100 | } 101 | 102 | type HasUpdate interface { 103 | Update() error 104 | } 105 | 106 | type Collection[DocumentType DocumentSpec] struct { 107 | Driver *Driver 108 | Name string 109 | nameBytes []byte 110 | beforeUpdate func(doc *DocumentType) error 111 | afterUpdate func(doc *DocumentType) error 112 | beforeDelete func(doc *DocumentType) error 113 | afterDelete func(doc *DocumentType) error 114 | beforeInsert func(doc *DocumentType) error 115 | afterInsert func(doc *DocumentType) error 116 | } 117 | 118 | func (c *Collection[T]) BeforeUpdate(f func(doc *T) error) *Collection[T] { 119 | c.beforeUpdate = f 120 | return c 121 | } 122 | 123 | func (c *Collection[T]) AfterUpdate(f func(doc *T) error) *Collection[T] { 124 | c.afterUpdate = f 125 | return c 126 | } 127 | 128 | func (c *Collection[T]) BeforeDelete(f func(doc *T) error) *Collection[T] { 129 | c.beforeDelete = f 130 | return c 131 | } 132 | 133 | func (c *Collection[T]) AfterDelete(f func(doc *T) error) *Collection[T] { 134 | c.afterDelete = f 135 | return c 136 | } 137 | 138 | func (c *Collection[T]) BeforeInsert(f func(doc *T) error) *Collection[T] { 139 | c.beforeInsert = f 140 | return c 141 | } 142 | 143 | func (c *Collection[T]) AfterInsert(f func(doc *T) error) *Collection[T] { 144 | c.afterInsert = f 145 | return c 146 | } 147 | 148 | func CollectionFrom[T DocumentSpec](driver *Driver, name string) *Collection[T] { 149 | return &Collection[T]{ 150 | Driver: driver, 151 | Name: name, 152 | nameBytes: []byte(name), 153 | } 154 | } 155 | 156 | type InsertResult struct { 157 | Success bool 158 | Errors []error 159 | InsertedId []byte 160 | } 161 | 162 | func (ir *InsertResult) Error() error { 163 | if len(ir.Errors) == 0 { 164 | return nil 165 | } 166 | var s []string 167 | for _, err := range ir.Errors { 168 | s = append(s, err.Error()) 169 | } 170 | return fmt.Errorf(strings.Join(s, ": ")) 171 | } 172 | 173 | func (ir *InsertResult) fail(errs ...error) { 174 | ir.Success = false 175 | for _, err := range errs { 176 | if err != nil { 177 | ir.Errors = append(ir.Errors, err) 178 | } 179 | } 180 | } 181 | 182 | func (c *Collection[T]) Insert(document T) (ir *InsertResult) { 183 | _, err := c.FindById(document.Key()) 184 | if err != nil && errors.Is(err, ErrDocumentNotFound) { 185 | return c.InsertOrUpsert(document) 186 | } 187 | 188 | if err != nil && !errors.Is(err, ErrDocumentNotFound) { 189 | return &InsertResult{Errors: []error{err}} 190 | } 191 | 192 | return &InsertResult{Errors: []error{ErrDocumentExists, fmt.Errorf("key %v already exists", string(document.Key()))}} 193 | 194 | } 195 | 196 | func (c *Collection[T]) InsertOrUpsert(document T) (ir *InsertResult) { 197 | ir = &InsertResult{ 198 | Success: true, 199 | } 200 | if err := c.Driver.val.Struct(document); err != nil { 201 | ir.fail(err) 202 | return 203 | } 204 | 205 | if c.beforeInsert != nil { 206 | err := c.beforeInsert(&document) 207 | if err != nil { 208 | ir.fail(err) 209 | return 210 | } 211 | } 212 | 213 | marshal, err := json.Marshal(document) 214 | if err != nil { 215 | ir.fail(err) 216 | return 217 | } 218 | var idBytes []byte 219 | ir.fail(c.Driver.db.Update(func(tx *bbolt.Tx) error { 220 | bucket, err := tx.CreateBucketIfNotExists(c.nameBytes) 221 | if err != nil { 222 | return err 223 | } 224 | 225 | key := document.Key() 226 | if len(key) == 0 { 227 | uniqueId, _ := bucket.NextSequence() 228 | idBytes = []byte(fmt.Sprintf("%v", uniqueId)) 229 | } else { 230 | idBytes = key 231 | } 232 | return bucket.Put(idBytes, marshal) 233 | })) 234 | 235 | if c.afterInsert != nil { 236 | err := c.afterInsert(&document) 237 | if err != nil { 238 | ir.fail(err) 239 | return 240 | } 241 | } 242 | 243 | ir.InsertedId = idBytes 244 | return 245 | } 246 | 247 | func (c *Collection[T]) FindById(id []byte) (T, error) { 248 | var document T 249 | err := c.Driver.db.View(func(tx *bbolt.Tx) error { 250 | bucket := tx.Bucket(c.nameBytes) 251 | if bucket == nil { 252 | return errors.Join(ErrDocumentNotFound, fmt.Errorf("document with id %v not found", string(id))) 253 | } 254 | value := bucket.Get(id) 255 | if value == nil { 256 | return errors.Join(ErrDocumentNotFound, fmt.Errorf("document with id %v not found", string(id))) 257 | } 258 | return json.Unmarshal(value, &document) 259 | }) 260 | return document, err 261 | } 262 | 263 | var stoperr = fmt.Errorf("stop") 264 | 265 | func (c *Collection[DocumentType]) queryKeys(keys ...string) []DocumentType { 266 | var documents []DocumentType 267 | _ = c.Driver.db.View(func(tx *bbolt.Tx) error { 268 | bucket := tx.Bucket(c.nameBytes) 269 | if bucket == nil { 270 | return fmt.Errorf("bucket %s not found", c.Name) 271 | } 272 | for _, key := range keys { 273 | value := bucket.Get([]byte(key)) 274 | if value == nil { 275 | continue 276 | } 277 | var document DocumentType 278 | err := json.Unmarshal(value, &document) 279 | if err != nil { 280 | continue 281 | } 282 | documents = append(documents, document) 283 | } 284 | return nil 285 | }) 286 | return documents 287 | } 288 | 289 | func (c *Collection[T]) queryFind(q Query[T]) ([]T, int, error) { 290 | var documents []T 291 | var currentFound = 0 292 | var last = 0 293 | err := c.Driver.db.View(func(tx *bbolt.Tx) error { 294 | bucket := tx.Bucket(c.nameBytes) 295 | if bucket == nil { 296 | return fmt.Errorf("bucket %s not found", c.Name) 297 | } 298 | wbucket := &WrappedBucket{bucket} 299 | return wbucket.ReverseIter(func(k, v []byte) error { 300 | last += 1 301 | if last <= q.Skip { 302 | return nil 303 | } 304 | 305 | var document T 306 | err := json.Unmarshal(v, &document) 307 | if err != nil { 308 | return err 309 | } 310 | if q.Filter(document) { 311 | documents = append(documents, document) 312 | currentFound += 1 313 | if q.Count > 0 && currentFound >= q.Count { 314 | return stoperr 315 | } 316 | } 317 | return nil 318 | }) 319 | }) 320 | if err != nil && !errors.Is(err, stoperr) { 321 | return documents, last, err 322 | } 323 | 324 | return documents, last, err 325 | } 326 | 327 | type Query[T DocumentSpec] struct { 328 | Filter func(doc T) bool 329 | Skip int 330 | Count int 331 | Keys []string 332 | } 333 | 334 | func (c *Collection[T]) Query(q Query[T]) *QueryResult[T] { 335 | if q.Keys != nil && q.Filter != nil { 336 | panic(fmt.Errorf("cannot use both key and filter")) 337 | } 338 | 339 | result := &QueryResult[T]{ 340 | Collection: c, 341 | } 342 | if q.Keys != nil { 343 | items := c.queryKeys(q.Keys...) 344 | for _, item := range items { 345 | item := item 346 | result.Items = append(result.Items, &item) 347 | } 348 | return result 349 | } 350 | 351 | if q.Filter != nil { 352 | items, last, err := c.queryFind(q) 353 | if err != nil { 354 | result.Error = errors.Join(err, fmt.Errorf("error while querying")) 355 | } 356 | result.Last = last 357 | for _, item := range items { 358 | item := item 359 | result.Items = append(result.Items, &item) 360 | } 361 | return result 362 | } 363 | 364 | result.Error = fmt.Errorf("no query provided") 365 | return result 366 | } 367 | 368 | type QueryResult[T DocumentSpec] struct { 369 | Collection *Collection[T] 370 | Items []*T 371 | Last int 372 | Error error 373 | } 374 | 375 | func (qr *QueryResult[T]) JSONResponse() map[string]any { 376 | if !qr.Any() { 377 | return map[string]any{ 378 | "result": []any{}, 379 | "count": 0, 380 | "next": 0, 381 | } 382 | } 383 | return map[string]any{ 384 | "result": qr.Items, 385 | "count": len(qr.Items), 386 | "next": qr.Last, 387 | } 388 | } 389 | 390 | func (qr *QueryResult[T]) Count() int { 391 | return len(qr.Items) 392 | } 393 | 394 | func (qr *QueryResult[T]) First() *T { 395 | if len(qr.Items) == 0 { 396 | return new(T) 397 | } 398 | return qr.Items[0] 399 | } 400 | 401 | func (qr *QueryResult[T]) Any() bool { 402 | return len(qr.Items) > 0 403 | } 404 | 405 | func (qr *QueryResult[T]) Iter(f func(doc *T) error) *QueryResult[T] { 406 | if qr.Error != nil { 407 | return qr 408 | } 409 | for _, document := range qr.Items { 410 | err := f(document) 411 | if err != nil { 412 | qr.Error = err 413 | return qr 414 | } 415 | } 416 | return qr 417 | } 418 | 419 | func (qr *QueryResult[T]) Filter(f func(doc *T) bool) *QueryResult[T] { 420 | if qr.Error != nil { 421 | return qr 422 | } 423 | var items []*T 424 | for _, document := range qr.Items { 425 | if f(document) { 426 | items = append(items, document) 427 | } 428 | } 429 | qr.Items = items 430 | return qr 431 | } 432 | 433 | func (qr *QueryResult[T]) Validate(f func(qr *QueryResult[T]) error) *QueryResult[T] { 434 | if qr.Error != nil { 435 | return qr 436 | } 437 | err := f(qr) 438 | if err != nil { 439 | qr.Error = err 440 | } 441 | return qr 442 | } 443 | 444 | func (qr *QueryResult[T]) Delete() error { 445 | if qr.Error != nil { 446 | return qr.Error 447 | } 448 | return qr.Collection.Driver.db.Update(func(tx *bbolt.Tx) error { 449 | bucket := tx.Bucket(qr.Collection.nameBytes) 450 | if bucket == nil { 451 | return fmt.Errorf("bucket %s not found", qr.Collection.Name) 452 | } 453 | 454 | for _, document := range qr.Items { 455 | if qr.Collection.beforeDelete != nil { 456 | err := qr.Collection.beforeDelete(document) 457 | if err != nil { 458 | return err 459 | } 460 | } 461 | 462 | err := bucket.Delete((*document).Key()) 463 | if err != nil { 464 | return err 465 | } 466 | 467 | if qr.Collection.afterDelete != nil { 468 | err := qr.Collection.afterDelete(document) 469 | if err != nil { 470 | return err 471 | } 472 | } 473 | } 474 | return nil 475 | }) 476 | } 477 | 478 | func (qr *QueryResult[T]) Update() error { 479 | if qr.Error != nil { 480 | return qr.Error 481 | } 482 | return qr.Collection.Driver.db.Update(func(tx *bbolt.Tx) error { 483 | bucket := tx.Bucket(qr.Collection.nameBytes) 484 | if bucket == nil { 485 | return fmt.Errorf("bucket %s not found", qr.Collection.Name) 486 | } 487 | 488 | for _, document := range qr.Items { 489 | if qr.Collection.beforeUpdate != nil { 490 | err := qr.Collection.beforeUpdate(document) 491 | if err != nil { 492 | return err 493 | } 494 | } 495 | 496 | data, err := json.Marshal(document) 497 | if err != nil { 498 | return err 499 | } 500 | 501 | err = bucket.Put((*document).Key(), data) 502 | if err != nil { 503 | return err 504 | } 505 | 506 | if qr.Collection.afterUpdate != nil { 507 | err := qr.Collection.afterUpdate(document) 508 | if err != nil { 509 | return err 510 | } 511 | } 512 | } 513 | return nil 514 | }) 515 | } 516 | ``` 517 | ┣ 📜 preprocessor.go 518 | ```go 519 | package bingo 520 | 521 | import ( 522 | "fmt" 523 | "reflect" 524 | ) 525 | 526 | type Preprocessor[T any] interface { 527 | Name() string 528 | To(T) ([]byte, error) 529 | From([]byte) (T, error) 530 | } 531 | 532 | type Guard interface { 533 | Name() string 534 | Check(any) error 535 | } 536 | 537 | type GuardNotNull struct{} 538 | 539 | func (g *GuardNotNull) Name() string { 540 | return "notnull" 541 | } 542 | 543 | func (g *GuardNotNull) Check(val any) error { 544 | if reflect.ValueOf(val).IsNil() { 545 | return fmt.Errorf("value is nil") 546 | } 547 | return nil 548 | } 549 | 550 | type GuardNotEmpty struct{} 551 | 552 | func (g *GuardNotEmpty) Name() string { 553 | return "notempty" 554 | } 555 | 556 | func (g *GuardNotEmpty) Check(val any) error { 557 | if val.(string) == "" { 558 | return fmt.Errorf("value is empty") 559 | } 560 | return nil 561 | } 562 | ``` 563 | -------------------------------------------------------------------------------- /bingo_test.go: -------------------------------------------------------------------------------- 1 | package bingo_test 2 | 3 | import ( 4 | "fmt" 5 | "github.com/nokusukun/bingo" 6 | "go.etcd.io/bbolt" 7 | "os" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | type TestDocument struct { 13 | bingo.Document 14 | Name string `json:"name" validate:"required"` 15 | } 16 | 17 | //func (td TestDocument) Key() []byte { 18 | // return []byte(td.ID) 19 | //} 20 | 21 | // Initialize the driver 22 | func TestNewDriver(t *testing.T) { 23 | config := bingo.DriverConfiguration{ 24 | Filename: "test.db", 25 | DeleteNoVerify: true, 26 | } 27 | driver, err := bingo.NewDriver(config) 28 | if err != nil { 29 | t.Fatalf("Failed to initialize driver: %v", err) 30 | } 31 | 32 | defer func() { 33 | driver.Close() 34 | os.Remove("test.db") 35 | }() 36 | } 37 | 38 | // Basic CRUD operations 39 | func TestCRUD(t *testing.T) { 40 | config := bingo.DriverConfiguration{ 41 | Filename: "testcrud.db", 42 | DeleteNoVerify: true, 43 | } 44 | driver, err := bingo.NewDriver(config) 45 | if err != nil { 46 | t.Fatalf("Failed to initialize driver: %v", err) 47 | } 48 | 49 | defer func() { 50 | driver.Close() 51 | os.Remove("testcrud.db") 52 | }() 53 | 54 | // Collection 55 | coll := bingo.CollectionFrom[TestDocument](driver, "testCollection") 56 | t.Run("should insert", func(t *testing.T) { 57 | // Insert 58 | _, err = coll.Insert(TestDocument{Document: bingo.Document{ID: "1"}, Name: "Test"}) 59 | if err != nil { 60 | t.Fatalf("Failed to insert document: %v", err) 61 | } 62 | 63 | _, err = coll.Insert(TestDocument{Name: "Fest"}) 64 | if err != nil { 65 | t.Fatalf("Failed to insert document: %v", err) 66 | } 67 | 68 | generatedId, err := coll.Insert(TestDocument{Name: "Rest"}) 69 | if err != nil { 70 | t.Fatalf("Failed to insert document: %v", err) 71 | } 72 | fmt.Println("Generated Id", string(generatedId)) 73 | coll.Driver.View(func(tx *bbolt.Tx) error { 74 | return tx.ForEach(func(name []byte, b *bbolt.Bucket) error { 75 | fmt.Println("Bucket", string(name)) 76 | return b.ForEach(func(k, v []byte) error { 77 | fmt.Println("Key", string(k), "Value", string(v)) 78 | return nil 79 | }) 80 | }) 81 | }) 82 | }) 83 | 84 | // should not insert the same document twice 85 | t.Run("should not insert the same document twice", func(t *testing.T) { 86 | doc := TestDocument{ 87 | Document: bingo.Document{ID: "1"}, 88 | Name: "Test", 89 | } 90 | _, err = coll.Insert(doc) 91 | if err == nil { 92 | t.Fatalf("Expected insertion failure due to duplicate key") 93 | } 94 | }) 95 | 96 | t.Run("should return correct key when using FindOneWithKey", func(t *testing.T) { 97 | foundDoc, id, err := coll.FindOneWithKey(func(doc TestDocument) bool { 98 | return doc.ID == "1" 99 | }) 100 | if err != nil { 101 | t.Fatalf("Failed to find document: %v", err) 102 | } 103 | if foundDoc.Name != "Test" { 104 | t.Fatalf("Unexpected document data: %v", foundDoc) 105 | } 106 | if id == nil { 107 | t.Fatalf("Expected ID to be returned") 108 | } 109 | if string(id) != "1" { 110 | t.Fatalf("Unexpected ID returned: %v", id) 111 | } 112 | }) 113 | 114 | // should not insert but not throw an error because IgnoreErrors is passed 115 | t.Run("should not insert but not throw an error because IgnoreErrors is passed", func(t *testing.T) { 116 | doc := TestDocument{Document: bingo.Document{ID: "1"}, Name: "Test"} 117 | id, err := coll.Insert(doc, bingo.IgnoreErrors) 118 | if err != nil && id != nil { 119 | t.Fatalf("Expected insertion to succeed due to IgnoreErrors") 120 | } 121 | }) 122 | 123 | t.Run("should upsert data", func(t *testing.T) { 124 | doc := TestDocument{Document: bingo.Document{ID: "1"}, Name: "Fooby"} 125 | id, err := coll.Insert(doc, bingo.Upsert) 126 | if err != nil { 127 | t.Fatalf("Expected insertion to succeed due to Upsert") 128 | } 129 | if id == nil { 130 | t.Fatalf("Expected ID to be returned due to Upsert") 131 | } 132 | 133 | foundDoc, err := coll.FindByBytesKey(id) 134 | if err != nil { 135 | t.Fatalf("Failed to find document: %v", err) 136 | } 137 | if foundDoc.Name != "Fooby" { 138 | t.Fatalf("Unexpected document data: %v", foundDoc) 139 | } 140 | }) 141 | 142 | // Find 143 | t.Run("should find by ID", func(t *testing.T) { 144 | foundDoc, err := coll.FindByKey("1") 145 | if err != nil { 146 | t.Fatalf("Failed to find document: %v", err) 147 | } 148 | if !strings.Contains("TestFooby", foundDoc.Name) { 149 | t.Fatalf("Unexpected document data: %v", foundDoc) 150 | } 151 | }) 152 | 153 | t.Run("Test metadata for fields", func(t *testing.T) { 154 | fields, err := driver.FieldsOf("testCollection") 155 | if err != nil { 156 | t.Fatalf("Failed to read metadata: %v", err) 157 | } 158 | if fields == nil { 159 | t.Fatalf("Expected fields to be returned") 160 | } 161 | fmt.Println("fields", fields) 162 | }) 163 | 164 | } 165 | 166 | func TestFindAll(t *testing.T) { 167 | config := bingo.DriverConfiguration{ 168 | Filename: "testquery.db", 169 | DeleteNoVerify: true, 170 | } 171 | driver, err := bingo.NewDriver(config) 172 | if err != nil { 173 | t.Fatalf("Failed to initialize driver: %v", err) 174 | } 175 | 176 | defer func() { 177 | driver.Close() 178 | os.Remove("testquery.db") 179 | }() 180 | 181 | // Collection 182 | coll := bingo.CollectionFrom[TestDocument](driver, "testCollection") 183 | 184 | docs := []TestDocument{ 185 | {Name: "Apple"}, 186 | {Name: "Banana"}, 187 | {Name: "Cherry"}, 188 | } 189 | for _, doc := range docs { 190 | _, err = coll.Insert(doc) 191 | if err != nil { 192 | t.Fatalf("Failed to insert document: %v", err) 193 | } 194 | } 195 | 196 | result, _ := coll.Find(func(doc TestDocument) bool { 197 | return true 198 | }, bingo.Skip(1), bingo.Count(1)) 199 | 200 | if result[0].Name != "Banana" { 201 | t.Fatalf("Unexpected query result: %v", result[0]) 202 | } 203 | } 204 | 205 | func TestUpdateOne(t *testing.T) { 206 | config := bingo.DriverConfiguration{ 207 | Filename: "testquery.db", 208 | DeleteNoVerify: true, 209 | } 210 | driver, err := bingo.NewDriver(config) 211 | if err != nil { 212 | t.Fatalf("Failed to initialize driver: %v", err) 213 | } 214 | 215 | defer func() { 216 | driver.Close() 217 | os.Remove("testquery.db") 218 | }() 219 | 220 | // Collection 221 | coll := bingo.CollectionFrom[TestDocument](driver, "testCollection") 222 | 223 | docs := []TestDocument{ 224 | {Name: "Apple"}, 225 | {Name: "Banana"}, 226 | {Name: "Cherry"}, 227 | } 228 | ids, err := coll.InsertMany(docs) 229 | if err != nil { 230 | t.Fatalf("Failed to insert document: %v", err) 231 | } 232 | if len(ids) != 3 { 233 | t.Fatalf("Unexpected number of inserted documents: %d", len(ids)) 234 | } 235 | 236 | result, err := coll.FindOne(func(doc TestDocument) bool { 237 | return doc.Name == "Apple" 238 | }) 239 | 240 | if err != nil { 241 | t.Fatalf("Failed to find document: %v", err) 242 | } 243 | 244 | result.Name = "Pineapple" 245 | err = coll.UpdateOne(result) 246 | if err != nil { 247 | t.Fatalf("Failed to update document: %v", err) 248 | } 249 | 250 | result, err = coll.FindOne(func(doc TestDocument) bool { 251 | return doc.Name == "Pineapple" 252 | }) 253 | if err != nil { 254 | t.Fatalf("Failed to find document: %v", err) 255 | } 256 | if result.Name != "Pineapple" { 257 | t.Fatalf("Unexpected query result: %v", result) 258 | } 259 | } 260 | 261 | func TestDeleteOne(t *testing.T) { 262 | config := bingo.DriverConfiguration{ 263 | Filename: "testquery.db", 264 | DeleteNoVerify: true, 265 | } 266 | driver, err := bingo.NewDriver(config) 267 | if err != nil { 268 | t.Fatalf("Failed to initialize driver: %v", err) 269 | } 270 | 271 | defer func() { 272 | driver.Close() 273 | os.Remove("testquery.db") 274 | }() 275 | 276 | // Collection 277 | coll := bingo.CollectionFrom[TestDocument](driver, "testCollection") 278 | 279 | docs := []TestDocument{ 280 | {Name: "Apple"}, 281 | {Name: "Banana"}, 282 | {Name: "Cherry"}, 283 | } 284 | ids, err := coll.InsertMany(docs) 285 | if err != nil { 286 | t.Fatalf("Failed to insert document: %v", err) 287 | } 288 | if len(ids) != 3 { 289 | t.Fatalf("Unexpected number of inserted documents: %d", len(ids)) 290 | } 291 | 292 | result, err := coll.FindOne(func(doc TestDocument) bool { 293 | return doc.Name == "Apple" 294 | }) 295 | 296 | if err != nil { 297 | t.Fatalf("Failed to find document: %v", err) 298 | } 299 | 300 | err = coll.DeleteOne(result) 301 | if err != nil { 302 | t.Fatalf("Failed to delete document: %v", err) 303 | } 304 | 305 | _, err = coll.FindOne(func(doc TestDocument) bool { 306 | return doc.Name == "Apple" 307 | }) 308 | if err == nil { 309 | t.Fatalf("Found document that should have been deleted") 310 | } 311 | } 312 | 313 | func TestDeleteIter(t *testing.T) { 314 | config := bingo.DriverConfiguration{ 315 | Filename: "testquery.db", 316 | DeleteNoVerify: true, 317 | } 318 | driver, err := bingo.NewDriver(config) 319 | if err != nil { 320 | t.Fatalf("Failed to initialize driver: %v", err) 321 | } 322 | 323 | defer func() { 324 | driver.Close() 325 | os.Remove("testquery.db") 326 | }() 327 | 328 | // Collection 329 | coll := bingo.CollectionFrom[TestDocument](driver, "testCollection") 330 | 331 | docs := []TestDocument{ 332 | {Name: "Apple"}, 333 | {Name: "Banana"}, 334 | {Name: "Cherry"}, 335 | {Name: "Pineapple"}, 336 | {Name: "Strawberry"}, 337 | {Name: "Watermelon"}, 338 | {Name: "Orange"}, 339 | {Name: "Grape"}, 340 | {Name: "Kiwi"}, 341 | {Name: "Mango"}, 342 | {Name: "Peach"}, 343 | {Name: "Pear"}, 344 | {Name: "Plum"}, 345 | {Name: "Pomegranate"}, 346 | {Name: "Raspberry"}, 347 | } 348 | ids, err := coll.InsertMany(docs) 349 | if err != nil { 350 | t.Fatalf("Failed to insert document: %v", err) 351 | } 352 | if len(ids) != len(docs) { 353 | t.Fatalf("Unexpected number of inserted documents: %d", len(ids)) 354 | } 355 | 356 | err = coll.DeleteIter(func(doc *TestDocument) bool { 357 | return doc.Name[0] == 'P' 358 | }) 359 | if err != nil { 360 | t.Fatalf("Failed to delete documents: %v", err) 361 | } 362 | 363 | result, err := coll.Find(func(doc TestDocument) bool { 364 | return doc.Name[0] == 'P' 365 | }) 366 | if err != nil && !bingo.IsErrDocumentNotFound(err) { 367 | t.Fatalf("Failed to find documents: %v", err) 368 | } 369 | 370 | if len(result) > 0 { 371 | t.Fatalf("Found documents that should have been deleted") 372 | } 373 | } 374 | 375 | func TestUpdateIter(t *testing.T) { 376 | config := bingo.DriverConfiguration{ 377 | Filename: "testquery.db", 378 | DeleteNoVerify: true, 379 | } 380 | driver, err := bingo.NewDriver(config) 381 | if err != nil { 382 | t.Fatalf("Failed to initialize driver: %v", err) 383 | } 384 | 385 | defer func() { 386 | driver.Close() 387 | os.Remove("testquery.db") 388 | }() 389 | 390 | // Collection 391 | coll := bingo.CollectionFrom[TestDocument](driver, "testCollection") 392 | 393 | docs := []TestDocument{ 394 | {Name: "Apple"}, 395 | {Name: "Banana"}, 396 | {Name: "Cherry"}, 397 | {Name: "Pineapple"}, 398 | {Name: "Strawberry"}, 399 | {Name: "Watermelon"}, 400 | {Name: "Orange"}, 401 | {Name: "Grape"}, 402 | {Name: "Kiwi"}, 403 | {Name: "Mango"}, 404 | {Name: "Peach"}, 405 | {Name: "Pear"}, 406 | {Name: "Plum"}, 407 | {Name: "Pomegranate"}, 408 | {Name: "Raspberry"}, 409 | } 410 | ids, err := coll.InsertMany(docs) 411 | if err != nil { 412 | t.Fatalf("Failed to insert document: %v", err) 413 | } 414 | if len(ids) != len(docs) { 415 | t.Fatalf("Unexpected number of inserted documents: %d", len(ids)) 416 | } 417 | 418 | err = coll.UpdateIter(func(doc *TestDocument) *TestDocument { 419 | if strings.Contains(doc.Name, "P") { 420 | doc.Name = "Modified" 421 | return doc 422 | } 423 | return nil 424 | }) 425 | if err != nil { 426 | t.Fatalf("Failed to update documents: %v", err) 427 | } 428 | 429 | result, err := coll.Find(func(doc TestDocument) bool { 430 | return doc.Name == "Modified" 431 | }) 432 | if err != nil { 433 | t.Fatalf("Failed to find documents: %v", err) 434 | } 435 | 436 | if len(result) == 0 { 437 | t.Fatalf("Failed to find documents that should have been updated") 438 | } 439 | 440 | if len(result) != 5 { 441 | t.Fatalf("Unexpected number of documents found: %d", len(result)) 442 | } 443 | } 444 | 445 | func TestQueryFunctionality(t *testing.T) { 446 | config := bingo.DriverConfiguration{ 447 | Filename: "testquery.db", 448 | DeleteNoVerify: true, 449 | } 450 | driver, err := bingo.NewDriver(config) 451 | if err != nil { 452 | t.Fatalf("Failed to initialize driver: %v", err) 453 | } 454 | 455 | defer func() { 456 | driver.Close() 457 | os.Remove("testquery.db") 458 | }() 459 | 460 | // Collection 461 | coll := bingo.CollectionFrom[TestDocument](driver, "testQueryCollection") 462 | 463 | // Insert multiple docs 464 | docs := []TestDocument{ 465 | {Name: "Apple"}, 466 | {Name: "Banana"}, 467 | {Name: "Cherry"}, 468 | } 469 | ids, err := coll.InsertMany(docs) 470 | if err != nil { 471 | t.Fatalf("Failed to insert document: %v", err) 472 | } 473 | if len(ids) != len(docs) { 474 | t.Fatalf("Unexpected number of inserted documents: %d", len(ids)) 475 | } 476 | 477 | // Query by filter 478 | query := bingo.Query[TestDocument]{ 479 | Filter: func(doc TestDocument) bool { 480 | return doc.Name == "Banana" 481 | }, 482 | } 483 | qResult := coll.Query(query) 484 | if !qResult.Any() { 485 | t.Fatalf("Query didn't return any results") 486 | } 487 | if qResult.First().Name != "Banana" { 488 | t.Fatalf("Unexpected query result: %v", qResult.First()) 489 | } 490 | 491 | // Query by keys 492 | keyQuery := bingo.Query[TestDocument]{Keys: ids[:2]} 493 | kResult := coll.Query(keyQuery) 494 | if kResult.Count() != 2 { 495 | t.Fatalf("Unexpected count for key-based query: %d", kResult.Count()) 496 | } 497 | } 498 | 499 | func TestErrorScenarios(t *testing.T) { 500 | config := bingo.DriverConfiguration{ 501 | Filename: "testerrors.db", 502 | DeleteNoVerify: true, 503 | } 504 | driver, err := bingo.NewDriver(config) 505 | if err != nil { 506 | t.Fatalf("Failed to initialize driver: %v", err) 507 | } 508 | 509 | defer func() { 510 | driver.Close() 511 | os.Remove("testerrors.db") 512 | }() 513 | 514 | coll := bingo.CollectionFrom[TestDocument](driver, "testErrorCollection") 515 | 516 | // Find non-existent document 517 | _, err = coll.FindByKey("nonexistent") 518 | if err == nil || !bingo.IsErrDocumentNotFound(err) { 519 | t.Fatalf("Expected a document not found error, got: %v", err) 520 | } 521 | } 522 | 523 | func TestMiddlewareFunctionality(t *testing.T) { 524 | config := bingo.DriverConfiguration{ 525 | Filename: "testmiddleware.db", 526 | DeleteNoVerify: true, 527 | } 528 | driver, err := bingo.NewDriver(config) 529 | if err != nil { 530 | t.Fatalf("Failed to initialize driver: %v", err) 531 | } 532 | 533 | defer func() { 534 | driver.Close() 535 | os.Remove("testmiddleware.db") 536 | }() 537 | 538 | coll := bingo.CollectionFrom[TestDocument](driver, "testMiddlewareCollection") 539 | 540 | // Before insert middleware 541 | coll.BeforeInsert(func(doc *TestDocument) error { 542 | doc.Name = "Modified" 543 | return nil 544 | }) 545 | 546 | // Insert 547 | doc := TestDocument{Document: bingo.Document{ID: "1"}, Name: "Original"} 548 | coll.Insert(doc) 549 | 550 | // Find and check if middleware modified the name 551 | foundDoc, err := coll.FindByKey("1") 552 | if err != nil { 553 | t.Fatalf("Failed to find document: %v", err) 554 | } 555 | if foundDoc.Name != "Modified" { 556 | t.Fatalf("Middleware did not modify the document name. Expected 'Modified', got: %v", foundDoc.Name) 557 | } 558 | } 559 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bingo: A Generics First Database 2 | 3 | Bingo is a Golang embedded database library that uses `bbolt` as a backend. 4 | 5 | Through the use of generics, it includes capabilities for CRUD operations, validation, preprocessing, and more. 6 | 7 | ## Features: 8 | 9 | - **Simple CRUD**: Easily create, read, update, and delete documents in your `bbolt` database. 10 | - **Validation**: Uses `go-playground/validator` for struct validation. 11 | - **Querying**: Advanced querying capabilities using filters. 12 | - **Hooks**: Execute functions before and after certain operations (Insert, Update, Delete). 13 | package main 14 | 15 | 16 | ## Installation 17 | 18 | Install the library using `go get`: 19 | 20 | ```bash 21 | go get -u github.com/nokusukun/bingo 22 | ``` 23 | 24 | ## Full Example 25 | 26 | ```go 27 | package main 28 | 29 | import ( 30 | "fmt" 31 | "github.com/nokusukun/bingo" 32 | "os" 33 | "strings" 34 | ) 35 | 36 | type Platform string 37 | 38 | const ( 39 | Excel = "excel" 40 | Sheets = "sheets" 41 | ) 42 | 43 | type Function struct { 44 | bingo.Document 45 | Name string `json:"name,omitempty"` 46 | Category string `json:"category,omitempty"` 47 | Args []string `json:"args,omitempty"` 48 | Example string `json:"example,omitempty"` 49 | Description string `json:"description,omitempty"` 50 | URL string `json:"URL,omitempty"` 51 | Platform Platform `json:"platform,omitempty"` 52 | } 53 | 54 | func main() { 55 | driver, err := bingo.NewDriver(bingo.DriverConfiguration{ 56 | Filename: "clippy.db", 57 | }) 58 | if err != nil { 59 | panic(err) 60 | } 61 | 62 | defer func() { 63 | os.Remove("clippy.db") 64 | }() 65 | 66 | functionDB := bingo.CollectionFrom[Function](driver, "functions") 67 | 68 | // Registering a custom ID generator, 69 | // this will be called when a new document is inserted 70 | // and the if the document does not have a key/id set. 71 | functionDB.OnNewId = func(_ int, doc *Function) []byte { 72 | return []byte(strings.ToLower(doc.Name)) 73 | } 74 | 75 | // Inserting 76 | key, err := functionDB.Insert(Function{ 77 | Name: "SUM", 78 | Category: "Math", 79 | Args: []string{"a", "b"}, 80 | Example: "SUM(1, 2)", 81 | Description: "Adds two numbers together", 82 | URL: "https://support.google.com/docs/answer/3093669?hl=en", 83 | Platform: Sheets, 84 | }) 85 | if err != nil { 86 | panic(err) 87 | } 88 | 89 | fmt.Println("Inserted document with key", string(key)) 90 | 91 | searchQuery := "sum" 92 | platform := "sheets" 93 | // Querying 94 | query := functionDB.Query(bingo.Query[Function]{ 95 | Filter: func(doc Function) bool { 96 | return doc.Platform == Platform(platform) && strings.Contains(strings.ToLower(doc.Name), strings.ToLower(searchQuery)) 97 | }, 98 | Count: 3, 99 | }) 100 | if query.Error != nil { 101 | panic(query.Error) 102 | } 103 | 104 | if !query.Any() { 105 | panic("No documents found!") 106 | } 107 | 108 | fmt.Println("Found", query.Count(), "documents") 109 | for _, function := range query.Items { 110 | fmt.Printf("%s: %s\n", function.Name, function.Description) 111 | } 112 | 113 | sum, err := functionDB.FindByKey("sum") 114 | if err != nil { 115 | panic(err) 116 | } 117 | sum.Category = "Algebra" 118 | err = functionDB.UpdateOne(sum) 119 | if err != nil { 120 | panic(err) 121 | } 122 | 123 | newSum, _ := functionDB.FindByBytesKey(sum.Key()) 124 | fmt.Println("Updated SUM category to", newSum.Category) 125 | fmt.Println(newSum) 126 | } 127 | 128 | ``` 129 | 130 | ## Usage: 131 | 132 | ### 1. Initialize the Driver 133 | 134 | Create a new driver instance: 135 | 136 | ```go 137 | config := bingo.DriverConfiguration{ 138 | DeleteNoVerify: false, 139 | Filename: "mydb.db", 140 | } 141 | driver, err := bingo.NewDriver(config) 142 | ``` 143 | 144 | 145 | ### 2. Define your document type 146 | You can specify an autoincrement ID by returning nil in the `Key` method. 147 | 148 | ```go 149 | type User struct { 150 | bingo.Document 151 | Username string `json:"username,omitempty" validate:"required,min=3,max=64"` 152 | Email string `json:"email,omitempty" validate:"required,email"` 153 | Password string `json:"password,omitempty" preprocessor:"password-prep" validate:"required,min=6,max=64"` 154 | } 155 | 156 | func (u *User) CheckPassword(password string) bool { 157 | current := u.Password 158 | if strings.HasPrefix(current, "hash:") { 159 | current = strings.TrimPrefix(current, "hash:") 160 | return bcrypt.CompareHashAndPassword([]byte(current), []byte(password)) == nil 161 | } 162 | return current == password 163 | } 164 | 165 | func (u *User) EnsureHashedPassword() error { 166 | if strings.HasPrefix("hash:", u.Password) { 167 | return nil 168 | } 169 | hashed, err := HashPassword(u.Password) 170 | if err != nil { 171 | return err 172 | } 173 | u.Password = fmt.Sprintf("hash:%s", hashed) 174 | return nil 175 | } 176 | ``` 177 | 178 | ### 3. Create a collection for your document type 179 | Note: Your document type must have bingo.Document as it's first field or implement the `bingo.DocumentSpec` interface. 180 | 181 | ```go 182 | users := bingo.CollectionFrom[User](driver, "users") 183 | ``` 184 | 185 | You can also add hooks: 186 | 187 | ```go 188 | users.BeforeUpdate(func(doc *User) error { 189 | return doc.EnsureHashedPassword() 190 | }) 191 | ``` 192 | 193 | ### 4. CRUD Operations 194 | 195 | **Inserting documents:** 196 | 197 | ```go 198 | id, err := users.Insert(User{ 199 | Username: "test", 200 | Password: "test123", 201 | Email: "random@rrege.com", 202 | }) 203 | if err != nil { 204 | panic(err) 205 | } 206 | 207 | fmt.Println("Inserted user with ID:", id) 208 | ``` 209 | 210 | **Inserting documents with a custom ID:** 211 | 212 | ```go 213 | id, err := users.Insert(User{ 214 | Document: bingo.Document{ 215 | ID: []byte("custom-id"), 216 | }, 217 | Username: "test", 218 | Password: "test123", 219 | Email: "random@rrege.com", 220 | }) 221 | if err != nil { 222 | panic(err) 223 | } 224 | 225 | fmt.Println("Inserted user with ID:", id) 226 | ``` 227 | 228 | 229 | **Querying documents:** 230 | 231 | ```go 232 | result := users.Query(bingo.Query[User]{ 233 | Filter: func(doc User) bool { 234 | return doc.Username == "test" 235 | }, 236 | }) 237 | if !result.Any() { 238 | panic("No user found") 239 | } 240 | ``` 241 | 242 | 243 | 244 | ### Inserting a Document 245 | 246 | ```go 247 | user := User{ 248 | Username: "john_doe", 249 | Email: "john.doe@example.com", 250 | Password: "password1234", 251 | Active: true, 252 | } 253 | 254 | // Ensure password is hashed 255 | _ = user.EnsureHashedPassword() 256 | 257 | insertResult := userCollection.Insert(user) 258 | if insertResult.Error() != nil { 259 | log.Fatalf("Failed to insert user: %v", insertResult.Error()) 260 | } 261 | ``` 262 | 263 | ### Updating a Document 264 | 265 | ```go 266 | result, err := coll.FindOne(func(doc TestDocument) bool { 267 | return doc.Name == "Apple" 268 | }) 269 | 270 | if err != nil { 271 | t.Fatalf("Failed to find document: %v", err) 272 | } 273 | 274 | result.Name = "Pineapple" 275 | err = coll.UpdateOne(result) 276 | if err != nil { 277 | t.Fatalf("Failed to update document: %v", err) 278 | } 279 | ``` 280 | #### Using Iterators 281 | 282 | ```go 283 | err := coll.UpdateIter(func(doc *TestDocument) *TestDocument { 284 | if doc.Name == "Apple" { 285 | doc.Name = "Pineapple" 286 | return doc 287 | } 288 | return nil 289 | }) 290 | ``` 291 | 292 | ### Deleting a Document 293 | 294 | ```go 295 | result, err := coll.FindOne(func(doc TestDocument) bool { 296 | return doc.Name == "Apple" 297 | }) 298 | 299 | if err != nil { 300 | t.Fatalf("Failed to find document: %v", err) 301 | } 302 | 303 | err = coll.DeleteOne(result) 304 | if err != nil { 305 | t.Fatalf("Failed to delete document: %v", err) 306 | } 307 | ``` 308 | 309 | #### Using Iterators 310 | 311 | ```go 312 | err := coll.DeleteIter(func(doc *TestDocument) bool { 313 | return doc.Name == "Apple" 314 | }) 315 | ``` 316 | 317 | ## Querying for Documents 318 | 319 | ```go 320 | result := userCollection.Query(bingo.Query[User]{ 321 | Filter: func(doc User) bool { 322 | return doc.Username == "john_doe" 323 | }, 324 | Count: 1, // Only get the first result 325 | }) 326 | if result.Any() { 327 | firstUser := result.First() 328 | fmt.Println("Found user:", firstUser.Username, firstUser.Email) 329 | } else { 330 | fmt.Println("User not found") 331 | } 332 | ``` 333 | 334 | ### Pagination 335 | 336 | ```go 337 | page1 := userCollection.Query(bingo.Query[User]{ 338 | Filter: func(doc User) bool { 339 | return doc.Active 340 | }, 341 | Count: 10, // Get 10 results 342 | }) 343 | 344 | page2 := userCollection.Query(bingo.Query[User]{ 345 | Filter: func(doc User) bool { 346 | return doc.Active 347 | }, 348 | Count: 10, // Get 10 results 349 | Skip: page1.Next, // Skip n Users, this value is returned by the previous query as QueryResult.Next 350 | }) 351 | ``` 352 | 353 | ## More on Querying 354 | 355 | ### Setting Up 356 | 357 | ```go 358 | package main 359 | 360 | import ( 361 | "fmt" 362 | "log" 363 | 364 | "github.com/nokusukun/bingodb" 365 | ) 366 | 367 | 368 | type User struct { 369 | Username string `json:"username,omitempty" validate:"required,min=3,max=64"` 370 | Email string `json:"email,omitempty" validate:"required,email"` 371 | Password string `json:"password,omitempty" preprocessor:"password-prep" validate:"required,min=6,max=64"` 372 | Active bool `json:"active,omitempty"` 373 | } 374 | 375 | func (u User) Key() []byte { 376 | return []byte(u.Username) 377 | } 378 | 379 | func (u *User) CheckPassword(password string) bool { 380 | current := u.Password 381 | if strings.HasPrefix(current, "hash:") { 382 | current = strings.TrimPrefix(current, "hash:") 383 | return bcrypt.CompareHashAndPassword([]byte(current), []byte(password)) == nil 384 | } 385 | return current == password 386 | } 387 | 388 | func (u *User) EnsureHashedPassword() error { 389 | if strings.HasPrefix("hash:", u.Password) { 390 | return nil 391 | } 392 | hashed, err := HashPassword(u.Password) 393 | if err != nil { 394 | return err 395 | } 396 | u.Password = fmt.Sprintf("hash:%s", hashed) 397 | return nil 398 | } 399 | 400 | func main() { 401 | config := bingo.DriverConfiguration{ 402 | DeleteNoVerify: true, 403 | Filename: "test.db", 404 | } 405 | driver, err := bingo.NewDriver(config) 406 | if err != nil { 407 | log.Fatalf("Failed to create new driver: %v", err) 408 | } 409 | 410 | userCollection := bingo.CollectionFrom[User](driver, "users") 411 | 412 | //... your operations ... 413 | } 414 | ``` 415 | 416 | ### Validating a Query 417 | The `Validate` method allows you to validate the query result before performing any operations on it. This is useful for checking if a document exists before updating or deleting it. 418 | 419 | ```go 420 | 421 | newText := "Hello World!" 422 | 423 | err := Posts.Query(bingo.Query[Post]{ 424 | KeysStr: []string{c.Param("id")}, // Get only the post with the given ID 425 | }).Validate(func(qr *bingo.QueryResult[Post]) error { 426 | if !qr.Any() { 427 | return fmt.Errorf("404::post not found") // Return a premature error if no post was found 428 | } 429 | return nil 430 | }).Iter(func(doc *Post) error { 431 | if doc.Author != username { 432 | return fmt.Errorf("401::you are not the author of this post") 433 | } 434 | doc.Content = newText 435 | doc.Edited = time.Now().Unix() 436 | return nil 437 | }).Update() 438 | 439 | ``` 440 | 441 | ### Further Filtering a Query result 442 | If for some reason you need to further filter the query result, you can use the `Filter` method: 443 | 444 | ```go 445 | newText := "The contents of this post has been deleted" 446 | 447 | err := Posts.Query(bingo.Query[Post]{ 448 | Filter: func(doc Post) bool { 449 | return doc.Author == username 450 | }, 451 | }).Validate(func(qr *bingo.QueryResult[Post]) error { 452 | if !qr.Any() { 453 | return fmt.Errorf("404::posts not found") // Return a premature error if no post was found 454 | } 455 | return nil 456 | }).Filter(func (doc Post) bool { 457 | return doc.Edited == 0 // Only get posts that have not been edited 458 | }).Iter(func(doc *Post) error { 459 | doc.Content = newText 460 | doc.Edited = time.Now().Unix() 461 | return nil 462 | }).Update() 463 | ``` 464 | 465 | ### Updating a Document 466 | 467 | ```go 468 | err := userCollection.Query(bingo.Query[User]{ 469 | Filter: func(doc User) bool { 470 | return doc.Username == "john_doe" 471 | }, 472 | Count: 1, // Only get the first result 473 | }).Validate(func(qr *bingo.QueryResult[Post]) error { 474 | if !qr.Any() { 475 | return fmt.Errorf("404::user not found") // Return a premature error if no post was found 476 | } 477 | return nil 478 | }).Iter(func(doc *User) error { 479 | doc.Email = "new.email@example.com" 480 | return nil 481 | }).Update() 482 | 483 | if err != nil { 484 | log.Fatalf("Failed to update user: %v", err) 485 | } 486 | ``` 487 | 488 | ### Deleting a Document 489 | 490 | ```go 491 | err := userCollection.Query(bingo.Query[User]{ 492 | Filter: func(doc User) bool { 493 | return doc.Username == "john_doe" 494 | }, 495 | Count: 1, // Only get the first result 496 | }).Validate(func(qr *bingo.QueryResult[Post]) error { 497 | if !qr.Any() { 498 | return fmt.Errorf("404::user not found") // Return a premature error if no post was found 499 | } 500 | return nil 501 | }).Delete() 502 | 503 | if err != nil { 504 | log.Fatalf("Failed to delete user: %v", err) 505 | } 506 | ``` 507 | 508 | ### Custom Operations with Middleware 509 | 510 | If you want to have custom operations before or after inserting, updating, or deleting users, you can define them as: 511 | 512 | ```go 513 | userCollection.BeforeInsert(func(u *User) error { 514 | return u.EnsureHashedPassword() 515 | }) 516 | 517 | userCollection.AfterInsert(func(u *User) error { 518 | fmt.Println("User inserted:", u.Username) 519 | return nil 520 | }) 521 | 522 | userCollection.BeforeUpdate(func(u *User) error { 523 | return u.EnsureHashedPassword() 524 | }) 525 | 526 | userCollection.AfterUpdate(func(u *User) error { 527 | fmt.Println("User updated:", u.Username) 528 | return nil 529 | }) 530 | 531 | userCollection.BeforeDelete(func(u *User) error { 532 | fmt.Println("Deleting user:", u.Username) 533 | return nil 534 | }) 535 | ``` 536 | 537 | ### Error Handling 538 | 539 | The library provides helper functions to check for specific errors: 540 | 541 | ```go 542 | err := users.Insert(newUser) 543 | if bingo.IsErrDocumentExists(err) { 544 | fmt.Println("Document already exists!") 545 | } else if bingo.IsErrDocumentNotFound(err) { 546 | fmt.Println("Document not found!") 547 | } 548 | ``` 549 | 550 | ## Important Methods 551 | 552 | ### `NewDriver()` 553 | This method initializes a new connection to the database. 554 | 555 | ### `CollectionFrom()` 556 | This is a factory method to create a new collection for a specific document type. 557 | 558 | ### `Insert()` 559 | Inserts a document into the collection. 560 | 561 | ### `Query()` 562 | Query documents using a custom filter. 563 | 564 | 565 | ## Error Handling 566 | 567 | Special error types are provided for common error scenarios: 568 | 569 | - `bingo.ErrDocumentNotFound`: When a document is not found in the collection. 570 | - `bingo.ErrDocumentExists`: When attempting to insert a document with an existing key. 571 | 572 | Helper functions like `IsErrDocumentNotFound` and `IsErrDocumentExists` are available for easy error checking. 573 | 574 | ## Safety Measures 575 | 576 | For destructive operations like `Drop`, safety checks are in place. By default, you need to set environment variables to permit such operations: 577 | 578 | ```bash 579 | export BINGO_ALLOW_DROP_MY_COLLECTION_NAME=true 580 | ``` 581 | 582 | ## Dependencies 583 | 584 | Bingo relies on the following third-party packages: 585 | 586 | - [github.com/go-playground/validator/v10](https://github.com/go-playground/validator) 587 | - [github.com/json-iterator/go](https://github.com/json-iterator/go) 588 | - [go.etcd.io/bbolt](https://github.com/etcd-io/bbolt) 589 | 590 | ## Contributing 591 | 592 | If you'd like to contribute to the development of Bingo, please submit a pull request or open an issue. 593 | 594 | ## License 595 | 596 | This library is released under the MIT License. 597 | 598 | --- 599 | 600 | Please make sure to adhere to the terms of use and licensing of the third-party dependencies if you decide to use this library. 601 | -------------------------------------------------------------------------------- /collection.go: -------------------------------------------------------------------------------- 1 | package bingo 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/bwmarrin/snowflake" 7 | "go.etcd.io/bbolt" 8 | "reflect" 9 | ) 10 | 11 | type KeyMap map[string]any 12 | 13 | func (KeyMap) Key() []byte { 14 | return nil 15 | } 16 | 17 | // DocumentSpec represents a document that can be stored in a collection. 18 | type DocumentSpec interface { 19 | Key() []byte 20 | } 21 | 22 | // Collection represents a collection of documents managed by a database driver. 23 | type Collection[DocumentType DocumentSpec] struct { 24 | Driver *Driver 25 | Name string 26 | nameBytes []byte 27 | beforeUpdate func(doc *DocumentType) error 28 | afterUpdate func(doc *DocumentType) error 29 | beforeDelete func(doc *DocumentType) error 30 | afterDelete func(doc *DocumentType) error 31 | beforeInsert func(doc *DocumentType) error 32 | afterInsert func(doc *DocumentType) error 33 | OnNewId func(count int, document *DocumentType) []byte 34 | } 35 | 36 | // BeforeUpdate registers a function to be called before a document is updated in the collection. 37 | func (c *Collection[T]) BeforeUpdate(f func(doc *T) error) *Collection[T] { 38 | c.beforeUpdate = f 39 | return c 40 | } 41 | 42 | // AfterUpdate registers a function to be called after a document is updated in the collection. 43 | func (c *Collection[T]) AfterUpdate(f func(doc *T) error) *Collection[T] { 44 | c.afterUpdate = f 45 | return c 46 | } 47 | 48 | // BeforeDelete registers a function to be called before a document is deleted from the collection. 49 | func (c *Collection[T]) BeforeDelete(f func(doc *T) error) *Collection[T] { 50 | c.beforeDelete = f 51 | return c 52 | } 53 | 54 | // AfterDelete registers a function to be called after a document is deleted from the collection. 55 | func (c *Collection[T]) AfterDelete(f func(doc *T) error) *Collection[T] { 56 | c.afterDelete = f 57 | return c 58 | } 59 | 60 | // BeforeInsert registers a function to be called before a document is inserted into the collection. 61 | func (c *Collection[T]) BeforeInsert(f func(doc *T) error) *Collection[T] { 62 | c.beforeInsert = f 63 | return c 64 | } 65 | 66 | // AfterInsert registers a function to be called after a document is inserted into the collection. 67 | func (c *Collection[T]) AfterInsert(f func(doc *T) error) *Collection[T] { 68 | c.afterInsert = f 69 | return c 70 | } 71 | 72 | type InsertOptions struct { 73 | IgnoreErrors bool 74 | Upsert bool 75 | } 76 | 77 | func IgnoreErrors(opts *InsertOptions) { 78 | opts.IgnoreErrors = true 79 | } 80 | 81 | func Upsert(opts *InsertOptions) { 82 | opts.Upsert = true 83 | } 84 | 85 | // Insert inserts a document into the collection. If upsert and ignoreErrors are not set, an error is returned if the document already exists. 86 | // If IgnoreErrors is passed without Upsert, the document is not inserted and no error is returned if the document already exists. 87 | func (c *Collection[T]) Insert(document T, opts ...func(options *InsertOptions)) ([]byte, error) { 88 | ids, err := c.inserts([]T{document}, opts...) 89 | if err != nil { 90 | return nil, err 91 | } 92 | if len(ids) == 0 { 93 | return nil, nil 94 | } 95 | return ids[0], nil 96 | } 97 | 98 | // InsertMany inserts a document into the collection. If the document already exists, an error is returned. 99 | func (c *Collection[T]) InsertMany(documents []T, opts ...func(options *InsertOptions)) ([][]byte, error) { 100 | return c.inserts(documents, opts...) 101 | } 102 | 103 | func (c *Collection[T]) inserts(docs []T, opts ...func(options *InsertOptions)) ([][]byte, error) { 104 | opt := &InsertOptions{} 105 | for _, o := range opts { 106 | o(opt) 107 | } 108 | 109 | var results [][]byte 110 | err := c.Driver.db.Update(func(tx *bbolt.Tx) error { 111 | bucket, err := tx.CreateBucketIfNotExists(c.nameBytes) 112 | if err != nil { 113 | return err 114 | } 115 | 116 | for _, doc := range docs { 117 | id, err := c.insertWithTx(bucket, doc, opt) 118 | if !opt.IgnoreErrors && err != nil { 119 | return err 120 | } 121 | results = append(results, id) 122 | } 123 | return nil 124 | }) 125 | 126 | return results, err 127 | } 128 | 129 | func (c *Collection[T]) insertWithTx(bucket *bbolt.Bucket, doc T, opt *InsertOptions) ([]byte, error) { 130 | if !opt.Upsert { 131 | _, err := c.FindByBytesKey(doc.Key()) 132 | if err == nil { 133 | return nil, ErrDocumentExists 134 | } 135 | } 136 | 137 | if err := c.Driver.val.Struct(doc); err != nil { 138 | return nil, err 139 | } 140 | 141 | if c.beforeInsert != nil { 142 | err := c.beforeInsert(&doc) 143 | if err != nil { 144 | return nil, err 145 | } 146 | } 147 | 148 | idBytes := c.getKey(bucket, &doc) 149 | 150 | marshal, err := Marshaller.Marshal(doc) 151 | if err != nil { 152 | return nil, err 153 | } 154 | 155 | err = bucket.Put(idBytes, marshal) 156 | 157 | if err != nil { 158 | return nil, err 159 | } 160 | 161 | if c.afterInsert != nil { 162 | err := c.afterInsert(&doc) 163 | if err != nil { 164 | return nil, err 165 | } 166 | } 167 | 168 | return idBytes, nil 169 | } 170 | 171 | var node *snowflake.Node 172 | 173 | func (c *Collection[T]) getKey(bucket *bbolt.Bucket, doc *T) []byte { 174 | if node == nil { 175 | var err error 176 | node, err = snowflake.NewNode(1) 177 | if err != nil { 178 | panic(fmt.Errorf("unable to create snowflake node: %v", err)) 179 | } 180 | } 181 | var idBytes []byte 182 | 183 | key := (*doc).Key() 184 | if len(key) == 0 { 185 | idBytes = []byte(node.Generate().Base58()) 186 | if c.OnNewId != nil { 187 | idBytes = c.OnNewId(bucket.Stats().KeyN, doc) 188 | } 189 | reflect.ValueOf(doc).Elem().FieldByName("ID").SetString(string(idBytes)) 190 | } else { 191 | idBytes = key 192 | } 193 | return idBytes 194 | } 195 | 196 | func (c *Collection[T]) FindOneWithKey(filter func(doc T) bool) (T, []byte, error) { 197 | var empty T 198 | r, keys, _, err := c.queryFind(Query[T]{ 199 | Filter: filter, 200 | }) 201 | 202 | if err != nil { 203 | return empty, nil, err 204 | } 205 | 206 | if len(r) == 0 { 207 | return empty, nil, errors.Join(ErrDocumentNotFound, fmt.Errorf("document not found")) 208 | } 209 | 210 | return r[0], keys[0], err 211 | } 212 | 213 | func (c *Collection[T]) FindOne(filter func(doc T) bool) (T, error) { 214 | r, _, err := c.FindOneWithKey(filter) 215 | return r, err 216 | } 217 | 218 | type IterOptsFunc func(opts *iterOpts) 219 | 220 | type iterOpts struct { 221 | Skip int 222 | Count int 223 | } 224 | 225 | func applyOpts[T DocumentSpec](query *Query[T], opts ...IterOptsFunc) { 226 | options := iterOpts{} 227 | for _, opt := range opts { 228 | opt(&options) 229 | } 230 | query.Skip = options.Skip 231 | query.Count = options.Count 232 | } 233 | 234 | func Skip(skip int) IterOptsFunc { 235 | return func(opts *iterOpts) { 236 | opts.Skip = skip 237 | } 238 | } 239 | 240 | func Count(count int) IterOptsFunc { 241 | return func(opts *iterOpts) { 242 | opts.Count = count 243 | } 244 | } 245 | 246 | func (c *Collection[T]) FindWithKeys(filter func(doc T) bool, opts ...IterOptsFunc) ([]T, [][]byte, error) { 247 | q := Query[T]{ 248 | Filter: filter, 249 | } 250 | applyOpts[T](&q, opts...) 251 | 252 | r, keys, _, err := c.queryFind(q) 253 | 254 | if err != nil { 255 | return nil, nil, err 256 | } 257 | 258 | if len(r) == 0 { 259 | return nil, nil, errors.Join(ErrDocumentNotFound, fmt.Errorf("document not found")) 260 | } 261 | 262 | return r, keys, err 263 | } 264 | 265 | func (c *Collection[T]) Find(filter func(doc T) bool, opts ...IterOptsFunc) ([]T, error) { 266 | r, _, err := c.FindWithKeys(filter, opts...) 267 | 268 | return r, err 269 | } 270 | 271 | func (c *Collection[T]) Delete(docs ...T) error { 272 | for _, doc := range docs { 273 | if err := c.DeleteOne(doc); err != nil { 274 | return err 275 | } 276 | } 277 | return nil 278 | } 279 | 280 | func (c *Collection[T]) Update(docs ...T) error { 281 | for _, doc := range docs { 282 | if err := c.UpdateOne(doc); err != nil { 283 | return err 284 | } 285 | } 286 | return nil 287 | } 288 | 289 | // Deprecated: FindByBytesId retrieves a document from the collection by its id. If the document is not found, an error is returned. 290 | // Use FindByBytesKey instead 291 | func (c *Collection[T]) FindByBytesId(id []byte) (T, error) { 292 | var document T 293 | r := c.queryKeys(id) 294 | if len(r) == 0 { 295 | return document, errors.Join(ErrDocumentNotFound, fmt.Errorf("document with id %v not found", string(id))) 296 | } 297 | document = r[0] 298 | return document, nil 299 | } 300 | 301 | // FindByBytesKey retrieves a document from the collection by its id. If the document is not found, an error is returned. 302 | func (c *Collection[T]) FindByBytesKey(id []byte) (T, error) { 303 | var document T 304 | r := c.queryKeys(id) 305 | if len(r) == 0 { 306 | return document, errors.Join(ErrDocumentNotFound, fmt.Errorf("document with id %v not found", string(id))) 307 | } 308 | document = r[0] 309 | return document, nil 310 | } 311 | 312 | // Deprecated: FindByBytesIds retrieves documents from the collection by their ids. If the document is not found, an empty list is returned. 313 | // Use FindByBytesKeys instead 314 | func (c *Collection[T]) FindByBytesIds(ids ...[]byte) []T { 315 | return c.queryKeys(ids...) 316 | } 317 | 318 | // FindByBytesKeys retrieves documents from the collection by their ids. If the document is not found, an empty list is returned. 319 | func (c *Collection[T]) FindByBytesKeys(ids ...[]byte) []T { 320 | return c.queryKeys(ids...) 321 | } 322 | 323 | // Deprecated: FindById retrieves a document from the collection by its id. If the document is not found, an error is returned. 324 | // Use FindByKey instead 325 | func (c *Collection[T]) FindById(id string) (T, error) { 326 | var document T 327 | r := c.queryKeys([]byte(id)) 328 | if len(r) == 0 { 329 | return document, errors.Join(ErrDocumentNotFound, fmt.Errorf("document with id %v not found", string(id))) 330 | } 331 | document = r[0] 332 | return document, nil 333 | } 334 | 335 | // FindByKey retrieves a document from the collection by its id. If the document is not found, an error is returned. 336 | func (c *Collection[T]) FindByKey(id string) (T, error) { 337 | var document T 338 | r := c.queryKeys([]byte(id)) 339 | if len(r) == 0 { 340 | return document, errors.Join(ErrDocumentNotFound, fmt.Errorf("document with id %v not found", string(id))) 341 | } 342 | document = r[0] 343 | return document, nil 344 | } 345 | 346 | // Deprecated: FindByIds retrieves documents from the collection by their ids. If the document is not found, an empty list is returned. 347 | // Use FindByKeys instead 348 | func (c *Collection[T]) FindByIds(ids ...string) []T { 349 | idsBytes := make([][]byte, len(ids)) 350 | for i, id := range ids { 351 | idsBytes[i] = []byte(id) 352 | } 353 | return c.queryKeys(idsBytes...) 354 | } 355 | 356 | // FindByKeys retrieves documents from the collection by their ids. If the document is not found, an empty list is returned. 357 | func (c *Collection[T]) FindByKeys(ids ...string) []T { 358 | idsBytes := make([][]byte, len(ids)) 359 | for i, id := range ids { 360 | idsBytes[i] = []byte(id) 361 | } 362 | return c.queryKeys(idsBytes...) 363 | } 364 | 365 | // UpdateIter updates documents in the collection that match the filter function. 366 | // The updateFunc is called on each document that matches the filter function. 367 | // return the document from the updateFunc to update the document, otherwise return nil to skip the document. 368 | func (c *Collection[T]) UpdateIter(updateFunc func(*T) *T) error { 369 | 370 | return c.Driver.db.Update(func(tx *bbolt.Tx) error { 371 | bucket := tx.Bucket(c.nameBytes) 372 | if bucket == nil { 373 | return fmt.Errorf("bucket %s not found", c.Name) 374 | } 375 | wbucket := &WrappedBucket{bucket} 376 | return wbucket.ReverseIter(func(k, v []byte) error { 377 | var document T 378 | err := Unmarshaller.Unmarshal(v, &document) 379 | if err != nil { 380 | return err 381 | } 382 | newDocument := updateFunc(&document) 383 | if newDocument == nil { 384 | return nil 385 | } 386 | if c.beforeUpdate != nil { 387 | err := c.beforeUpdate(newDocument) 388 | if err != nil { 389 | return err 390 | } 391 | } 392 | 393 | marshal, err := Marshaller.Marshal(newDocument) 394 | if err != nil { 395 | return err 396 | } 397 | err = bucket.Put(document.Key(), marshal) 398 | if err != nil { 399 | return err 400 | } 401 | 402 | if c.afterUpdate != nil { 403 | err := c.afterUpdate(newDocument) 404 | if err != nil { 405 | return err 406 | } 407 | } 408 | return nil 409 | }) 410 | }) 411 | } 412 | 413 | // DeleteIter deletes documents from the collection that match the filter function. 414 | // The deleteFunc is called on each document that matches the filter function. 415 | // return true from the deleteFunc to delete the document, otherwise return false to skip the document. 416 | func (c *Collection[T]) DeleteIter(deleteFunc func(*T) bool) error { 417 | 418 | return c.Driver.db.Update(func(tx *bbolt.Tx) error { 419 | bucket := tx.Bucket(c.nameBytes) 420 | if bucket == nil { 421 | return fmt.Errorf("bucket %s not found", c.Name) 422 | } 423 | wbucket := &WrappedBucket{bucket} 424 | return wbucket.ReverseIter(func(k, v []byte) error { 425 | var document T 426 | err := Unmarshaller.Unmarshal(v, &document) 427 | if err != nil { 428 | return err 429 | } 430 | if !deleteFunc(&document) { 431 | return nil 432 | } 433 | if c.beforeDelete != nil { 434 | err := c.beforeDelete(&document) 435 | if err != nil { 436 | return err 437 | } 438 | } 439 | 440 | err = bucket.Delete(document.Key()) 441 | if err != nil { 442 | return err 443 | } 444 | 445 | if c.afterDelete != nil { 446 | err := c.afterDelete(&document) 447 | if err != nil { 448 | return err 449 | } 450 | } 451 | return nil 452 | }) 453 | }) 454 | } 455 | 456 | // UpdateOne updates a document in the collection. 457 | func (c *Collection[T]) UpdateOne(doc T) error { 458 | if c.beforeUpdate != nil { 459 | err := c.beforeUpdate(&doc) 460 | if err != nil { 461 | return err 462 | } 463 | } 464 | 465 | marshal, err := Marshaller.Marshal(doc) 466 | if err != nil { 467 | return err 468 | } 469 | 470 | err = c.Driver.db.Update(func(tx *bbolt.Tx) error { 471 | bucket := tx.Bucket(c.nameBytes) 472 | if bucket == nil { 473 | return fmt.Errorf("bucket %s not found", c.Name) 474 | } 475 | return bucket.Put(doc.Key(), marshal) 476 | }) 477 | if err != nil { 478 | return err 479 | } 480 | 481 | if c.afterUpdate != nil { 482 | err := c.afterUpdate(&doc) 483 | if err != nil { 484 | return err 485 | } 486 | } 487 | return nil 488 | } 489 | 490 | // DeleteOne deletes a document from the collection. 491 | func (c *Collection[T]) DeleteOne(doc T) error { 492 | if c.beforeDelete != nil { 493 | err := c.beforeDelete(&doc) 494 | if err != nil { 495 | return err 496 | } 497 | } 498 | 499 | err := c.Driver.db.Update(func(tx *bbolt.Tx) error { 500 | bucket := tx.Bucket(c.nameBytes) 501 | if bucket == nil { 502 | return fmt.Errorf("bucket %s not found", c.Name) 503 | } 504 | return bucket.Delete(doc.Key()) 505 | }) 506 | if err != nil { 507 | return err 508 | } 509 | 510 | if c.afterDelete != nil { 511 | err := c.afterDelete(&doc) 512 | if err != nil { 513 | return err 514 | } 515 | } 516 | return nil 517 | } 518 | 519 | var stoperr = fmt.Errorf("stop") 520 | 521 | func (c *Collection[DocumentType]) queryKeys(keys ...[]byte) []DocumentType { 522 | var documents []DocumentType 523 | _ = c.Driver.db.View(func(tx *bbolt.Tx) error { 524 | bucket := tx.Bucket(c.nameBytes) 525 | if bucket == nil { 526 | return fmt.Errorf("bucket %s not found", c.Name) 527 | } 528 | for _, key := range keys { 529 | value := bucket.Get(key) 530 | if value == nil { 531 | continue 532 | } 533 | var document DocumentType 534 | err := Unmarshaller.Unmarshal(value, &document) 535 | if err != nil { 536 | continue 537 | } 538 | documents = append(documents, document) 539 | } 540 | return nil 541 | }) 542 | return documents 543 | } 544 | 545 | func (c *Collection[T]) queryFind(q Query[T]) ([]T, [][]byte, int, error) { 546 | var documents []T 547 | var keys [][]byte 548 | var currentFound = 0 549 | var last = 0 550 | err := c.Driver.db.View(func(tx *bbolt.Tx) error { 551 | bucket := tx.Bucket(c.nameBytes) 552 | if bucket == nil { 553 | return fmt.Errorf("bucket %s not found", c.Name) 554 | } 555 | wbucket := &WrappedBucket{bucket} 556 | return wbucket.ReverseIter(func(k, v []byte) error { 557 | last += 1 558 | if last <= q.Skip { 559 | return nil 560 | } 561 | var document T 562 | err := Unmarshaller.Unmarshal(v, &document) 563 | if err != nil { 564 | return err 565 | } 566 | if q.Filter(document) { 567 | documents = append(documents, document) 568 | keys = append(keys, k) 569 | currentFound += 1 570 | if q.Count > 0 && currentFound >= q.Count { 571 | return stoperr 572 | } 573 | } 574 | return nil 575 | }) 576 | }) 577 | if err != nil && !errors.Is(err, stoperr) { 578 | return documents, keys, last, err 579 | } else { 580 | return documents, keys, last, nil 581 | } 582 | } 583 | 584 | // Query executes the query and returns a QueryResult object that contains the results of the query. 585 | func (c *Collection[T]) Query(q Query[T]) *QueryResult[T] { 586 | if q.Keys != nil && q.Filter != nil { 587 | panic(fmt.Errorf("cannot use both key and filter")) 588 | } 589 | 590 | if len(q.KeysStr) > 0 { 591 | keys := make([][]byte, len(q.KeysStr)) 592 | for i, key := range q.KeysStr { 593 | keys[i] = []byte(key) 594 | } 595 | q.Keys = append(q.Keys, keys...) 596 | } 597 | 598 | result := &QueryResult[T]{ 599 | Collection: c, 600 | } 601 | if q.Keys != nil { 602 | items := c.queryKeys(q.Keys...) 603 | for i, item := range items { 604 | item := item 605 | result.Items = append(result.Items, &item) 606 | result.Keys = append(result.Keys, q.Keys[i]) 607 | } 608 | return result 609 | } 610 | 611 | if q.Filter != nil { 612 | items, keys, last, err := c.queryFind(q) 613 | if err != nil { 614 | result.Error = errors.Join(err, fmt.Errorf("error while querying")) 615 | } 616 | result.Next = last 617 | for i, item := range items { 618 | item := item 619 | result.Items = append(result.Items, &item) 620 | result.Keys = append(result.Keys, keys[i]) 621 | } 622 | return result 623 | } 624 | 625 | result.Error = fmt.Errorf("no query provided") 626 | return result 627 | } 628 | --------------------------------------------------------------------------------