diff --git a/om/hash.go b/om/hash.go index 85adbaea..30dc5636 100644 --- a/om/hash.go +++ b/om/hash.go @@ -65,11 +65,19 @@ func (r *HashRepository[T]) FetchCache(ctx context.Context, id string, ttl time. return v, err } -func (r *HashRepository[T]) toExec(entity *T) (val reflect.Value, exec rueidis.LuaExec) { - val = reflect.ValueOf(entity).Elem() +func (r *HashRepository[T]) toExec(entity *T) (verf reflect.Value, exec rueidis.LuaExec) { + val := reflect.ValueOf(entity).Elem() + if !r.schema.verless { + verf = val.Field(r.schema.ver.idx) + } else { + verf = reflect.ValueOf(int64(0)) // verless, set verf to a dummy value + } fields := r.factory.NewConverter(val).ToHash() keyVal := fields[r.schema.key.name] - verVal := fields[r.schema.ver.name] + var verVal string + if !r.schema.verless { + verVal = fields[r.schema.ver.name] + } extVal := int64(0) if r.schema.ext != nil { if ext, ok := val.Field(r.schema.ext.idx).Interface().(time.Time); ok && !ext.IsZero() { @@ -96,14 +104,16 @@ func (r *HashRepository[T]) toExec(entity *T) (val reflect.Value, exec rueidis.L // Save the entity under the redis key of `{prefix}:{id}`. // It also uses the `redis:",ver"` field and lua script to perform optimistic locking and prevent lost update. func (r *HashRepository[T]) Save(ctx context.Context, entity *T) (err error) { - val, exec := r.toExec(entity) + verf, exec := r.toExec(entity) str, err := hashSaveScript.Exec(ctx, r.client, exec.Keys, exec.Args).ToString() if rueidis.IsRedisNil(err) { + if r.schema.verless { + return nil + } return ErrVersionMismatch - } - if err == nil { + } else if err == nil { ver, _ := strconv.ParseInt(str, 10, 64) - val.Field(r.schema.ver.idx).SetInt(ver) + verf.SetInt(ver) } return err } @@ -111,19 +121,25 @@ func (r *HashRepository[T]) Save(ctx context.Context, entity *T) (err error) { // SaveMulti batches multiple HashRepository.Save at once func (r *HashRepository[T]) SaveMulti(ctx context.Context, entities ...*T) []error { errs := make([]error, len(entities)) - vals := make([]reflect.Value, len(entities)) + verf := make([]reflect.Value, len(entities)) exec := make([]rueidis.LuaExec, len(entities)) for i, entity := range entities { - vals[i], exec[i] = r.toExec(entity) + verf[i], exec[i] = r.toExec(entity) } for i, resp := range hashSaveScript.ExecMulti(ctx, r.client, exec...) { - if str, err := resp.ToString(); err != nil { - if errs[i] = err; rueidis.IsRedisNil(err) { - errs[i] = ErrVersionMismatch + str, err := resp.ToString() + if rueidis.IsRedisNil(err) { + if r.schema.verless { + continue } - } else { + errs[i] = ErrVersionMismatch + continue + } + if err == nil { ver, _ := strconv.ParseInt(str, 10, 64) - vals[i].Field(r.schema.ver.idx).SetInt(ver) + verf[i].SetInt(ver) + } else { + errs[i] = err } } return errs @@ -200,6 +216,15 @@ func (r *HashRepository[T]) fromFields(fields map[string]string) (*T, error) { } var hashSaveScript = rueidis.NewLuaScript(` +if (ARGV[1] == '') +then + local e = (#ARGV % 2 == 1) and table.remove(ARGV) or nil + if redis.call('HSET',KEYS[1],unpack(ARGV)) + then + if e then redis.call('PEXPIREAT',KEYS[1],e) end + end + return nil +end local v = redis.call('HGET',KEYS[1],ARGV[1]) if (not v or v == ARGV[2]) then diff --git a/om/hash_test.go b/om/hash_test.go index 58826a5a..862c3e23 100644 --- a/om/hash_test.go +++ b/om/hash_test.go @@ -31,6 +31,12 @@ type HashTestStruct struct { JSON json.RawMessage } +type Verless struct { + Key string `redis:",key"` + Val int64 + JSON json.RawMessage +} + type Unsupported struct { Key string `redis:",key"` Ver int64 `redis:",ver"` @@ -339,6 +345,213 @@ func TestNewHashRepository(t *testing.T) { }) } +//gocyclo:ignore +func TestNewHashRepositoryVerless(t *testing.T) { + ctx := context.Background() + + client := setup(t) + client.Do(ctx, client.B().Flushall().Build()) + defer client.Close() + + repo := NewHashRepository("hash", Verless{}, client) + + t.Run("NewEntity", func(t *testing.T) { + e := repo.NewEntity() + ulid.MustParse(e.Key) + }) + + t.Run("Save", func(t *testing.T) { + e := repo.NewEntity() + // test save + e.Val = rand.Int63() + e.JSON = []byte(`[1]`) + if err := repo.Save(ctx, e); err != nil { + t.Fatal(err) + } + + // confirm no version related errors with second save + if err := repo.Save(ctx, e); err != nil { + t.Fatal(err) + } + + t.Run("Fetch", func(t *testing.T) { + ei, err := repo.Fetch(ctx, e.Key) + if err != nil { + t.Fatal(err) + } + if e == ei { + t.Fatalf("e's address should not be the same as ee's") + } + if !reflect.DeepEqual(e, ei) { + t.Fatalf("e should be the same as ee") + } + }) + + t.Run("FetchCache", func(t *testing.T) { + ei, err := repo.FetchCache(ctx, e.Key, time.Minute) + if err != nil { + t.Fatal(err) + } + if e == ei { + t.Fatalf("e's address should not be the same as ee's") + } + if !reflect.DeepEqual(e, ei) { + t.Fatalf("ee should be the same as e") + } + }) + + t.Run("Search", func(t *testing.T) { + err := repo.CreateIndex(ctx, func(schema FtCreateSchema) rueidis.Completed { + return schema.FieldName("Val").Text().Build() + }) + time.Sleep(time.Second) + if err != nil { + t.Fatal(err) + } + n, records, err := repo.Search(ctx, func(search FtSearchIndex) rueidis.Completed { + return search.Query("*").Build() + }) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("unexpected total count %v", n) + } + if len(records) != 1 { + t.Fatalf("unexpected return count %v", n) + } + if !reflect.DeepEqual(e, records[0]) { + t.Fatalf("items[0] should be the same as e") + } + if err = repo.DropIndex(ctx); err != nil { + t.Fatal(err) + } + }) + + t.Run("Search Sort", func(t *testing.T) { + err := repo.CreateIndex(ctx, func(schema FtCreateSchema) rueidis.Completed { + return schema.FieldName("Val").Text().Sortable().Build() + }) + time.Sleep(time.Second) + if err != nil { + t.Fatal(err) + } + n, records, err := repo.Search(ctx, func(search FtSearchIndex) rueidis.Completed { + return search.Query("*").Sortby("Val").Build() + }) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("unexpected total count %v", n) + } + if len(records) != 1 { + t.Fatalf("unexpected return count %v", n) + } + if !reflect.DeepEqual(e, records[0]) { + t.Fatalf("items[0] should be the same as e") + } + if err = repo.DropIndex(ctx); err != nil { + t.Fatal(err) + } + }) + + t.Run("Delete", func(t *testing.T) { + if err := repo.Remove(ctx, e.Key); err != nil { + t.Fatal(err) + } + ei, err := repo.Fetch(ctx, e.Key) + if !IsRecordNotFound(err) { + t.Fatalf("should not be found, but got %v", ei) + } + _, err = repo.FetchCache(ctx, e.Key, time.Minute) + if !IsRecordNotFound(err) { + t.Fatalf("should not be found, but got %v", e) + } + }) + + t.Run("Alter Index", func(t *testing.T) { + err := repo.CreateIndex(ctx, func(schema FtCreateSchema) rueidis.Completed { + return schema.FieldName("Val").Text().Build() + }) + if err != nil { + t.Fatal(err) + } + time.Sleep(time.Second) + var entities []*Verless + for i := 3; i >= 1; i-- { + e := repo.NewEntity() + e.Val = rand.Int63() + e.JSON = []byte(fmt.Sprintf("[%d]", i)) + err = repo.Save(ctx, e) + if err != nil { + t.Fatal(err) + } + entities = append(entities, e) + } + time.Sleep(time.Second) + _, _, err = repo.Search(ctx, func(search FtSearchIndex) rueidis.Completed { + return search.Query("*").Sortby("JSON").Build() + }) + if err == nil { + t.Fatalf("search by property not loaded nor in schema") + } + err = repo.AlterIndex(ctx, func(alter FtAlterIndex) rueidis.Completed { + return alter. + Schema().Add().Field("JSON").Options("TEXT", "SORTABLE"). + Build() + }) + if err != nil { + t.Fatal(err) + } + time.Sleep(time.Second) + n, records, err := repo.Search(ctx, func(search FtSearchIndex) rueidis.Completed { + return search.Query("*").Sortby("JSON").Build() + }) + if err != nil { + t.Fatal(err) + } + if n != 3 { + t.Fatalf("unexpected total count %v", n) + } + if len(records) != 3 { + t.Fatalf("unexpected return count %v", n) + } + if !reflect.DeepEqual(entities[2], records[0]) { + t.Fatalf("entities[0] should be the same as records[2]") + } + if err = repo.DropIndex(ctx); err != nil { + t.Fatal(err) + } + }) + }) + + t.Run("SaveMulti", func(t *testing.T) { + entities := []*Verless{ + repo.NewEntity(), + repo.NewEntity(), + repo.NewEntity(), + } + + for _, e := range entities { + e.Val = rand.Int63() + } + + for _, err := range repo.SaveMulti(context.Background(), entities...) { + if err != nil { + t.Fatal(err) + } + } + + // confirm no version related errors + for _, err := range repo.SaveMulti(context.Background(), entities...) { + if err != nil { + t.Fatal(err) + } + } + }) +} + type HashTestTTLStruct struct { Key string `redis:",key"` Ver int64 `redis:",ver"` @@ -479,3 +692,125 @@ func TestNewHashRepositoryTTL(t *testing.T) { } }) } + +type HashTestVerlessTTLStruct struct { + Key string `redis:",key"` + Exat time.Time `redis:",exat"` +} + +//gocyclo:ignore +func TestNewHashRepositoryVerlessTTL(t *testing.T) { + ctx := context.Background() + + client := setup(t) + client.Do(ctx, client.B().Flushall().Build()) + defer client.Close() + + repo := NewHashRepository("hashttl", HashTestVerlessTTLStruct{}, client) + + t.Run("NewEntity", func(t *testing.T) { + e := repo.NewEntity() + ulid.MustParse(e.Key) + }) + + t.Run("Save", func(t *testing.T) { + e := repo.NewEntity() + e.Exat = time.Now().Add(time.Minute) + if err := repo.Save(ctx, e); err != nil { + t.Fatal(err) + } + + // confirm no version related errors with second save + if err := repo.Save(ctx, e); err != nil { + t.Fatal(err) + } + + t.Run("ExpireAt", func(t *testing.T) { + exat, err := client.Do(ctx, client.B().Pexpiretime().Key("hashttl:"+e.Key).Build()).AsInt64() + if err != nil { + t.Fatal(err) + } + if exat != e.Exat.UnixMilli() { + t.Fatalf("wrong exat") + } + }) + + t.Run("Fetch", func(t *testing.T) { + ei, err := repo.Fetch(ctx, e.Key) + if err != nil { + t.Fatal(err) + } + if e == ei { + t.Fatalf("e's address should not be the same as ee's") + } + e.Exat = e.Exat.Truncate(time.Millisecond) + ei.Exat = ei.Exat.Truncate(time.Millisecond) + if !e.Exat.Equal(ei.Exat) { + t.Fatalf("e should be the same as ee %v %v", e, ei) + } + }) + + t.Run("FetchCache", func(t *testing.T) { + ei, err := repo.FetchCache(ctx, e.Key, time.Minute) + if err != nil { + t.Fatal(err) + } + if e == ei { + t.Fatalf("e's address should not be the same as ee's") + } + e.Exat = e.Exat.Truncate(time.Millisecond) + ei.Exat = ei.Exat.Truncate(time.Millisecond) + if !e.Exat.Equal(ei.Exat) { + t.Fatalf("ee should be the same as e %v %v", e, ei) + } + }) + t.Run("Delete", func(t *testing.T) { + if err := repo.Remove(ctx, e.Key); err != nil { + t.Fatal(err) + } + ei, err := repo.Fetch(ctx, e.Key) + if !IsRecordNotFound(err) { + t.Fatalf("should not be found, but got %v", ei) + } + _, err = repo.FetchCache(ctx, e.Key, time.Minute) + if !IsRecordNotFound(err) { + t.Fatalf("should not be found, but got %v", e) + } + }) + }) + + t.Run("SaveMulti", func(t *testing.T) { + entities := []*HashTestVerlessTTLStruct{ + repo.NewEntity(), + repo.NewEntity(), + repo.NewEntity(), + } + + for _, e := range entities { + e.Exat = time.Now().Add(time.Minute) + } + + for _, err := range repo.SaveMulti(context.Background(), entities...) { + if err != nil { + t.Fatal(err) + } + } + + // confirm no version related errors + for _, err := range repo.SaveMulti(context.Background(), entities...) { + if err != nil { + t.Fatal(err) + } + } + + for _, e := range entities { + exat, err := client.Do(ctx, client.B().Pexpiretime().Key("hashttl:"+e.Key).Build()).AsInt64() + if err != nil { + t.Fatal(err) + } + if exat != e.Exat.UnixMilli() { + t.Fatalf("wrong exat") + } + } + }) +} diff --git a/om/json.go b/om/json.go index 860b6902..05c49f78 100644 --- a/om/json.go +++ b/om/json.go @@ -75,7 +75,11 @@ func (r *JSONRepository[T]) decode(record string) (*T, error) { func (r *JSONRepository[T]) toExec(entity *T) (verf reflect.Value, exec rueidis.LuaExec) { val := reflect.ValueOf(entity).Elem() - verf = val.Field(r.schema.ver.idx) + if !r.schema.verless { + verf = val.Field(r.schema.ver.idx) + } else { + verf = reflect.ValueOf(int64(0)) // verless, set verf to a dummy value + } extVal := int64(0) if r.schema.ext != nil { if ext, ok := val.Field(r.schema.ext.idx).Interface().(time.Time); ok && !ext.IsZero() { @@ -94,14 +98,18 @@ func (r *JSONRepository[T]) toExec(entity *T) (verf reflect.Value, exec rueidis. // Save the entity under the redis key of `{prefix}:{id}`. // It also uses the `redis:",ver"` field and lua script to perform optimistic locking and prevent lost update. func (r *JSONRepository[T]) Save(ctx context.Context, entity *T) (err error) { - valf, exec := r.toExec(entity) + var verf reflect.Value + var exec rueidis.LuaExec + verf, exec = r.toExec(entity) str, err := jsonSaveScript.Exec(ctx, r.client, exec.Keys, exec.Args).ToString() if rueidis.IsRedisNil(err) { + if r.schema.verless { + return nil + } return ErrVersionMismatch - } - if err == nil { + } else if err == nil { ver, _ := strconv.ParseInt(str, 10, 64) - valf.SetInt(ver) + verf.SetInt(ver) } return err } @@ -109,19 +117,25 @@ func (r *JSONRepository[T]) Save(ctx context.Context, entity *T) (err error) { // SaveMulti batches multiple HashRepository.Save at once func (r *JSONRepository[T]) SaveMulti(ctx context.Context, entities ...*T) []error { errs := make([]error, len(entities)) - valf := make([]reflect.Value, len(entities)) + verf := make([]reflect.Value, len(entities)) exec := make([]rueidis.LuaExec, len(entities)) for i, entity := range entities { - valf[i], exec[i] = r.toExec(entity) + verf[i], exec[i] = r.toExec(entity) } for i, resp := range jsonSaveScript.ExecMulti(ctx, r.client, exec...) { - if str, err := resp.ToString(); err != nil { - if errs[i] = err; rueidis.IsRedisNil(err) { - errs[i] = ErrVersionMismatch + str, err := resp.ToString() + if rueidis.IsRedisNil(err) { + if r.schema.verless { + continue } - } else { + errs[i] = ErrVersionMismatch + continue + } + if err == nil { ver, _ := strconv.ParseInt(str, 10, 64) - valf[i].SetInt(ver) + verf[i].SetInt(ver) + } else { + errs[i] = err } } return errs @@ -187,6 +201,12 @@ func (r *JSONRepository[T]) IndexName() string { } var jsonSaveScript = rueidis.NewLuaScript(` +if (ARGV[1] == '') +then + redis.call('JSON.SET',KEYS[1],'$',ARGV[3]) + if #ARGV == 4 then redis.call('PEXPIREAT',KEYS[1],ARGV[4]) end + return nil +end local v = redis.call('JSON.GET',KEYS[1],ARGV[1]) if (not v or v == ARGV[2]) then diff --git a/om/json_test.go b/om/json_test.go index 0537432c..56e19b91 100644 --- a/om/json_test.go +++ b/om/json_test.go @@ -2,6 +2,7 @@ package om import ( "context" + "errors" "fmt" "reflect" "testing" @@ -18,6 +19,12 @@ type JSONTestStruct struct { Ver int64 `redis:",ver"` } +type JSONVerless struct { + Key string `redis:",key"` + Nested struct{ F1 string } + Val []byte +} + func TestNewJsonRepositoryMismatch(t *testing.T) { ctx := context.Background() @@ -89,7 +96,7 @@ func TestNewJSONRepository(t *testing.T) { // test ErrVersionMismatch e.Ver = 0 - if err := repo.Save(ctx, e); err != ErrVersionMismatch { + if err := repo.Save(ctx, e); !errors.Is(err, ErrVersionMismatch) { t.Fatalf("save should fail if ErrVersionMismatch, got: %v", err) } e.Ver = 1 // restore @@ -290,7 +297,7 @@ func TestNewJSONRepository(t *testing.T) { for i, err := range repo.SaveMulti(context.Background(), entities...) { if i == len(entities)-1 { - if err != ErrVersionMismatch { + if !errors.Is(err, ErrVersionMismatch) { t.Fatalf("unexpected err %v", err) } } else { @@ -305,6 +312,239 @@ func TestNewJSONRepository(t *testing.T) { }) } +//gocyclo:ignore +func TestNewJSONRepositoryVerless(t *testing.T) { + ctx := context.Background() + + client := setup(t) + client.Do(ctx, client.B().Flushall().Build()) + defer client.Close() + + repo := NewJSONRepository("json", JSONVerless{}, client) + + t.Run("IndexName", func(t *testing.T) { + if name := repo.IndexName(); name != "jsonidx:json" { + t.Fatal("unexpected value") + } + }) + + t.Run("NewEntity", func(t *testing.T) { + e := repo.NewEntity() + ulid.MustParse(e.Key) + }) + + t.Run("Save", func(t *testing.T) { + e := repo.NewEntity() + + // test save + e.Val = []byte("any") + if err := repo.Save(ctx, e); err != nil { + t.Fatal(err) + } + + // confirm no version related errors with second save + if err := repo.Save(ctx, e); err != nil { + t.Fatal(err) + } + + t.Run("Fetch", func(t *testing.T) { + ei, err := repo.Fetch(ctx, e.Key) + if err != nil { + t.Fatal(err) + } + if e == ei { + t.Fatalf("e's address should not be the same as ee's") + } + if !reflect.DeepEqual(e, ei) { + t.Fatalf("e should be the same as ee") + } + }) + + t.Run("FetchCache", func(t *testing.T) { + ei, err := repo.FetchCache(ctx, e.Key, time.Minute) + if err != nil { + t.Fatal(err) + } + ee := ei + if e == ee { + t.Fatalf("e's address should not be the same as ee's") + } + if !reflect.DeepEqual(e, ee) { + t.Fatalf("ee should be the same as e") + } + }) + + t.Run("Search", func(t *testing.T) { + err := repo.CreateIndex(ctx, func(schema FtCreateSchema) rueidis.Completed { + return schema.FieldName("$.Val").Text().Build() + }) + if err != nil { + t.Fatal(err) + } + time.Sleep(time.Second) + n, records, err := repo.Search(ctx, func(search FtSearchIndex) rueidis.Completed { + return search.Query("*").Build() + }) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("unexpected total count %v", n) + } + if len(records) != 1 { + t.Fatalf("unexpected return count %v", n) + } + if !reflect.DeepEqual(e, records[0]) { + t.Fatalf("items[0] should be the same as e") + } + n, records, err = repo.Search(ctx, func(search FtSearchIndex) rueidis.Completed { + return search.Query("*").Dialect(3).Build() + }) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("unexpected total count %v", n) + } + if len(records) != 1 { + t.Fatalf("unexpected return count %v", n) + } + if !reflect.DeepEqual(e, records[0]) { + t.Fatalf("items[0] should be the same as e") + } + if err = repo.DropIndex(ctx); err != nil { + t.Fatal(err) + } + }) + + t.Run("Search Sort", func(t *testing.T) { + err := repo.CreateIndex(ctx, func(schema FtCreateSchema) rueidis.Completed { + return schema.FieldName("$.Val").Text().Sortable().Build() + }) + if err != nil { + t.Fatal(err) + } + time.Sleep(time.Second) + n, records, err := repo.Search(ctx, func(search FtSearchIndex) rueidis.Completed { + return search.Query("*").Sortby("$.Val").Build() + }) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("unexpected total count %v", n) + } + if len(records) != 1 { + t.Fatalf("unexpected return count %v", n) + } + if !reflect.DeepEqual(e, records[0]) { + t.Fatalf("items[0] should be the same as e") + } + if err = repo.DropIndex(ctx); err != nil { + t.Fatal(err) + } + }) + + t.Run("Delete", func(t *testing.T) { + if err := repo.Remove(ctx, e.Key); err != nil { + t.Fatal(err) + } + ei, err := repo.Fetch(ctx, e.Key) + if !IsRecordNotFound(err) { + t.Fatalf("should not be found, but got %v", ei) + } + _, err = repo.FetchCache(ctx, e.Key, time.Minute) + if !IsRecordNotFound(err) { + t.Fatalf("should not be found, but got %v", e) + } + }) + + t.Run("Alter Index", func(t *testing.T) { + err := repo.CreateIndex(ctx, func(schema FtCreateSchema) rueidis.Completed { + return schema.FieldName("$.Val").Text().Build() + }) + if err != nil { + t.Fatal(err) + } + time.Sleep(time.Second) + var entities []*JSONVerless + for i := 3; i >= 1; i-- { + e := repo.NewEntity() + e.Val = []byte("any") + e.Nested = struct { + F1 string + }{ + F1: fmt.Sprintf("%d", i), + } + err = repo.Save(ctx, e) + if err != nil { + t.Fatal(err) + } + entities = append(entities, e) + } + time.Sleep(time.Second) + n, records, err := repo.Search(ctx, func(search FtSearchIndex) rueidis.Completed { + return search.Query("*").Sortby("$.Nested.F1").Build() + }) + if err == nil { + t.Fatalf("search by property not loaded nor in schema") + } + err = repo.AlterIndex(ctx, func(alter FtAlterIndex) rueidis.Completed { + return alter. + Schema().Add().Field("$.Nested.F1").Options("TEXT", "SORTABLE"). + Build() + }) + if err != nil { + t.Fatal(err) + } + time.Sleep(time.Second) + n, records, err = repo.Search(ctx, func(search FtSearchIndex) rueidis.Completed { + return search.Query("*").Sortby("$.Nested.F1").Build() + }) + if err != nil { + t.Fatal(err) + } + if n != 3 { + t.Fatalf("unexpected total count %v", n) + } + if len(records) != 3 { + t.Fatalf("unexpected return count %v", n) + } + if !reflect.DeepEqual(entities[2], records[0]) { + t.Fatalf("entities[0] should be the same as records[2]") + } + if err = repo.DropIndex(ctx); err != nil { + t.Fatal(err) + } + }) + }) + + t.Run("SaveMulti", func(t *testing.T) { + entities := []*JSONVerless{ + repo.NewEntity(), + repo.NewEntity(), + repo.NewEntity(), + } + + for _, e := range entities { + e.Val = []byte("any") + } + + for _, err := range repo.SaveMulti(context.Background(), entities...) { + if err != nil { + t.Fatal(err) + } + } + + // confirm no version related errors + for _, err := range repo.SaveMulti(context.Background(), entities...) { + if err != nil { + t.Fatal(err) + } + } + }) +} + type JSONTestTTLStruct struct { Key string `redis:",key"` Ver int64 `redis:",ver"` @@ -340,7 +580,7 @@ func TestNewJSONTTLRepository(t *testing.T) { // test ErrVersionMismatch e.Ver = 0 - if err := repo.Save(ctx, e); err != ErrVersionMismatch { + if err := repo.Save(ctx, e); !errors.Is(err, ErrVersionMismatch) { t.Fatalf("save should fail if ErrVersionMismatch, got: %v", err) } e.Ver = 1 // restore @@ -424,7 +664,7 @@ func TestNewJSONTTLRepository(t *testing.T) { for i, err := range repo.SaveMulti(context.Background(), entities...) { if i == len(entities)-1 { - if err != ErrVersionMismatch { + if !errors.Is(err, ErrVersionMismatch) { t.Fatalf("unexpected err %v", err) } } else { @@ -448,3 +688,128 @@ func TestNewJSONTTLRepository(t *testing.T) { } }) } + +type JSONTestVerlessTTLStruct struct { + Key string `redis:",key"` + Exat time.Time `redis:",exat"` +} + +//gocyclo:ignore +func TestNewJSONVerlessTTLRepository(t *testing.T) { + ctx := context.Background() + + client := setup(t) + client.Do(ctx, client.B().Flushall().Build()) + defer client.Close() + + repo := NewJSONRepository("jsonttl", JSONTestVerlessTTLStruct{}, client) + + t.Run("NewEntity", func(t *testing.T) { + e := repo.NewEntity() + ulid.MustParse(e.Key) + }) + + t.Run("Save", func(t *testing.T) { + e := repo.NewEntity() + + // test save + e.Exat = time.Now().Add(time.Minute) + if err := repo.Save(ctx, e); err != nil { + t.Fatal(err) + } + + // confirm no version related errors with second save + if err := repo.Save(ctx, e); err != nil { + t.Fatal(err) + } + + t.Run("ExpireAt", func(t *testing.T) { + exat, err := client.Do(ctx, client.B().Pexpiretime().Key("jsonttl:"+e.Key).Build()).AsInt64() + if err != nil { + t.Fatal(err) + } + if exat != e.Exat.UnixMilli() { + t.Fatalf("wrong exat") + } + }) + + t.Run("Fetch", func(t *testing.T) { + ei, err := repo.Fetch(ctx, e.Key) + if err != nil { + t.Fatal(err) + } + if e == ei { + t.Fatalf("e's address should not be the same as ee's") + } + e.Exat = e.Exat.Truncate(time.Nanosecond) + ei.Exat = ei.Exat.Truncate(time.Nanosecond) + if !e.Exat.Equal(ei.Exat) { + t.Fatalf("e should be the same as ee %v, %v", e, ei) + } + }) + + t.Run("FetchCache", func(t *testing.T) { + ei, err := repo.FetchCache(ctx, e.Key, time.Minute) + if err != nil { + t.Fatal(err) + } + if e == ei { + t.Fatalf("e's address should not be the same as ee's") + } + e.Exat = e.Exat.Truncate(time.Nanosecond) + ei.Exat = ei.Exat.Truncate(time.Nanosecond) + if !e.Exat.Equal(ei.Exat) { + t.Fatalf("ee should be the same as e %v, %v", e, ei) + } + }) + + t.Run("Delete", func(t *testing.T) { + if err := repo.Remove(ctx, e.Key); err != nil { + t.Fatal(err) + } + ei, err := repo.Fetch(ctx, e.Key) + if !IsRecordNotFound(err) { + t.Fatalf("should not be found, but got %v", ei) + } + _, err = repo.FetchCache(ctx, e.Key, time.Minute) + if !IsRecordNotFound(err) { + t.Fatalf("should not be found, but got %v", e) + } + }) + }) + + t.Run("SaveMulti", func(t *testing.T) { + entities := []*JSONTestVerlessTTLStruct{ + repo.NewEntity(), + repo.NewEntity(), + repo.NewEntity(), + } + + for _, e := range entities { + e.Exat = time.Now().Add(time.Minute) + } + + for _, err := range repo.SaveMulti(context.Background(), entities...) { + if err != nil { + t.Fatal(err) + } + } + + // confirm no version related errors + for _, err := range repo.SaveMulti(context.Background(), entities...) { + if err != nil { + t.Fatal(err) + } + } + + for _, e := range entities { + exat, err := client.Do(ctx, client.B().Pexpiretime().Key("jsonttl:"+e.Key).Build()).AsInt64() + if err != nil { + t.Fatal(err) + } + if exat != e.Exat.UnixMilli() { + t.Fatalf("wrong exat") + } + } + }) +} diff --git a/om/repo.go b/om/repo.go index 2db391fe..fac23275 100644 --- a/om/repo.go +++ b/om/repo.go @@ -16,7 +16,7 @@ type ( FtSearchIndex = cmds.FtSearchIndex // FtAggregateIndex is the FT.AGGREGATE command builder FtAggregateIndex = cmds.FtAggregateIndex - // FtAlterSchema is the FT.ALTERINDEX command builder + // FtAlterIndex is the FT.ALTERINDEX command builder FtAlterIndex = cmds.FtAlterIndex // Arbitrary is an alias to cmds.Arbitrary. This allows the user to build an arbitrary command in Repository.CreateIndex Arbitrary = cmds.Arbitrary @@ -31,7 +31,7 @@ var ( // IsRecordNotFound checks if the error is indicating the requested entity is not found. func IsRecordNotFound(err error) bool { - return rueidis.IsRedisNil(err) || err == ErrEmptyHashRecord + return rueidis.IsRedisNil(err) || errors.Is(err, ErrEmptyHashRecord) } // Repository is backed by HashRepository or JSONRepository diff --git a/om/schema.go b/om/schema.go index 0050782b..920b393d 100644 --- a/om/schema.go +++ b/om/schema.go @@ -10,10 +10,11 @@ import ( const ignoreField = "-" type schema struct { - key *field - ver *field - ext *field - fields map[string]*field + key *field + ver *field + ext *field + fields map[string]*field + verless bool } type field struct { @@ -67,8 +68,11 @@ func newSchema(t reflect.Type) schema { if s.key == nil { panic(fmt.Sprintf("schema %q should have one field with `redis:\",key\"` tag", t)) } + + // ver is no longer required if s.ver == nil { - panic(fmt.Sprintf("schema %q should have one field with `redis:\",ver\"` tag", t)) + s.ver = &field{reflect.TypeOf(int64(0)), "", -1, false, true, false} + s.verless = true } return s diff --git a/om/schema_test.go b/om/schema_test.go index d5ca4191..fe543171 100644 --- a/om/schema_test.go +++ b/om/schema_test.go @@ -22,11 +22,16 @@ type s3 struct { type s4 struct { A string `redis:",key"` - B int64 `json:"-" redis:",ver"` + B int64 `redis:",ver"` private int64 } type s5 struct { + A string `redis:",key"` + private int64 +} + +type s6 struct { A string `redis:",key"` B int64 `redis:",ver"` C int64 `redis:",exat"` @@ -47,7 +52,7 @@ func TestSchema(t *testing.T) { t.Fatalf("unexpected msg %v", v) } }) - t.Run("non string `redis:\",ver\"`", func(t *testing.T) { + t.Run("non int64 `redis:\",ver\"`", func(t *testing.T) { if v := recovered(func() { newSchema(reflect.TypeOf(s2{})) }); !strings.Contains(v, "should be a int64") { @@ -61,16 +66,21 @@ func TestSchema(t *testing.T) { t.Fatalf("unexpected msg %v", v) } }) - t.Run("missing `redis:\",ver\"`", func(t *testing.T) { - if v := recovered(func() { - newSchema(reflect.TypeOf(s4{})) - }); !strings.Contains(v, "should have one field with `redis:\",ver\"` tag") { - t.Fatalf("unexpected msg %v", v) + t.Run("ver is not verless", func(t *testing.T) { + v := newSchema(reflect.TypeOf(s4{})) + if v.verless { + t.Fatal("schema should not be verless") + } + }) + t.Run("missing `redis:\",ver\"` should be verless", func(t *testing.T) { + v := newSchema(reflect.TypeOf(s5{})) + if !v.verless { + t.Fatal("schema should be verless") } }) t.Run("non time.Time `redis:\",exat\"`", func(t *testing.T) { if v := recovered(func() { - newSchema(reflect.TypeOf(s5{})) + newSchema(reflect.TypeOf(s6{})) }); !strings.Contains(v, "should be a time.Time") { t.Fatalf("unexpected msg %v", v) }