diff --git a/objectbox/README.md b/objectbox/README.md new file mode 100644 index 00000000..3ae59adb --- /dev/null +++ b/objectbox/README.md @@ -0,0 +1,117 @@ +--- +id: objectbox +title: ObjectBox +--- + +![Release](https://img.shields.io/github/v/tag/gofiber/storage?filter=objectbox*) +[![Discord](https://img.shields.io/discord/704680098577514527?style=flat&label=%F0%9F%92%AC%20discord&color=00ACD7)](https://gofiber.io/discord) +![Test](https://img.shields.io/github/actions/workflow/status/gofiber/storage/test-objectbox.yml?label=Tests) +![Security](https://img.shields.io/github/actions/workflow/status/gofiber/storage/gosec.yml?label=Security) +![Linter](https://img.shields.io/github/actions/workflow/status/gofiber/storage/linter.yml?label=Linter) + +An ObjectBox storage driver using [objectbox/objectbox-go](https://github.com/objectbox/objectbox-go). + +### Table of Contents + +- [Signatures](#signatures) +- [Installation](#installation) +- [Examples](#examples) +- [Config](#config) +- [Default Config](#default-config) + +### Signatures + +```go +func New(config ...Config) Storage +func (s *Storage) Get(key string) ([]byte, error) +func (s *Storage) Set(key string, val []byte, exp time.Duration) error +func (s *Storage) Delete(key string) error +func (s *Storage) Reset() error +func (s *Storage) Close() error +``` + +### Installation + +[Install ojectbox](https://golang.objectbox.io/install) + +```bash +bash <(curl -s https://raw.githubusercontent.com/objectbox/objectbox-go/main/install.sh) +``` +Init your module + +```bash +go mod init github.com// +``` + +And then install the objectbox implementation: + +```bash +go get github.com/gofiber/storage/objectbox/v2 +``` + +### Examples + +Import the storage package. + +```go +import "github.com/gofiber/storage/objectbox/v2" +``` + +You can use the following possibilities to create a storage: + +```go +// Initialize default config +store := objectbox.New() + +// Initialize custom config +store := objectbox.New(objectbox.Config{ + Directory: "objectbox_db", + MaxSizeInKb: 1024 * 1024, // 1GB + MaxReaders: 126, + Reset: false, + CleanerInterval: 60 * time.Second, +}) +``` + +### Config + +```go +type Config struct { + // Directory is the path where the database is stored. + // + // Optional, defaults to "objectbox_db" + Directory string + + // MaxSizeInKb sets the maximum size of the database in kilobytes. + // + // Optional, defaults to 1GB (1024 * 1024) + MaxSizeInKb uint64 + + // MaxReaders defines the maximum number of concurrent readers. + // + // Optional, defaults to 126 + MaxReaders uint + + // Reset determines if existing keys should be cleared on startup. + // + // Optional, defaults to false + Reset bool + + // CleanerInterval sets the frequency for deleting expired keys. + // + // Optional, defaults to 60 seconds + CleanerInterval time.Duration +} +``` + +### Default Config + +```go +var DefaultConfig = Config{ + Directory: "objectbox_db", + MaxSizeInKb: 1024 * 1024, // 1GB + MaxReaders: 126, + Reset: false, + CleanerInterval: 60 * time.Second, +} +``` diff --git a/objectbox/cache.go b/objectbox/cache.go new file mode 100644 index 00000000..0b7200ed --- /dev/null +++ b/objectbox/cache.go @@ -0,0 +1,11 @@ +package objectbox + +//go:generate go run github.com/objectbox/objectbox-go/cmd/objectbox-gogen + +// Cache represents a single cache entry in the storage. +type Cache struct { + Id uint64 `objectbox:"id"` + Key string `objectbox:"index,unique"` + Value []byte + ExpiresAt int64 `objectbox:"index,date"` +} diff --git a/objectbox/cache.obx.go b/objectbox/cache.obx.go new file mode 100644 index 00000000..117abb2e --- /dev/null +++ b/objectbox/cache.obx.go @@ -0,0 +1,364 @@ +// Code generated by ObjectBox; DO NOT EDIT. +// Learn more about defining entities and generating this file - visit https://golang.objectbox.io/entity-annotations + +package objectbox + +import ( + "errors" + "github.com/google/flatbuffers/go" + "github.com/objectbox/objectbox-go/objectbox" + "github.com/objectbox/objectbox-go/objectbox/fbutils" +) + +type cache_EntityInfo struct { + objectbox.Entity + Uid uint64 +} + +var CacheBinding = cache_EntityInfo{ + Entity: objectbox.Entity{ + Id: 1, + }, + Uid: 8543848339172790713, +} + +// Cache_ contains type-based Property helpers to facilitate some common operations such as Queries. +var Cache_ = struct { + Id *objectbox.PropertyUint64 + Key *objectbox.PropertyString + Value *objectbox.PropertyByteVector + ExpiresAt *objectbox.PropertyInt64 +}{ + Id: &objectbox.PropertyUint64{ + BaseProperty: &objectbox.BaseProperty{ + Id: 1, + Entity: &CacheBinding.Entity, + }, + }, + Key: &objectbox.PropertyString{ + BaseProperty: &objectbox.BaseProperty{ + Id: 2, + Entity: &CacheBinding.Entity, + }, + }, + Value: &objectbox.PropertyByteVector{ + BaseProperty: &objectbox.BaseProperty{ + Id: 3, + Entity: &CacheBinding.Entity, + }, + }, + ExpiresAt: &objectbox.PropertyInt64{ + BaseProperty: &objectbox.BaseProperty{ + Id: 4, + Entity: &CacheBinding.Entity, + }, + }, +} + +// GeneratorVersion is called by ObjectBox to verify the compatibility of the generator used to generate this code +func (cache_EntityInfo) GeneratorVersion() int { + return 6 +} + +// AddToModel is called by ObjectBox during model build +func (cache_EntityInfo) AddToModel(model *objectbox.Model) { + model.Entity("Cache", 1, 8543848339172790713) + model.Property("Id", 6, 1, 3986202391135829849) + model.PropertyFlags(1) + model.Property("Key", 9, 2, 6255868849552810122) + model.PropertyFlags(2080) + model.PropertyIndex(1, 4154314791709387436) + model.Property("Value", 23, 3, 441219408181596061) + model.Property("ExpiresAt", 10, 4, 6145573473554079304) + model.PropertyFlags(8) + model.PropertyIndex(2, 8378671756205806266) + model.EntityLastPropertyId(4, 6145573473554079304) +} + +// GetId is called by ObjectBox during Put operations to check for existing ID on an object +func (cache_EntityInfo) GetId(object interface{}) (uint64, error) { + return object.(*Cache).Id, nil +} + +// SetId is called by ObjectBox during Put to update an ID on an object that has just been inserted +func (cache_EntityInfo) SetId(object interface{}, id uint64) error { + object.(*Cache).Id = id + return nil +} + +// PutRelated is called by ObjectBox to put related entities before the object itself is flattened and put +func (cache_EntityInfo) PutRelated(ob *objectbox.ObjectBox, object interface{}, id uint64) error { + return nil +} + +// Flatten is called by ObjectBox to transform an object to a FlatBuffer +func (cache_EntityInfo) Flatten(object interface{}, fbb *flatbuffers.Builder, id uint64) error { + obj := object.(*Cache) + var offsetKey = fbutils.CreateStringOffset(fbb, obj.Key) + var offsetValue = fbutils.CreateByteVectorOffset(fbb, obj.Value) + + // build the FlatBuffers object + fbb.StartObject(4) + fbutils.SetUint64Slot(fbb, 0, id) + fbutils.SetUOffsetTSlot(fbb, 1, offsetKey) + fbutils.SetUOffsetTSlot(fbb, 2, offsetValue) + fbutils.SetInt64Slot(fbb, 3, obj.ExpiresAt) + return nil +} + +// Load is called by ObjectBox to load an object from a FlatBuffer +func (cache_EntityInfo) Load(ob *objectbox.ObjectBox, bytes []byte) (interface{}, error) { + if len(bytes) == 0 { // sanity check, should "never" happen + return nil, errors.New("can't deserialize an object of type 'Cache' - no data received") + } + + var table = &flatbuffers.Table{ + Bytes: bytes, + Pos: flatbuffers.GetUOffsetT(bytes), + } + + var propId = table.GetUint64Slot(4, 0) + + return &Cache{ + Id: propId, + Key: fbutils.GetStringSlot(table, 6), + Value: fbutils.GetByteVectorSlot(table, 8), + ExpiresAt: fbutils.GetInt64Slot(table, 10), + }, nil +} + +// MakeSlice is called by ObjectBox to construct a new slice to hold the read objects +func (cache_EntityInfo) MakeSlice(capacity int) interface{} { + return make([]*Cache, 0, capacity) +} + +// AppendToSlice is called by ObjectBox to fill the slice of the read objects +func (cache_EntityInfo) AppendToSlice(slice interface{}, object interface{}) interface{} { + if object == nil { + return append(slice.([]*Cache), nil) + } + return append(slice.([]*Cache), object.(*Cache)) +} + +// Box provides CRUD access to Cache objects +type CacheBox struct { + *objectbox.Box +} + +// BoxForCache opens a box of Cache objects +func BoxForCache(ob *objectbox.ObjectBox) *CacheBox { + return &CacheBox{ + Box: ob.InternalBox(1), + } +} + +// Put synchronously inserts/updates a single object. +// In case the Id is not specified, it would be assigned automatically (auto-increment). +// When inserting, the Cache.Id property on the passed object will be assigned the new ID as well. +func (box *CacheBox) Put(object *Cache) (uint64, error) { + return box.Box.Put(object) +} + +// Insert synchronously inserts a single object. As opposed to Put, Insert will fail if given an ID that already exists. +// In case the Id is not specified, it would be assigned automatically (auto-increment). +// When inserting, the Cache.Id property on the passed object will be assigned the new ID as well. +func (box *CacheBox) Insert(object *Cache) (uint64, error) { + return box.Box.Insert(object) +} + +// Update synchronously updates a single object. +// As opposed to Put, Update will fail if an object with the same ID is not found in the database. +func (box *CacheBox) Update(object *Cache) error { + return box.Box.Update(object) +} + +// PutAsync asynchronously inserts/updates a single object. +// Deprecated: use box.Async().Put() instead +func (box *CacheBox) PutAsync(object *Cache) (uint64, error) { + return box.Box.PutAsync(object) +} + +// PutMany inserts multiple objects in single transaction. +// In case Ids are not set on the objects, they would be assigned automatically (auto-increment). +// +// Returns: IDs of the put objects (in the same order). +// When inserting, the Cache.Id property on the objects in the slice will be assigned the new IDs as well. +// +// Note: In case an error occurs during the transaction, some of the objects may already have the Cache.Id assigned +// even though the transaction has been rolled back and the objects are not stored under those IDs. +// +// Note: The slice may be empty or even nil; in both cases, an empty IDs slice and no error is returned. +func (box *CacheBox) PutMany(objects []*Cache) ([]uint64, error) { + return box.Box.PutMany(objects) +} + +// Get reads a single object. +// +// Returns nil (and no error) in case the object with the given ID doesn't exist. +func (box *CacheBox) Get(id uint64) (*Cache, error) { + object, err := box.Box.Get(id) + if err != nil { + return nil, err + } else if object == nil { + return nil, nil + } + return object.(*Cache), nil +} + +// GetMany reads multiple objects at once. +// If any of the objects doesn't exist, its position in the return slice is nil +func (box *CacheBox) GetMany(ids ...uint64) ([]*Cache, error) { + objects, err := box.Box.GetMany(ids...) + if err != nil { + return nil, err + } + return objects.([]*Cache), nil +} + +// GetManyExisting reads multiple objects at once, skipping those that do not exist. +func (box *CacheBox) GetManyExisting(ids ...uint64) ([]*Cache, error) { + objects, err := box.Box.GetManyExisting(ids...) + if err != nil { + return nil, err + } + return objects.([]*Cache), nil +} + +// GetAll reads all stored objects +func (box *CacheBox) GetAll() ([]*Cache, error) { + objects, err := box.Box.GetAll() + if err != nil { + return nil, err + } + return objects.([]*Cache), nil +} + +// Remove deletes a single object +func (box *CacheBox) Remove(object *Cache) error { + return box.Box.Remove(object) +} + +// RemoveMany deletes multiple objects at once. +// Returns the number of deleted object or error on failure. +// Note that this method will not fail if an object is not found (e.g. already removed). +// In case you need to strictly check whether all of the objects exist before removing them, +// you can execute multiple box.Contains() and box.Remove() inside a single write transaction. +func (box *CacheBox) RemoveMany(objects ...*Cache) (uint64, error) { + var ids = make([]uint64, len(objects)) + for k, object := range objects { + ids[k] = object.Id + } + return box.Box.RemoveIds(ids...) +} + +// Creates a query with the given conditions. Use the fields of the Cache_ struct to create conditions. +// Keep the *CacheQuery if you intend to execute the query multiple times. +// Note: this function panics if you try to create illegal queries; e.g. use properties of an alien type. +// This is typically a programming error. Use QueryOrError instead if you want the explicit error check. +func (box *CacheBox) Query(conditions ...objectbox.Condition) *CacheQuery { + return &CacheQuery{ + box.Box.Query(conditions...), + } +} + +// Creates a query with the given conditions. Use the fields of the Cache_ struct to create conditions. +// Keep the *CacheQuery if you intend to execute the query multiple times. +func (box *CacheBox) QueryOrError(conditions ...objectbox.Condition) (*CacheQuery, error) { + if query, err := box.Box.QueryOrError(conditions...); err != nil { + return nil, err + } else { + return &CacheQuery{query}, nil + } +} + +// Async provides access to the default Async Box for asynchronous operations. See CacheAsyncBox for more information. +func (box *CacheBox) Async() *CacheAsyncBox { + return &CacheAsyncBox{AsyncBox: box.Box.Async()} +} + +// CacheAsyncBox provides asynchronous operations on Cache objects. +// +// Asynchronous operations are executed on a separate internal thread for better performance. +// +// There are two main use cases: +// +// 1) "execute & forget:" you gain faster put/remove operations as you don't have to wait for the transaction to finish. +// +// 2) Many small transactions: if your write load is typically a lot of individual puts that happen in parallel, +// this will merge small transactions into bigger ones. This results in a significant gain in overall throughput. +// +// In situations with (extremely) high async load, an async method may be throttled (~1ms) or delayed up to 1 second. +// In the unlikely event that the object could still not be enqueued (full queue), an error will be returned. +// +// Note that async methods do not give you hard durability guarantees like the synchronous Box provides. +// There is a small time window in which the data may not have been committed durably yet. +type CacheAsyncBox struct { + *objectbox.AsyncBox +} + +// AsyncBoxForCache creates a new async box with the given operation timeout in case an async queue is full. +// The returned struct must be freed explicitly using the Close() method. +// It's usually preferable to use CacheBox::Async() which takes care of resource management and doesn't require closing. +func AsyncBoxForCache(ob *objectbox.ObjectBox, timeoutMs uint64) *CacheAsyncBox { + var async, err = objectbox.NewAsyncBox(ob, 1, timeoutMs) + if err != nil { + panic("Could not create async box for entity ID 1: %s" + err.Error()) + } + return &CacheAsyncBox{AsyncBox: async} +} + +// Put inserts/updates a single object asynchronously. +// When inserting a new object, the Id property on the passed object will be assigned the new ID the entity would hold +// if the insert is ultimately successful. The newly assigned ID may not become valid if the insert fails. +func (asyncBox *CacheAsyncBox) Put(object *Cache) (uint64, error) { + return asyncBox.AsyncBox.Put(object) +} + +// Insert a single object asynchronously. +// The Id property on the passed object will be assigned the new ID the entity would hold if the insert is ultimately +// successful. The newly assigned ID may not become valid if the insert fails. +// Fails silently if an object with the same ID already exists (this error is not returned). +func (asyncBox *CacheAsyncBox) Insert(object *Cache) (id uint64, err error) { + return asyncBox.AsyncBox.Insert(object) +} + +// Update a single object asynchronously. +// The object must already exists or the update fails silently (without an error returned). +func (asyncBox *CacheAsyncBox) Update(object *Cache) error { + return asyncBox.AsyncBox.Update(object) +} + +// Remove deletes a single object asynchronously. +func (asyncBox *CacheAsyncBox) Remove(object *Cache) error { + return asyncBox.AsyncBox.Remove(object) +} + +// Query provides a way to search stored objects +// +// For example, you can find all Cache which Id is either 42 or 47: +// +// box.Query(Cache_.Id.In(42, 47)).Find() +type CacheQuery struct { + *objectbox.Query +} + +// Find returns all objects matching the query +func (query *CacheQuery) Find() ([]*Cache, error) { + objects, err := query.Query.Find() + if err != nil { + return nil, err + } + return objects.([]*Cache), nil +} + +// Offset defines the index of the first object to process (how many objects to skip) +func (query *CacheQuery) Offset(offset uint64) *CacheQuery { + query.Query.Offset(offset) + return query +} + +// Limit sets the number of elements to process by the query +func (query *CacheQuery) Limit(limit uint64) *CacheQuery { + query.Query.Limit(limit) + return query +} diff --git a/objectbox/config.go b/objectbox/config.go new file mode 100644 index 00000000..df71750a --- /dev/null +++ b/objectbox/config.go @@ -0,0 +1,62 @@ +package objectbox + +import "time" + +// Config defines the configuration options for ObjectBox storage. +type Config struct { + // Directory is the path where the database is stored. + // Optional, defaults to "objectbox" + Directory string + + // MaxSizeInKb sets the maximum size of the database in kilobytes. + // Optional, defaults to 1GB (1024 * 1024 * 1024) + MaxSizeInKb uint64 + + // MaxReaders defines the maximum number of concurrent readers. + // Optional, defaults to 126 + MaxReaders uint + + // Reset determines if existing keys should be cleared on startup. + // Optional, defaults to false + Reset bool + + // CleanerInterval sets the frequency for deleting expired keys. + // Optional, defaults to 60 seconds + CleanerInterval time.Duration +} + +var DefaultConfig = Config{ + Directory: "objectbox_db", + MaxSizeInKb: 1024 * 1024, // 1GB + MaxReaders: 126, + Reset: false, + CleanerInterval: 60 * time.Second, +} + +func getConfig(config ...Config) Config { + if len(config) < 1 { + return DefaultConfig + } + + cfg := config[0] + + // Set default values + + if cfg.Directory == "" { + cfg.Directory = DefaultConfig.Directory + } + + if cfg.MaxSizeInKb == 0 { + cfg.MaxSizeInKb = DefaultConfig.MaxSizeInKb + } + + if cfg.MaxReaders == 0 { + cfg.MaxReaders = DefaultConfig.MaxReaders + } + + if int(cfg.CleanerInterval.Seconds()) == 0 { + cfg.CleanerInterval = DefaultConfig.CleanerInterval + } + + return cfg +} diff --git a/objectbox/go.mod b/objectbox/go.mod new file mode 100644 index 00000000..a8899ab8 --- /dev/null +++ b/objectbox/go.mod @@ -0,0 +1,17 @@ +module github.com/gofiber/storage/objectbox/v2 + +go 1.23 + +require github.com/objectbox/objectbox-go v1.8.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +require ( + github.com/google/flatbuffers v23.5.26+incompatible // indirect + github.com/objectbox/objectbox-generator v0.14.0 // indirect + github.com/stretchr/testify v1.10.0 +) diff --git a/objectbox/go.sum b/objectbox/go.sum new file mode 100644 index 00000000..263ac59c --- /dev/null +++ b/objectbox/go.sum @@ -0,0 +1,15 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg= +github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/objectbox/objectbox-generator v0.14.0 h1:+fr7vOFsdz7d/HWZN3ZLjLnGJ8TviliRyeBHlkL/J40= +github.com/objectbox/objectbox-generator v0.14.0/go.mod h1:yMFFd/okhMBw02p6ZJJAmfblqyoQHjfYJie95DQbGn0= +github.com/objectbox/objectbox-go v1.8.1 h1:Dl9cXJe4sZKz2XaOFUHTftgicatiBpUex+KLbDTS1rU= +github.com/objectbox/objectbox-go v1.8.1/go.mod h1:FvnhelfA+S8zdGo916Z/WpPhM8AoMGP8vbXnIAj1jGo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/objectbox/objectbox-model.go b/objectbox/objectbox-model.go new file mode 100644 index 00000000..cf510db1 --- /dev/null +++ b/objectbox/objectbox-model.go @@ -0,0 +1,20 @@ +// Code generated by ObjectBox; DO NOT EDIT. + +package objectbox + +import ( + "github.com/objectbox/objectbox-go/objectbox" +) + +// ObjectBoxModel declares and builds the model from all the entities in the package. +// It is usually used when setting-up ObjectBox as an argument to the Builder.Model() function. +func ObjectBoxModel() *objectbox.Model { + model := objectbox.NewModel() + model.GeneratorVersion(6) + + model.RegisterBinding(CacheBinding) + model.LastEntityId(1, 8543848339172790713) + model.LastIndexId(2, 8378671756205806266) + + return model +} diff --git a/objectbox/objectbox-model.json b/objectbox/objectbox-model.json new file mode 100644 index 00000000..1c6efd0a --- /dev/null +++ b/objectbox/objectbox-model.json @@ -0,0 +1,49 @@ +{ + "_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.", + "_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.", + "_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.", + "entities": [ + { + "id": "1:8543848339172790713", + "lastPropertyId": "4:6145573473554079304", + "name": "Cache", + "properties": [ + { + "id": "1:3986202391135829849", + "name": "Id", + "type": 6, + "flags": 1 + }, + { + "id": "2:6255868849552810122", + "name": "Key", + "indexId": "1:4154314791709387436", + "type": 9, + "flags": 2080 + }, + { + "id": "3:441219408181596061", + "name": "Value", + "type": 23 + }, + { + "id": "4:6145573473554079304", + "name": "ExpiresAt", + "indexId": "2:8378671756205806266", + "type": 10, + "flags": 8 + } + ] + } + ], + "lastEntityId": "1:8543848339172790713", + "lastIndexId": "2:8378671756205806266", + "lastRelationId": "", + "modelVersion": 5, + "modelVersionParserMinimum": 5, + "retiredEntityUids": [], + "retiredIndexUids": [], + "retiredPropertyUids": [], + "retiredRelationUids": [], + "version": 1 +} \ No newline at end of file diff --git a/objectbox/objectbox.go b/objectbox/objectbox.go new file mode 100644 index 00000000..310294f8 --- /dev/null +++ b/objectbox/objectbox.go @@ -0,0 +1,162 @@ +package objectbox + +import ( + "time" + + "github.com/objectbox/objectbox-go/objectbox" +) + +// Storage handles the ObjectBox database operations and cleanup routines. +type Storage struct { + ob *objectbox.ObjectBox + box *CacheBox + done chan struct{} +} + +// New creates a new Storage instance with the provided configuration. +// It initializes the ObjectBox database and starts the cleanup routine. +func New(config ...Config) *Storage { + cfg := getConfig(config...) + + ob, err := objectbox.NewBuilder().Model(ObjectBoxModel()).MaxSizeInKb(cfg.MaxSizeInKb).MaxReaders(cfg.MaxReaders).Directory(cfg.Directory).Build() + if err != nil { + panic(err) + } + + if cfg.Reset { + box := BoxForCache(ob) + err = box.RemoveAll() + + if err != nil { + panic(err) + } + } + + storage := &Storage{ + ob: ob, + box: BoxForCache(ob), + done: make(chan struct{}), + } + + go storage.cleanerTicker(cfg.CleanerInterval) + + return storage +} + +// Get retrieves a value from cache by its key. +// Returns nil if key doesn't exist or has expired. +func (s *Storage) Get(key string) ([]byte, error) { + if len(key) < 1 { + return nil, nil + } + + query := s.box.Query(Cache_.Key.Equals(key, true), + objectbox.Any( + Cache_.ExpiresAt.Equals(0), + Cache_.ExpiresAt.GreaterThan(time.Now().Unix()), + )) + caches, err := query.Find() + + if err != nil { + return nil, err + } + + if len(caches) < 1 { + return nil, nil + } + + return caches[0].Value, nil +} + +// Set stores a value in cache with the specified key and expiration. +// If expiration is 0, the entry won't expire. +func (s *Storage) Set(key string, value []byte, exp time.Duration) error { + if len(key) <= 0 || len(value) <= 0 { + return nil + } + + // Since objectbox go doen't support conflict strategy, + // we need to check if the key already exists + // and update the value if it does. + + query := s.box.Query(Cache_.Key.Equals(key, true)) + cachesIds, err := query.FindIds() + if err != nil { + return err + } + + // if the id is 0 it will create new cache + // otherwise it will update the existing entry + var id uint64 = 0 + if len(cachesIds) > 0 { + id = cachesIds[0] + } + + var expAt int64 + + if exp > 0 { // Changed from exp != 0 to exp > 0 + expAt = time.Now().Add(exp).Unix() + } + + cache := &Cache{ + Id: id, + Key: key, + Value: value, + ExpiresAt: expAt, + } + + _, err = s.box.Put(cache) + if err != nil { + return err + } + + return nil +} + +// Delete removes an entry from cache by its key. +func (s *Storage) Delete(key string) error { + if len(key) <= 0 { + return nil + } + + _, err := s.box.Query(Cache_.Key.Equals(key, true)).Remove() + + if err != nil { + return err + } + + return nil +} + +// Reset removes all entries from the cache. +func (s *Storage) Reset() error { + return s.box.RemoveAll() +} + +// Close shuts down the storage, stopping the cleanup routine +// and closing the database connection. +func (s *Storage) Close() error { + close(s.done) + s.ob.Close() + return nil +} + +// cleanStorage removes all expired cache entries. +func (s *Storage) cleanStorage() { + s.box.Query(Cache_.ExpiresAt.LessThan(time.Now().Unix())).Remove() //nolint:errcheck // It is fine to ignore the error +} + +// cleanerTicker runs periodic cleanup of expired entries. +func (s *Storage) cleanerTicker(interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + s.cleanStorage() + case <-s.done: + return + } + } +} diff --git a/objectbox/objectbox_test.go b/objectbox/objectbox_test.go new file mode 100644 index 00000000..eafd4e1b --- /dev/null +++ b/objectbox/objectbox_test.go @@ -0,0 +1,262 @@ +package objectbox + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var store = New(Config{ + Reset: true, + Directory: "test_db", + MaxSizeInKb: 1024, + MaxReaders: 10, + CleanerInterval: 60 * time.Second, +}) + +func Test_ObjectBox_Set_And_Get(t *testing.T) { + key := "test_key" + value := []byte("test_value") + + err := store.Set(key, value, 5*time.Second) + require.NoError(t, err) + + got, err := store.Get(key) + require.NoError(t, err) + require.Equal(t, value, got) +} + +func Test_ObjectBox_Multiple_Sets_Same_Key(t *testing.T) { + key := "multi_set_test" + value1 := []byte("first_value") + value2 := []byte("second_value") + value3 := []byte("third_value") + + err := store.Set(key, value1, 0) + require.NoError(t, err) + + got, err := store.Get(key) + require.NoError(t, err) + require.Equal(t, value1, got) + + err = store.Set(key, value2, 1*time.Second) + require.NoError(t, err) + + got, err = store.Get(key) + require.NoError(t, err) + require.Equal(t, value2, got) + + err = store.Set(key, value3, 2*time.Second) + require.NoError(t, err) + + got, err = store.Get(key) + require.NoError(t, err) + require.Equal(t, value3, got) +} + +func Test_ObjectBox_Get_NotExist(t *testing.T) { + result, err := store.Get("nonexistent_key") + require.NoError(t, err) + require.Zero(t, len(result)) +} + +func Test_ObjectBox_Delete(t *testing.T) { + key := "delete_test" + value := []byte("delete_value") + + err := store.Set(key, value, 0) + require.NoError(t, err) + + err = store.Delete(key) + require.NoError(t, err) + + got, err := store.Get(key) + require.NoError(t, err) + require.Nil(t, got) +} + +func Test_ObjectBox_Expiration(t *testing.T) { + key := "expire_test" + value := []byte("expire_value") + + err := store.Set(key, value, 1*time.Second) + require.NoError(t, err) + + time.Sleep(2 * time.Second) + + got, err := store.Get(key) + require.NoError(t, err) + require.Nil(t, got) +} + +func Test_ObjectBox_Reset(t *testing.T) { + key1 := "reset_test_1" + key2 := "reset_test_2" + value := []byte("reset_value") + + err := store.Set(key1, value, 0) + require.NoError(t, err) + err = store.Set(key2, value, 0) + require.NoError(t, err) + + err = store.Reset() + require.NoError(t, err) + + got, err := store.Get(key1) + require.NoError(t, err) + require.Nil(t, got) + + got, err = store.Get(key2) + require.NoError(t, err) + require.Nil(t, got) +} + +func Test_ObjectBox_Empty_Key(t *testing.T) { + result, err := store.Get("") + require.NoError(t, err) + require.Nil(t, result) +} + +func Test_ObjectBox_Empty_Value(t *testing.T) { + err := store.Set("test_key", []byte{}, 0) + require.NoError(t, err) +} + +func Test_ObjectBox_Empty_Key_Delete(t *testing.T) { + err := store.Delete("") + require.NoError(t, err) +} + +func Test_ObjectBox_Concurrent_Operations(t *testing.T) { + const goroutines = 10 + done := make(chan bool) + + for i := 0; i < goroutines; i++ { + go func(id int) { + key := fmt.Sprintf("concurrent_key_%d", id) + value := []byte(fmt.Sprintf("value_%d", id)) + + err := store.Set(key, value, time.Hour) + if err != nil { + t.Errorf("Failed concurrent set: %v", err) + } + + _, err = store.Get(key) + if err != nil { + t.Errorf("Failed concurrent get: %v", err) + } + + done <- true + }(i) + } + + for i := 0; i < goroutines; i++ { + <-done + } +} + +func Test_ObjectBox_Zero_Expiration(t *testing.T) { + key := "zero_expiration_test" + value := []byte("test_value") + + err := store.Set(key, value, 0) + require.NoError(t, err) + + // Wait some time to verify the value persists + time.Sleep(2 * time.Second) + + got, err := store.Get(key) + require.NoError(t, err) + require.Equal(t, value, got) +} + +func Test_ObjectBox_Cleaner(t *testing.T) { + // Set items with different expiration times + tests := []struct { + key string + value []byte + expiry time.Duration + }{ + {"expired1", []byte("value1"), -1 * time.Second}, + {"expired2", []byte("value2"), -2 * time.Second}, + {"valid1", []byte("value3"), 1 * time.Hour}, + {"valid2", []byte("value4"), 2 * time.Hour}, + } + + for _, tt := range tests { + err := store.Set(tt.key, tt.value, tt.expiry) + if err != nil { + t.Fatalf("Failed to set test data: %v", err) + } + } + + // Run cleanup + store.cleanStorage() + + // Verify expired items are removed and valid ones remain + for _, tt := range tests { + got, err := store.Get(tt.key) + if err != nil { + t.Fatalf("Failed to get value: %v", err) + } + + if tt.expiry < 0 && got != nil { + t.Errorf("Expected expired key %s to be nil, got %v", tt.key, got) + } + if tt.expiry > 0 && got == nil { + t.Errorf("Expected valid key %s to exist, got nil", tt.key) + } + } +} + +func Benchmark_ObjectBox_Set(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + + var err error + for i := 0; i < b.N; i++ { + err = store.Set("bench_key", []byte("bench_value"), 0) + } + require.NoError(b, err) +} + +func Benchmark_ObjectBox_Get(b *testing.B) { + err := store.Set("bench_key", []byte("bench_value"), 0) + require.NoError(b, err) + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + _, err = store.Get("bench_key") + } + require.NoError(b, err) +} + +func Benchmark_ObjectBox_SetAndDelete(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + + var err error + + for i := 0; i < b.N; i++ { + _ = store.Set("bench_key", []byte("bench_value"), 0) + err = store.Delete("bench_key") + } + require.NoError(b, err) +} + +func Benchmark_ObjectBox_Cleaner(b *testing.B) { + for i := 0; i < 100; i++ { + key := fmt.Sprintf("expired-key-%d", i) + store.Set(key, []byte("benchmark-value"), -1*time.Second) + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + store.cleanStorage() + } +}