v0.0.641 Handle cursortokens with non-decodable values gracefully
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m20s

This commit is contained in:
2026-05-22 22:06:05 +02:00
parent e12764c0a2
commit fad2e4ff6d
8 changed files with 190 additions and 32 deletions
+9
View File
@@ -7,14 +7,23 @@ import (
ct "git.blackforestbytes.com/BlackForestBytes/goext/cursortoken"
"git.blackforestbytes.com/BlackForestBytes/goext/exerr"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
type EntityID interface {
MarshalBSONValue() (byte, []byte, error)
IsZero() bool
String() string
}
type MongoEntityID interface {
EntityID
ObjID() (bson.ObjectID, error)
Valid() bool
}
type Decodable interface {
Decode(v any) error
}
+55 -25
View File
@@ -401,14 +401,18 @@ func createPaginationPipeline[TData any](coll *Coll[TData], token ct.CTKeySort,
return nil, nil, exerr.Wrap(err, "failed to get (primary) token-value as mongo-type").Build()
}
if sortPrimary == ct.SortASC {
// We sort ASC on <field> - so we want all entries newer ($gt) than the $primary
cond = append(cond, bson.M{fieldPrimary: bson.M{"$gt": valuePrimary}})
sort = append(sort, bson.E{Key: fieldPrimary, Value: +1})
} else if sortPrimary == ct.SortDESC {
// We sort DESC on <field> - so we want all entries older ($lt) than the $primary
cond = append(cond, bson.M{fieldPrimary: bson.M{"$lt": valuePrimary}})
sort = append(sort, bson.E{Key: fieldPrimary, Value: -1})
if isValidTokenValue(valuePrimary) {
if sortPrimary == ct.SortASC {
// We sort ASC on <field> - so we want all entries newer ($gt) than the $primary
cond = append(cond, bson.M{fieldPrimary: bson.M{"$gt": valuePrimary}})
sort = append(sort, bson.E{Key: fieldPrimary, Value: +1})
} else if sortPrimary == ct.SortDESC {
// We sort DESC on <field> - so we want all entries older ($lt) than the $primary
cond = append(cond, bson.M{fieldPrimary: bson.M{"$lt": valuePrimary}})
sort = append(sort, bson.E{Key: fieldPrimary, Value: -1})
}
}
if fieldSecondary != nil && sortSecondary != nil && *fieldSecondary != fieldPrimary {
@@ -418,25 +422,29 @@ func createPaginationPipeline[TData any](coll *Coll[TData], token ct.CTKeySort,
return nil, nil, exerr.Wrap(err, "failed to get (secondary) token-value as mongo-type").Build()
}
if *sortSecondary == ct.SortASC {
if isValidTokenValue(valueSecondary) {
// the conflict-resolution condition, for entries with the _same_ <field> as the $primary we take the ones with a greater $secondary (= newer)
cond = append(cond, bson.M{"$and": bson.A{
bson.M{"$or": bson.A{bson.M{fieldPrimary: valuePrimary}, bson.M{fieldPrimary: nil}, bson.M{fieldPrimary: bson.M{"$exists": false}}}},
bson.M{*fieldSecondary: bson.M{"$gt": valueSecondary}},
}})
if *sortSecondary == ct.SortASC {
sort = append(sort, bson.E{Key: *fieldSecondary, Value: +1})
// the conflict-resolution condition, for entries with the _same_ <field> as the $primary we take the ones with a greater $secondary (= newer)
cond = append(cond, bson.M{"$and": bson.A{
bson.M{"$or": bson.A{bson.M{fieldPrimary: valuePrimary}, bson.M{fieldPrimary: nil}, bson.M{fieldPrimary: bson.M{"$exists": false}}}},
bson.M{*fieldSecondary: bson.M{"$gt": valueSecondary}},
}})
} else if *sortSecondary == ct.SortDESC {
sort = append(sort, bson.E{Key: *fieldSecondary, Value: +1})
// the conflict-resolution condition, for entries with the _same_ <field> as the $primary we take the ones with a smaller $secondary (= older)
cond = append(cond, bson.M{"$and": bson.A{
bson.M{"$or": bson.A{bson.M{fieldPrimary: valuePrimary}, bson.M{fieldPrimary: nil}, bson.M{fieldPrimary: bson.M{"$exists": false}}}},
bson.M{*fieldSecondary: bson.M{"$lt": valueSecondary}},
}})
} else if *sortSecondary == ct.SortDESC {
sort = append(sort, bson.E{Key: *fieldSecondary, Value: -1})
// the conflict-resolution condition, for entries with the _same_ <field> as the $primary we take the ones with a smaller $secondary (= older)
cond = append(cond, bson.M{"$and": bson.A{
bson.M{"$or": bson.A{bson.M{fieldPrimary: valuePrimary}, bson.M{fieldPrimary: nil}, bson.M{fieldPrimary: bson.M{"$exists": false}}}},
bson.M{*fieldSecondary: bson.M{"$lt": valueSecondary}},
}})
sort = append(sort, bson.E{Key: *fieldSecondary, Value: -1})
}
}
}
@@ -449,7 +457,9 @@ func createPaginationPipeline[TData any](coll *Coll[TData], token ct.CTKeySort,
} else if token.Mode == ct.CTMNormal {
pipeline = append(pipeline, bson.D{{Key: "$match", Value: bson.M{"$or": cond}}})
if len(cond) > 0 {
pipeline = append(pipeline, bson.D{{Key: "$match", Value: bson.M{"$or": cond}}})
}
} else if token.Mode == ct.CTMEnd {
@@ -462,9 +472,15 @@ func createPaginationPipeline[TData any](coll *Coll[TData], token ct.CTKeySort,
}
pipeline = append(pipeline, bson.D{{Key: "$sort", Value: sort}})
if len(sort) > 0 {
pipeline = append(pipeline, bson.D{{Key: "$sort", Value: sort}})
}
pipelineSort := mongo.Pipeline{bson.D{{Key: "$sort", Value: sort}}}
pipelineSort := mongo.Pipeline{}
if len(sort) > 0 {
pipelineSort = append(pipelineSort, bson.D{{Key: "$sort", Value: sort}})
}
if pageSize != nil {
pipeline = append(pipeline, bson.D{{Key: "$limit", Value: int64(*pageSize + 1)}})
@@ -473,6 +489,20 @@ func createPaginationPipeline[TData any](coll *Coll[TData], token ct.CTKeySort,
return pipeline, pipelineSort, nil
}
func isValidTokenValue(val any) bool {
// special handling for Mongo EntityIDs
// We want to prevent failing late (in mongoDB layer) when client sends invalid IDs as token values
if mongoID, ok := val.(MongoEntityID); ok {
if !mongoID.Valid() {
return false
}
}
return true
}
func createSortOnlyPipeline(fieldPrimary string, sortPrimary ct.SortDirection, fieldSecondary *string, sortSecondary *ct.SortDirection) ([]bson.D, error) {
sort := bson.D{}
+107
View File
@@ -0,0 +1,107 @@
package wmo
import (
"encoding/json"
"testing"
ct "git.blackforestbytes.com/BlackForestBytes/goext/cursortoken"
"git.blackforestbytes.com/BlackForestBytes/goext/exerr"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"git.blackforestbytes.com/BlackForestBytes/goext/rfctime"
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"go.mongodb.org/mongo-driver/v2/bson"
)
type TestExampleID string //@id:type
func (i TestExampleID) MarshalBSONValue() (byte, []byte, error) {
if objId, err := bson.ObjectIDFromHex(string(i)); err == nil {
tp, data, err := bson.MarshalValue(objId)
return byte(tp), data, err
} else {
return 0, nil, exerr.New(exerr.TypeMarshalEntityID, "Failed to marshal UserID("+i.String()+") to ObjectId").Str("value", string(i)).Type("type", i).Build()
}
}
func (i TestExampleID) String() string {
return string(i)
}
func (i TestExampleID) ObjID() (bson.ObjectID, error) {
return bson.ObjectIDFromHex(string(i))
}
func (i TestExampleID) Valid() bool {
_, err := bson.ObjectIDFromHex(string(i))
return err == nil
}
func (i TestExampleID) IsZero() bool {
return i == ""
}
var _ MongoEntityID = (*TestExampleID)(nil)
func TestCreatePaginationPipelineWithInvalidObjectIDValue(t *testing.T) {
//
// {"v1":"2026-05-16T21:44:13.16Z","v2":"6a08e52d144a332c2e2d067b","dir":"DESC","dir2":"DESC","size":50}
strtok := "tok_PMRHMMJCHIRDEMBSGYWTANJNGE3FIMRRHI2DIORRGMXDCNS2EIWCE5RSEI5CENTBGA4GKNJSMQYTINDBGMZTEYZSMUZGIMBWG5RCELBCMRUXEIR2EJCEKU2DEIWCEZDJOIZCEORCIRCVGQZCFQRHG2L2MURDUNJQPU======"
tok, err := ct.Decode(strtok)
if err != nil {
t.Error("Failed to decode token:", err)
return
}
type obj struct {
ID TestExampleID `bson:"_id"`
Created rfctime.RFC3339NanoTime `bson:"created"`
}
coll := Coll[obj]{coll: nil}
coll.init()
paginationPipeline, doubleSortPipeline, err := createPaginationPipeline[obj](&coll, tok.(ct.CTKeySort), "_id", ct.SortDESC, new("_id"), new(ct.SortDESC), new(15))
if err != nil {
t.Error("Failed to create pagination Pipeline:", err)
return
}
//fmt.Printf("# paginationPipeline:\n%+v\n\n", string(langext.Must(json.Marshal(paginationPipeline))))
//fmt.Printf("# doubleSortPipeline:\n%+v\n\n", string(langext.Must(json.Marshal(doubleSortPipeline))))
tst.AssertEqual(t, string(langext.Must(json.Marshal(paginationPipeline))), `[{"$limit":16}]`) // wrong (_id with wrong date type) fields are not used
tst.AssertEqual(t, string(langext.Must(json.Marshal(doubleSortPipeline))), `[]`) // wrong (_id with wrong date type) fields are not used
}
func TestCreatePaginationPipelineWithValidObjectIDValue(t *testing.T) {
//
// {"v1":"6a08e52d144a332c2e2d067b","v2":"6a08e52d144a332c2e2d067b","dir":"DESC","dir2":"DESC","size":50}
strtok := "tok_PMFCAIBAEARHMMJCHIQCENTBGA4GKNJSMQYTINDBGMZTEYZSMUZGIMBWG5RCELAKEAQCAIBCOYZCEORAEI3GCMBYMU2TEZBRGQ2GCMZTGJRTEZJSMQYDMN3CEIWAUIBAEAQCEZDJOIRDUIBCIRCVGQZCFQFCAIBAEARGI2LSGIRDUIBCIRCVGQZCFQFCAIBAEARHG2L2MURDUIBVGAFH2==="
tok, err := ct.Decode(strtok)
if err != nil {
t.Error("Failed to decode token:", err)
return
}
type obj struct {
ID TestExampleID `bson:"_id"`
Created rfctime.RFC3339NanoTime `bson:"created"`
}
coll := Coll[obj]{coll: nil}
coll.init()
paginationPipeline, doubleSortPipeline, err := createPaginationPipeline[obj](&coll, tok.(ct.CTKeySort), "_id", ct.SortDESC, new("_id"), new(ct.SortDESC), new(15))
if err != nil {
t.Error("Failed to create pagination Pipeline:", err)
return
}
//fmt.Printf("# paginationPipeline:\n%+v\n\n", string(langext.Must(json.Marshal(paginationPipeline))))
//fmt.Printf("# doubleSortPipeline:\n%+v\n\n", string(langext.Must(json.Marshal(doubleSortPipeline))))
tst.AssertEqual(t, string(langext.Must(json.Marshal(paginationPipeline))), `[{"$match":{"$or":[{"_id":{"$lt":"6a08e52d144a332c2e2d067b"}}]}},{"$sort":{"_id":-1}},{"$limit":16}]`)
tst.AssertEqual(t, string(langext.Must(json.Marshal(doubleSortPipeline))), `[{"$sort":{"_id":-1}}]`)
}