Skip to content

Commit

Permalink
feat(collections): add alternative value codec (cosmos#16773)
Browse files Browse the repository at this point in the history
Co-authored-by: unknown unknown <unknown@unknown>
  • Loading branch information
testinginprod and unknown unknown authored Jul 4, 2023
1 parent f0aec3f commit 309ed1a
Show file tree
Hide file tree
Showing 12 changed files with 337 additions and 4 deletions.
3 changes: 3 additions & 0 deletions collections/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ Ref: https://keepachangelog.com/en/1.0.0/
### Features

* [#16074](https://github.com/cosmos/cosmos-sdk/pull/16607) - Introduces `Clear` method for `Map` and `KeySet`
* [#16773](https://github.com/cosmos/cosmos-sdk/pull/16773)
* Adds `AltValueCodec` which provides a way to decode a value in two ways.
* Adds the possibility to specify an alternative way to decode the values of `KeySet`, `indexes.Multi`, `indexes.ReversePair`.

## [v0.2.0](https://github.com/cosmos/cosmos-sdk/releases/tag/collections%2Fv0.2.0)

Expand Down
27 changes: 27 additions & 0 deletions collections/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1117,3 +1117,30 @@ func (k Keeper) GetAccount(ctx sdk.context, addr sdk.AccAddress) (sdk.AccountI,
return k.Accounts.Get(ctx, addr)
}
```

## Advanced Usages

### Alternative Value Codec

The `codec.AltValueCodec` allows a collection to decode values using a different codec than the one used to encode them.
Basically it enables to decode two different byte representations of the same concrete value.
It can be used to lazily migrate values from one bytes representation to another, as long as the new representation is
not able to decode the old one.

A concrete example can be found in `x/bank` where the balance was initially stored as `Coin` and then migrated to `Int`.

```go

var BankBalanceValueCodec = codec.NewAltValueCodec(sdk.IntValue, func(b []byte) (sdk.Int, error) {
coin := sdk.Coin{}
err := coin.Unmarshal(b)
if err != nil {
return sdk.Int{}, err
}
return coin.Amount, nil
})
```

The above example shows how to create an `AltValueCodec` that can decode both `sdk.Int` and `sdk.Coin` values. The provided
decoder function will be used as a fallback in case the default decoder fails. When the value will be encoded back into state
it will use the default encoder. This allows to lazily migrate values to a new bytes representation.
47 changes: 47 additions & 0 deletions collections/codec/alternative_value.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package codec

// NewAltValueCodec returns a new AltValueCodec. canonicalValueCodec is the codec that you want the value
// to be encoded and decoded as, alternativeDecoder is a function that will attempt to decode the value
// in case the canonicalValueCodec fails to decode it.
func NewAltValueCodec[V any](canonicalValueCodec ValueCodec[V], alternativeDecoder func([]byte) (V, error)) ValueCodec[V] {
return AltValueCodec[V]{
canonicalValueCodec: canonicalValueCodec,
alternativeDecoder: alternativeDecoder,
}
}

// AltValueCodec is a codec that can decode a value from state in an alternative format.
// This is useful for migrating data from one format to another. For example, in x/bank
// balances were initially encoded as sdk.Coin, now they are encoded as math.Int.
// The AltValueCodec will be trying to decode the value as math.Int, and if that fails,
// it will attempt to decode it as sdk.Coin.
// NOTE: if the canonical format can also decode the alternative format, then this codec
// will produce undefined and undesirable behavior.
type AltValueCodec[V any] struct {
canonicalValueCodec ValueCodec[V]
alternativeDecoder func([]byte) (V, error)
}

// Decode will attempt to decode the value from state using the canonical value codec.
// If it fails to decode, it will attempt to decode the value using the alternative decoder.
func (a AltValueCodec[V]) Decode(b []byte) (V, error) {
v, err := a.canonicalValueCodec.Decode(b)
if err != nil {
return a.alternativeDecoder(b)
}
return v, nil
}

// Below there is the implementation of ValueCodec relying on the canonical value codec.

func (a AltValueCodec[V]) Encode(value V) ([]byte, error) { return a.canonicalValueCodec.Encode(value) }

func (a AltValueCodec[V]) EncodeJSON(value V) ([]byte, error) {
return a.canonicalValueCodec.EncodeJSON(value)
}

func (a AltValueCodec[V]) DecodeJSON(b []byte) (V, error) { return a.canonicalValueCodec.DecodeJSON(b) }

func (a AltValueCodec[V]) Stringify(value V) string { return a.canonicalValueCodec.Stringify(value) }

func (a AltValueCodec[V]) ValueType() string { return a.canonicalValueCodec.ValueType() }
53 changes: 53 additions & 0 deletions collections/codec/alternative_value_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package codec_test

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/require"

"cosmossdk.io/collections/codec"
"cosmossdk.io/collections/colltest"
)

type altValue struct {
Value uint64 `json:"value"`
}

func TestAltValueCodec(t *testing.T) {
// we assume we want to migrate the value from json(altValue) to just be
// the raw value uint64.
canonical := codec.KeyToValueCodec(codec.NewUint64Key[uint64]())
alternative := func(v []byte) (uint64, error) {
var alt altValue
err := json.Unmarshal(v, &alt)
if err != nil {
return 0, err
}
return alt.Value, nil
}

cdc := codec.NewAltValueCodec(canonical, alternative)

t.Run("decodes alternative value", func(t *testing.T) {
expected := uint64(100)
alternativeEncodedBytes, err := json.Marshal(altValue{Value: expected})
require.NoError(t, err)
got, err := cdc.Decode(alternativeEncodedBytes)
require.NoError(t, err)
require.Equal(t, expected, got)
})

t.Run("decodes canonical value", func(t *testing.T) {
expected := uint64(100)
canonicalEncodedBytes, err := cdc.Encode(expected)
require.NoError(t, err)
got, err := cdc.Decode(canonicalEncodedBytes)
require.NoError(t, err)
require.Equal(t, expected, got)
})

t.Run("conformance", func(t *testing.T) {
colltest.TestValueCodec(t, cdc, uint64(100))
})
}
2 changes: 1 addition & 1 deletion collections/codec/bool.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func (b boolKey[T]) Decode(buffer []byte) (int, T, error) {
}
}

func (b boolKey[T]) Size(key T) int { return 1 }
func (b boolKey[T]) Size(_ T) int { return 1 }

func (b boolKey[T]) EncodeJSON(value T) ([]byte, error) {
return json.Marshal(value)
Expand Down
5 changes: 5 additions & 0 deletions collections/colltest/codec.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ func TestKeyCodec[T any](t *testing.T, keyCodec codec.KeyCodec[T], key T) {
decoded, err := keyCodec.DecodeJSON(keyJSON)
require.NoError(t, err)
require.Equal(t, key, decoded, "json encoding and decoding did not produce the same results")

// check type
require.NotEmpty(t, keyCodec.KeyType())
// check string
_ = keyCodec.Stringify(key)
}

// TestValueCodec asserts the correct behavior of a ValueCodec over the type T.
Expand Down
27 changes: 27 additions & 0 deletions collections/indexes/multi.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ import (
"cosmossdk.io/collections/codec"
)

type multiOptions struct {
uncheckedValue bool
}

// WithMultiUncheckedValue is an option that can be passed to NewMulti to
// ignore index values different from '[]byte{}' and continue with the operation.
// This should be used only to behave nicely in case you have used values different
// from '[]byte{}' in your storage before migrating to collections. Refer to
// WithKeySetUncheckedValue for more information.
func WithMultiUncheckedValue() func(*multiOptions) {
return func(o *multiOptions) {
o.uncheckedValue = true
}
}

// Multi defines the most common index. It can be used to create a reference between
// a field of value and its primary key. Multiple primary keys can be mapped to the same
// reference key as the index does not enforce uniqueness constraints.
Expand All @@ -27,7 +42,19 @@ func NewMulti[ReferenceKey, PrimaryKey, Value any](
refCodec codec.KeyCodec[ReferenceKey],
pkCodec codec.KeyCodec[PrimaryKey],
getRefKeyFunc func(pk PrimaryKey, value Value) (ReferenceKey, error),
options ...func(*multiOptions),
) *Multi[ReferenceKey, PrimaryKey, Value] {
o := new(multiOptions)
for _, opt := range options {
opt(o)
}
if o.uncheckedValue {
return &Multi[ReferenceKey, PrimaryKey, Value]{
getRefKey: getRefKeyFunc,
refKeys: collections.NewKeySet(schema, prefix, name, collections.PairKeyCodec(refCodec, pkCodec), collections.WithKeySetUncheckedValue()),
}
}

return &Multi[ReferenceKey, PrimaryKey, Value]{
getRefKey: getRefKeyFunc,
refKeys: collections.NewKeySet(schema, prefix, name, collections.PairKeyCodec(refCodec, pkCodec)),
Expand Down
49 changes: 49 additions & 0 deletions collections/indexes/multi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,52 @@ func TestMultiIndex(t *testing.T) {
require.False(t, iter.Valid())
require.NoError(t, iter.Close())
}

func TestMultiUnchecked(t *testing.T) {
sk, ctx := deps()
schema := collections.NewSchemaBuilder(sk)

uncheckedMi := NewMulti(schema, collections.NewPrefix("prefix"), "multi_index", collections.StringKey, collections.Uint64Key, func(_ uint64, value company) (string, error) {
return value.City, nil
}, WithMultiUncheckedValue())

mi := NewMulti(schema, collections.NewPrefix("prefix"), "multi_index", collections.StringKey, collections.Uint64Key, func(_ uint64, value company) (string, error) {
return value.City, nil
})

rawKey, err := collections.EncodeKeyWithPrefix(
collections.NewPrefix("prefix"),
uncheckedMi.KeyCodec(),
collections.Join("milan", uint64(2)))
require.NoError(t, err)

// set value to be something different from []byte{}
require.NoError(t, sk.OpenKVStore(ctx).Set(rawKey, []byte("something")))

// normal multi index will fail.
err = mi.Walk(ctx, nil, func(indexingKey string, indexedKey uint64) (stop bool, err error) {
return true, err
})
require.ErrorIs(t, err, collections.ErrEncoding)

// unchecked multi index will not fail.
err = uncheckedMi.Walk(ctx, nil, func(indexingKey string, indexedKey uint64) (stop bool, err error) {
require.Equal(t, "milan", indexingKey)
require.Equal(t, uint64(2), indexedKey)
return true, err
})
require.NoError(t, err)

// unchecked multi will also reset the value
err = mi.Reference(ctx, 2, company{City: "milan"}, func() (company, error) {
return company{
City: "milan",
}, nil
})
require.NoError(t, err)

// value reset to []byte{}
rawValue, err := sk.OpenKVStore(ctx).Get(rawKey)
require.NoError(t, err)
require.Equal(t, []byte{}, rawValue)
}
26 changes: 26 additions & 0 deletions collections/indexes/reverse_pair.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ import (
"cosmossdk.io/collections/codec"
)

type reversePairOptions struct {
uncheckedValue bool
}

// WithReversePairUncheckedValue is an option that can be passed to NewReversePair to
// ignore index values different from '[]byte{}' and continue with the operation.
// This should be used only if you are migrating to collections and have used a different
// placeholder value in your storage index keys.
// Refer to WithKeySetUncheckedValue for more information.
func WithReversePairUncheckedValue() func(*reversePairOptions) {
return func(o *reversePairOptions) {
o.uncheckedValue = true
}
}

// ReversePair is an index that is used with collections.Pair keys. It indexes objects by their second part of the key.
// When the value is being indexed by collections.IndexedMap then ReversePair will create a relationship between
// the second part of the primary key and the first part.
Expand All @@ -31,8 +46,19 @@ func NewReversePair[Value, K1, K2 any](
prefix collections.Prefix,
name string,
pairCodec codec.KeyCodec[collections.Pair[K1, K2]],
options ...func(*reversePairOptions),
) *ReversePair[K1, K2, Value] {
pkc := pairCodec.(pairKeyCodec[K1, K2])
o := new(reversePairOptions)
for _, option := range options {
option(o)
}
if o.uncheckedValue {
return &ReversePair[K1, K2, Value]{
refKeys: collections.NewKeySet(sb, prefix, name, collections.PairKeyCodec(pkc.KeyCodec2(), pkc.KeyCodec1()), collections.WithKeySetUncheckedValue()),
}
}

mi := &ReversePair[K1, K2, Value]{
refKeys: collections.NewKeySet(sb, prefix, name, collections.PairKeyCodec(pkc.KeyCodec2(), pkc.KeyCodec1())),
}
Expand Down
35 changes: 35 additions & 0 deletions collections/indexes/reverse_pair_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,38 @@ func TestReversePair(t *testing.T) {
_, err = indexedMap.Indexes.Denom.MatchExact(ctx, "atom")
require.ErrorIs(t, collections.ErrInvalidIterator, err)
}

func TestUncheckedReversePair(t *testing.T) {
sk, ctx := deps()
sb := collections.NewSchemaBuilder(sk)
prefix := collections.NewPrefix("prefix")
keyCodec := collections.PairKeyCodec(collections.StringKey, collections.StringKey)

uncheckedRp := NewReversePair[Amount](sb, prefix, "denom_index", keyCodec, WithReversePairUncheckedValue())
rp := NewReversePair[Amount](sb, prefix, "denom_index", keyCodec)

rawKey, err := collections.EncodeKeyWithPrefix(prefix, uncheckedRp.KeyCodec(), collections.Join("atom", "address1"))
require.NoError(t, err)

require.NoError(t, sk.OpenKVStore(ctx).Set(rawKey, []byte("i should not be here")))

// normal reverse pair fails
err = rp.Walk(ctx, nil, func(denom, address string) (bool, error) {
return false, nil
})
require.ErrorIs(t, err, collections.ErrEncoding)

// unchecked reverse pair succeeds
err = uncheckedRp.Walk(ctx, nil, func(indexingKey, indexedKey string) (stop bool, err error) {
require.Equal(t, "atom", indexingKey)
return true, nil
})
require.NoError(t, err)

// unchecked reverse pair lazily updates
err = uncheckedRp.Reference(ctx, collections.Join("address1", "atom"), 0, nil)
require.NoError(t, err)
rawValue, err := sk.OpenKVStore(ctx).Get(rawKey)
require.NoError(t, err)
require.Equal(t, []byte{}, rawValue)
}
Loading

0 comments on commit 309ed1a

Please sign in to comment.