diff --git a/mdb/access_test_suite_test.go b/mdb/access_test_suite_test.go index c31b4a0..e09eaf0 100644 --- a/mdb/access_test_suite_test.go +++ b/mdb/access_test_suite_test.go @@ -1,10 +1,5 @@ package mdb -// Would prefer to name this file ending in _test.go -// so that it won't be included in generated code, -// but then it can't be referenced from other packages for some reason, -// so it couldn't be used (as designed) in tests in other packages. - import ( "github.com/stretchr/testify/suite" ) @@ -37,6 +32,8 @@ func (suite *AccessTestSuite) TearDownSuite() { suite.NoError(suite.access.Disconnect(), "disconnect from mongo") } +// ConnectCollection connects to the specified collection and adds any provided indexes +// as necessary in a SetupSuite() with test checks so that any errors blow up the test. func (suite *AccessTestSuite) ConnectCollection( definition *CollectionDefinition, indexDescriptions ...*IndexDescription) *Collection { collection, err := ConnectCollection(suite.access, definition) @@ -48,3 +45,17 @@ func (suite *AccessTestSuite) ConnectCollection( } return collection } + +// ConnectTypedCollectionHelper is similar to AccessTestSuite.ConnectionCollection(). +// Go doesn't support generic methods so this can't be a method on AccessTestSuite. +func ConnectTypedCollectionHelper[T any]( + suite *AccessTestSuite, definition *CollectionDefinition, indexDescriptions ...*IndexDescription) *TypedCollection[T] { + collection, err := ConnectTypedCollection[T](suite.access, definition) + suite.Require().NoError(err) + suite.NotNil(collection) + suite.Require().NoError(collection.DeleteAll()) + for _, indexDescription := range indexDescriptions { + suite.Require().NoError(suite.access.Index(&collection.Collection, indexDescription)) + } + return collection +} diff --git a/mdb/cached_collection_db_test.nogo b/mdb/cached_collection_db_test.nogo index 8fff608..29a93cf 100644 --- a/mdb/cached_collection_db_test.nogo +++ b/mdb/cached_collection_db_test.nogo @@ -24,7 +24,7 @@ func TestCacheSuite(t *testing.T) { func (suite *cacheTestSuite) SetupSuite() { suite.AccessTestSuite.SetupSuite() - reg.Highlander().Clear() + reg.Singleton().Clear() suite.Require().NoError(RegisterWrapped()) var err error suite.cached, err = ConnectCachedCollection[*SimpleItem](suite.access, testCollectionValidation, time.Hour) diff --git a/mdb/collection_db_test.go b/mdb/collection_db_test.go index f984d14..ee476bc 100644 --- a/mdb/collection_db_test.go +++ b/mdb/collection_db_test.go @@ -23,12 +23,7 @@ func TestCollectionSuite(t *testing.T) { func (suite *collectionTestSuite) SetupSuite() { suite.AccessTestSuite.SetupSuite() - var err error - suite.collection, err = ConnectCollection(suite.access, testCollection) - suite.Require().NoError(err) - suite.NotNil(suite.collection) - suite.Require().NoError(suite.collection.DeleteAll()) - suite.Require().NoError(suite.access.Index(suite.collection, NewIndexDescription(true, "alpha"))) + suite.collection = suite.ConnectCollection(testCollection, NewIndexDescription(true, "alpha")) } func (suite *collectionTestSuite) TearDownTest() { diff --git a/mdb/collection_definitions_test.go b/mdb/collection_definitions_test.go index 26f676f..9a75eec 100644 --- a/mdb/collection_definitions_test.go +++ b/mdb/collection_definitions_test.go @@ -14,8 +14,4 @@ var ( testCollectionWrapped = &CollectionDefinition{ Name: "test-collection-wrapped", } - testCollectionIndexFinisher = &CollectionDefinition{ - Name: "test-collection-index-finisher", - ValidationJSON: SimpleValidatorJSON, - } ) diff --git a/mdb/identity_db_test.go b/mdb/identity_db_test.go index 9d70d2b..7fb80cd 100644 --- a/mdb/identity_db_test.go +++ b/mdb/identity_db_test.go @@ -18,11 +18,7 @@ func TestIdentitySuite(t *testing.T) { func (suite *identityTestSuite) SetupSuite() { suite.AccessTestSuite.SetupSuite() - var err error - suite.collection, err = ConnectCollection(suite.access, testCollection) - suite.Require().NoError(err) - suite.NotNil(suite.collection) - suite.Require().NoError(suite.collection.DeleteAll()) + suite.collection = suite.ConnectCollection(testCollection) } func (suite *identityTestSuite) TearDownTest() { @@ -42,7 +38,7 @@ func (suite *identityTestSuite) TestIndex() { ind.Text = findable suite.Require().NoError(suite.collection.Create(ind)) found := suite.findStruct(bson.D{{"text", findable}}) - // Do we have an ID? + // Do we have an email? suite.Require().NotNil(found.ObjectID) suite.Require().NotNil(found.ID()) suite.Equal(found.ObjectID, found.ID()) diff --git a/mdb/index_db_test.go b/mdb/index_db_test.go index 17b9318..53c6a62 100644 --- a/mdb/index_db_test.go +++ b/mdb/index_db_test.go @@ -3,8 +3,12 @@ package mdb import ( + "context" + "strings" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -18,10 +22,7 @@ func TestIndexSuite(t *testing.T) { } func (suite *indexTestSuite) SetupTest() { - var err error - suite.collection, err = ConnectCollection(suite.access, testCollectionValidation) - suite.Require().NoError(err) - suite.NotNil(suite.collection) + suite.collection = suite.ConnectCollection(testCollectionValidation) } func (suite *indexTestSuite) TearDownTest() { @@ -70,3 +71,55 @@ func (suite *indexTestSuite) TestIndexFinisher() { suite.NotNil(collection) NewIndexTester().TestIndexes(suite.T(), collection, index) } + +// IndexTester provides a utility for verifying index creation. +type IndexTester []indexDatum + +type indexDatum struct { + Name string + Key map[string]int32 + Unique bool +} + +func NewIndexTester() IndexTester { + return make(IndexTester, 0, 2) +} + +// ============================================================================= + +func (it IndexTester) TestIndexes(t *testing.T, collection *Collection, descriptions ...*IndexDescription) { + ctx := context.Background() + cursor, err := collection.Indexes().List(ctx) + require.NoError(t, err) + err = cursor.All(ctx, &it) + require.NoError(t, err) + assert.Len(t, it, len(descriptions)+1) + it.hasIndexNamed(t, "_id_", NewIndexDescription(false, "_id")) + for _, description := range descriptions { + nameMap := make([]string, 0, len(description.keys)) + for _, key := range description.keys { + nameMap = append(nameMap, key+"_1") + } + it.hasIndexNamed(t, strings.Join(nameMap, "_"), description) + } +} + +func (it IndexTester) hasIndexNamed(t *testing.T, name string, description *IndexDescription) { + for _, data := range it { + if data.Name == name { + assert.Equal(t, description.unique, data.Unique, "check unique for index %s", name) + keyMap := make(map[string]int32, len(description.keys)) + for _, key := range description.keys { + keyMap[key] = 1 + } + assert.Equal(t, keyMap, data.Key, "check keys for index %s", name) + return + } + } + + names := make([]string, 0, len(it)) + for _, data := range it { + names = append(names, data.Name) + } + assert.Fail(t, "missing index", "no index %s (%s)", name, strings.Join(names, ", ")) +} diff --git a/mdb/pointer_db_test.go b/mdb/pointer_db_test.go new file mode 100644 index 0000000..e5180bc --- /dev/null +++ b/mdb/pointer_db_test.go @@ -0,0 +1,219 @@ +package mdb + +import ( + "fmt" + "os" + "strconv" + "testing" + + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/suite" + "go.mongodb.org/mongo-driver/bson" + + "github.com/madkins23/go-serial/pointer" + + "github.com/madkins23/go-mongo/mdbson" +) + +type pointerTestSuite struct { + AccessTestSuite + showSerialized bool + targetCollection *TypedCollection[TargetItem] + pointerCollection *TypedCollection[PointerDemo] +} + +func TestPointerSuite(t *testing.T) { + suite.Run(t, new(pointerTestSuite)) +} + +func (suite *pointerTestSuite) SetupSuite() { + if showSerialized, found := os.LookupEnv("GO-TYPE-SHOW-SERIALIZED"); found { + var err error + suite.showSerialized, err = strconv.ParseBool(showSerialized) + suite.Require().NoError(err) + } + suite.AccessTestSuite.SetupSuite() + suite.targetCollection = ConnectTypedCollectionHelper[TargetItem]( + &suite.AccessTestSuite, testCollectionValidation) + suite.pointerCollection = ConnectTypedCollectionHelper[PointerDemo]( + &suite.AccessTestSuite, testCollection) +} + +func (suite *pointerTestSuite) SetupTest() { + pointer.ClearTargetCache() + pointer.ClearFinderCache() +} + +func (suite *pointerTestSuite) TearDownTest() { + suite.NoError(suite.targetCollection.DeleteAll()) + suite.NoError(suite.pointerCollection.DeleteAll()) +} + +////////////////////////////////////////////////////////////////////////// + +func (suite *pointerTestSuite) TestPointerFinder() { + // This test will rely on automatic loading of the target cache + // when the demo object is saved to the DB. + // The Finder will not be used in this case. + suite.testPointerFinder("finder", false) +} + +func (suite *pointerTestSuite) TestPointerFinderWithDB() { + // This test will wipe the target cache after saving the demo object. + // The test case + suite.testPointerFinder("database", true) +} + +//------------------------------------------------------------------------ + +func (suite *pointerTestSuite) testPointerFinder(demoName string, forceFinder bool) { + var targetItems = targetItems() + + // Load suite.targetCollection with records. + for _, item := range targetItems { + suite.Require().False(pointer.HasTarget(item.Group(), item.Key())) + suite.Require().NoError(suite.targetCollection.Create(item)) + } + + // Add one record to the cache. + suite.Require().NoError(pointer.SetTarget(targetItems[0], false)) + + // Check to see if only the one item is in the target cache. + for i, item := range targetItems { + if i == 0 { + suite.True(pointer.HasTarget(item.Group(), item.Key())) + } else { + suite.False(pointer.HasTarget(item.Group(), item.Key())) + } + } + + // Create PointerDemo object. + demo := &PointerDemo{ + Name: demoName, + Single: mdbson.Point[*TargetItem](targetItems[0]), + Array: make([]*mdbson.Pointer[*TargetItem], 0), + Map: make(map[string]*mdbson.Pointer[*TargetItem]), + } + for _, item := range targetItems { + demo.Array = append(demo.Array, mdbson.Point[*TargetItem](item)) + demo.Map[item.Charlie] = mdbson.Point[*TargetItem](item) + } + if suite.showSerialized { + spew.Dump(demo) + } + + // Put the demo object into suite.pointerCollection. + // Note: This will fill the target cache as it goes along. + // The items in the cache will be pre-store to the DB, + // so they won't have Mongo ObjectIDs. + suite.Require().NoError(suite.pointerCollection.Create(demo)) + + finderCount := 0 + if forceFinder { + // Clear the target cache to force the Finder to be called. + pointer.ClearTargetCache() + + // Set a Finder in the target cache to pull targets from suite.targetCollection. + suite.Require().NoError(pointer.SetFinder("simple", func(key string) (pointer.Target, error) { + finderCount++ + item, err := suite.targetCollection.Find(bson.D{ + {"alpha", key}, + }) + if IsNotFound(err) { + return nil, err + } else if err != nil { + return nil, fmt.Errorf("find record: %w", err) + } else { + return item, nil + } + }, false)) + } + + // Read the demo object back from suite.pointerCollection. + readBack, err := suite.pointerCollection.Find(bson.D{{"name", demo.Name}}) + if suite.showSerialized { + fmt.Println("-----------------------------") + spew.Dump(readBack) + } + suite.Require().NoError(err) + readBack.clearObjectIDs() + suite.Equal(demo, readBack) + + if forceFinder { + // Make sure Finder executed: + suite.Len(targetItems, finderCount) + + // Note: In this mode the target cache has been rebuilt from the DB. + // The TargetItem entries now have Mongo ObjectIDs + // so they won't match the ones in the original demo object. + } else { + // Make sure the Finder did NOT execute: + suite.Equal(0, finderCount) + + // The Pointer items should be singletons from the target cache. + suite.True(demo.Single.Get() == readBack.Single.Get()) + for index, item := range demo.Array { + suite.True(item.Get() == readBack.Array[index].Get()) + } + for key, item := range demo.Map { + suite.True(item.Get() == readBack.Map[key].Get()) + } + } + + // Check for records in target cache, should all be present now. + for _, item := range targetItems { + suite.True(pointer.HasTarget(item.Group(), item.Key())) + } +} + +//======================================================================== + +type PointerDemo struct { + Name string + Single *mdbson.Pointer[*TargetItem] + Array []*mdbson.Pointer[*TargetItem] + Map map[string]*mdbson.Pointer[*TargetItem] +} + +func (pd *PointerDemo) clearObjectIDs() { + pd.Single.Get().clearID() + for _, ptr := range pd.Array { + ptr.Get().clearID() + } + for _, ptr := range pd.Map { + ptr.Get().clearID() + } +} + +var _ pointer.Target = &TargetItem{} + +type TargetItem struct { + SimpleItem `bson:"inline"` +} + +func (ti *TargetItem) clearID() { + ti.ObjectID = [12]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} +} + +func (ti *TargetItem) Group() string { + return "simple" +} + +func (ti *TargetItem) Key() string { + return ti.Alpha +} + +var tgtItems []*TargetItem + +func targetItems() []*TargetItem { + if tgtItems == nil { + for _, simpleItem := range simpleItems { + tgtItems = append(tgtItems, &TargetItem{SimpleItem: *simpleItem}) + } + } + return tgtItems +} + +var simpleItems = []*SimpleItem{ + SimpleItem1, SimpleItem1x, SimpleItem2, SimpleItem3, UnfilteredItem, +} diff --git a/mdb/typed_collection_db_test.go b/mdb/typed_collection_db_test.go index b431cf4..af04349 100644 --- a/mdb/typed_collection_db_test.go +++ b/mdb/typed_collection_db_test.go @@ -24,20 +24,19 @@ func TestTypedSuite(t *testing.T) { func (suite *typedTestSuite) SetupSuite() { suite.AccessTestSuite.SetupSuite() - reg.Highlander().Clear() + reg.Singleton().Clear() suite.Require().NoError(RegisterWrapped()) - var err error - suite.typed, err = ConnectTypedCollection[SimpleItem](suite.access, testCollectionValidation) - suite.Require().NoError(err) - suite.NotNil(suite.typed) - suite.Require().NoError(suite.typed.DeleteAll()) - suite.Require().NoError(suite.access.Index(&suite.typed.Collection, NewIndexDescription(true, "alpha"))) + suite.typed = ConnectTypedCollectionHelper[SimpleItem]( + &suite.AccessTestSuite, testCollectionValidation, + NewIndexDescription(true, "alpha")) } func (suite *typedTestSuite) TearDownTest() { suite.NoError(suite.typed.DeleteAll()) } +////////////////////////////////////////////////////////////////////////// + func (suite *typedTestSuite) TestCreateDuplicate() { err := suite.typed.Create(SimpleItem1) suite.Require().NoError(err) @@ -260,7 +259,7 @@ func (suite *typedTestSuite) TestCreateFindDeleteWrapped() { suite.Require().NoError(err) suite.Require().NotNil(foundWrapped) suite.NotNil(foundWrapped.ID) - // Zero out the object ID before testing equality. + // Zero out the object email before testing equality. foundWrapped.ObjectID = primitive.ObjectID{} suite.Equal(wrappedItems, foundWrapped) foundWrappedGet := foundWrapped.Single.Get() diff --git a/mdbson/pointer.go b/mdbson/pointer.go new file mode 100644 index 0000000..089ad9a --- /dev/null +++ b/mdbson/pointer.go @@ -0,0 +1,88 @@ +package mdbson + +import ( + "errors" + "fmt" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/madkins23/go-serial/pointer" +) + +const ( + tgtGroup = "group" + tgtKey = "key" +) + +// Pointer is used to specify an object that may be found in a cache or DB. +type Pointer[T pointer.Target] struct { + item T +} + +func Point[T pointer.Target](target T) *Pointer[T] { + p := new(Pointer[T]) + p.Set(target) + return p +} + +// Get the Target item from the Pointer. +func (p *Pointer[T]) Get() T { + return p.item +} + +// Set the Target item for the Pointer. +func (p *Pointer[T]) Set(t T) { + p.item = t +} + +// ----------------------------------------------------------------------- + +func (p *Pointer[T]) MarshalBSON() ([]byte, error) { + var err error + var group = p.item.Group() + var key = p.item.Key() + var pack = map[string]string{ + tgtGroup: group, + tgtKey: key, + } + + if !pointer.HasTarget(group, key) { + if err = pointer.SetTarget(p.item, false); err == nil { + } else if !errors.Is(err, pointer.ErrTargetAlreadyExists) { + return nil, fmt.Errorf("setting target in cache: %w", err) + } + } + + var marshaled []byte + marshaled, err = bson.Marshal(pack) + if err != nil { + return []byte(""), fmt.Errorf("marshal packed form: %p", err) + } + return marshaled, nil +} + +var ( + errEmptyGroupField = errors.New("empty group field") + errEmptyKeyField = errors.New("empty key field") + fmtWrongTargetType = "object '%v' not Target" +) + +func (p *Pointer[T]) UnmarshalBSON(marshaled []byte) error { + var pack map[string]string + if err := bson.Unmarshal(marshaled, &pack); err != nil { + return fmt.Errorf("unmarshal packed area: %p", err) + } + + var ok bool + if group, found := pack[tgtGroup]; !found { + return errEmptyGroupField + } else if key, found := pack[tgtKey]; !found { + return errEmptyKeyField + } else if target, err := pointer.GetTarget(group, key, pointer.GetFinder(group)); err != nil { + return fmt.Errorf("get target: %w", err) + } else if p.item, ok = target.(T); !ok { + return fmt.Errorf(fmtWrongTargetType, target) + } else { + return nil + } +} diff --git a/mdbson/pointer_test.go b/mdbson/pointer_test.go new file mode 100644 index 0000000..57a3ce0 --- /dev/null +++ b/mdbson/pointer_test.go @@ -0,0 +1,77 @@ +package mdbson + +import ( + "fmt" + "os" + "strconv" + "testing" + + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/suite" + "go.mongodb.org/mongo-driver/bson" + + "github.com/madkins23/go-serial/pointer" + "github.com/madkins23/go-serial/test" +) + +type BsonPointerTestSuite struct { + suite.Suite + showSerialized bool +} + +func (suite *BsonPointerTestSuite) SetupSuite() { + if showSerialized, found := os.LookupEnv("GO-TYPE-SHOW-SERIALIZED"); found { + var err error + suite.showSerialized, err = strconv.ParseBool(showSerialized) + suite.Require().NoError(err) + } + pointer.ClearTargetCache() + pointer.ClearFinderCache() + suite.Require().NoError(test.CachePets()) +} + +func TestBsonPointerSuite(t *testing.T) { + suite.Run(t, new(BsonPointerTestSuite)) +} + +////////////////////////////////////////////////////////////////////////// + +func (suite *BsonPointerTestSuite) TestPointer() { + ptr := Point[*test.Pet](test.Lacey) + suite.Assert().Equal(test.Lacey, ptr.Get()) + ptr.Set(test.Noah) + suite.Assert().Equal(test.Noah, ptr.Get()) +} + +type animals struct { + Cats []*Pointer[*test.Pet] + Dog *Pointer[*test.Pet] +} + +func makeAnimals() *animals { + return &animals{ + Cats: []*Pointer[*test.Pet]{ + Point[*test.Pet](test.Noah), + Point[*test.Pet](test.Lacey), + Point[*test.Pet](test.Orca), + }, + Dog: Point[*test.Pet](test.Knight), + } +} + +func (suite *BsonPointerTestSuite) TestMarshalCycle() { + start := makeAnimals() + marshaled, err := bson.Marshal(start) + suite.Require().NoError(err) + suite.Require().NotNil(marshaled) + + finish := new(animals) + suite.Require().NotNil(finish) + suite.Require().NoError(bson.Unmarshal(marshaled, finish)) + if suite.showSerialized { + fmt.Println("---------------------------") + spew.Dump(finish) + } + + suite.Require().Equal(start, finish) +} diff --git a/mdbson/bson.go b/mdbson/wrapper.go similarity index 98% rename from mdbson/bson.go rename to mdbson/wrapper.go index 880631f..5997215 100644 --- a/mdbson/bson.go +++ b/mdbson/wrapper.go @@ -83,7 +83,7 @@ func (w *Wrapper[T]) UnmarshalBSON(marshaled []byte) error { } else if err = decoder.Decode(temp); err != nil { return fmt.Errorf("decode wrapper contents: %w", err) } else if w.item, ok = temp.(T); !ok { - // TODO: How to get name of T? + // TODO(mAdkins): How to get name of T? return fmt.Errorf("type %s not generic type", pack.TypeName) } else { return nil diff --git a/mdbson/bson_test.go b/mdbson/wrapper_test.go similarity index 99% rename from mdbson/bson_test.go rename to mdbson/wrapper_test.go index 5f3dac0..0b0a85e 100644 --- a/mdbson/bson_test.go +++ b/mdbson/wrapper_test.go @@ -24,7 +24,7 @@ func (suite *BsonTestSuite) SetupSuite() { suite.showSerialized, err = strconv.ParseBool(showSerialized) suite.Require().NoError(err) } - reg.Highlander().Clear() + reg.Singleton().Clear() suite.Require().NoError(RegisterPortfolio()) suite.Require().NoError(reg.AddAlias("mdbson", Bond{}), "creating bson test alias") suite.Require().NoError(reg.Register(Bond{}))