feat(schema): testing utilities (#20705)

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Aaron Craelius 2024-07-31 08:58:30 +02:00 committed by GitHub
parent 6eea0ae6d2
commit e7844e640c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 2147 additions and 2 deletions

View File

@ -479,6 +479,36 @@ jobs:
with:
projectBaseDir: schema/
test-schema-testing:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"
cache: true
cache-dependency-path: schema/testing/go.sum
- uses: technote-space/get-diff-action@v6.1.2
id: git_diff
with:
PATTERNS: |
schema/testing/**/*.go
schema/testing/go.mod
schema/testing/go.sum
- name: tests
if: env.GIT_DIFF
run: |
cd schema
go test -mod=readonly -timeout 30m -coverprofile=coverage.out -covermode=atomic ./...
- name: sonarcloud
if: ${{ env.GIT_DIFF && !github.event.pull_request.draft && env.SONAR_TOKEN != null }}
uses: SonarSource/sonarcloud-github-action@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
projectBaseDir: schema/testing/
test-indexer-postgres:
runs-on: ubuntu-latest
steps:

View File

@ -0,0 +1,15 @@
package appdata
// PacketForwarder creates a listener which listens to all callbacks and forwards all packets to the provided
// function. This is intended to be used primarily for tests and debugging.
func PacketForwarder(f func(Packet) error) Listener {
return Listener{
InitializeModuleData: func(data ModuleInitializationData) error { return f(data) },
OnTx: func(data TxData) error { return f(data) },
OnEvent: func(data EventData) error { return f(data) },
OnKVPair: func(data KVPairData) error { return f(data) },
OnObjectUpdate: func(data ObjectUpdateData) error { return f(data) },
StartBlock: func(data StartBlockData) error { return f(data) },
Commit: func(data CommitData) error { return f(data) },
}
}

View File

@ -0,0 +1,39 @@
package appdata
import (
"reflect"
"testing"
)
func TestPacketForwarder(t *testing.T) {
var received []Packet
listener := PacketForwarder(func(packet Packet) error {
received = append(received, packet)
return nil
})
expected := []Packet{
ModuleInitializationData{},
StartBlockData{},
TxData{},
EventData{},
KVPairData{},
ObjectUpdateData{},
CommitData{},
}
for i, packet := range expected {
err := listener.SendPacket(packet)
if err != nil {
t.Fatal(err)
}
if len(received) != i+1 {
t.Fatalf("didn't receive packet %v", packet)
}
if !reflect.DeepEqual(received[i], packet) {
t.Fatalf("received packet %v, expected %v", received[i], packet)
}
}
}

View File

@ -151,7 +151,7 @@ func (t Kind) String() string {
case Float64Kind:
return "float64"
case AddressKind:
return "bech32address"
return "address"
case EnumKind:
return "enum"
case JSONKind:

View File

@ -213,7 +213,7 @@ func TestKind_String(t *testing.T) {
{Float64Kind, "float64"},
{JSONKind, "json"},
{EnumKind, "enum"},
{AddressKind, "bech32address"},
{AddressKind, "address"},
{InvalidKind, "invalid(0)"},
}
for i, tt := range tests {

View File

@ -0,0 +1,37 @@
<!--
Guiding Principles:
Changelogs are for humans, not machines.
There should be an entry for every single version.
The same types of changes should be grouped.
Versions and sections should be linkable.
The latest version comes first.
The release date of each version is displayed.
Mention whether you follow Semantic Versioning.
Usage:
Change log entries are to be added to the Unreleased section under the
appropriate stanza (see below). Each entry should ideally include a tag and
the Github issue reference in the following format:
* (<tag>) \#<issue-number> message
The issue numbers will later be link-ified during the release process so you do
not have to worry about including a link manually, but you can if you wish.
Types of changes (Stanzas):
"Features" for new features.
"Improvements" for changes in existing functionality.
"Deprecated" for soon-to-be removed features.
"Bug Fixes" for any bug fixes.
"Client Breaking" for breaking Protobuf, gRPC and REST routes used by end-users.
"CLI Breaking" for breaking CLI commands.
"API Breaking" for breaking exported APIs used by developers building on SDK.
Ref: https://keepachangelog.com/en/1.0.0/
-->
# Changelog
## [Unreleased]

50
schema/testing/README.md Normal file
View File

@ -0,0 +1,50 @@
# Schema & Indexer Testing
This module contains core test utilities and fixtures for testing `cosmossdk.io/schema` and `cosmossdk.io/schema/indexer` functionality. It is managed as a separate go module to manage versions better and allow for dependencies on useful testing libraries without imposing those elsewhere.
The two primary intended uses of this library are:
- testing that indexers can handle all valid app data that they might be asked to index
- testing that state management frameworks properly map their data to and from schema types
## Testing Indexers
Indexers are expected to process all valid `schema` and `appdata` types, yet it may be hard to find a data set in the wild that comprehensively represents the full valid range of these types. This library provides utilities for simulating such data. The simplest way for an indexer to leverage this test framework is to implement the `appdatasim.HasAppData` type against their data store. Then the `appdatasim.Simulator` can be used to generate deterministically random valid data that can be sent to the indexer and also stored in the simulator. After each generated block is applied, `appdatasim.DiffAppData` can be used to compare the expected state in the simulator to the actual state in the indexer.
This example code shows how this might look in a test:
```go
func TestMyIndexer(t *testing.T) {
var myIndexerListener appdata.Listener
var myIndexerAppData appdatasim.HasAppData
// do the setup for your indexer and return an appdata.Listener to consume updates and the appdatasim.HasAppData instance to check the actual vs expected data
myIndexerListener, myIndexerAppData := myIndexer.Setup()
simulator, err := appdatasim.NewSimulator(appdatatest.SimulatorOptions{
AppSchema: indexertesting.ExampleAppSchema,
StateSimOptions: statesim.Options{
CanRetainDeletions: true,
},
Listener: myIndexerListener,
})
require.NoError(t, err)
blockDataGen := simulator.BlockDataGen()
for i := 0; i < 1000; i++ {
// using Example generates a deterministic data set based
// on a seed so that regression tests can be created OR rapid.Check can
// be used for fully random property-based testing
blockData := blockDataGen.Example(i)
// process the generated block data with the simulator which will also
// send it to the indexer
require.NoError(t, simulator.ProcessBlockData(blockData))
// compare the expected state in the simulator to the actual state in the indexer and expect the diff to be empty
require.Empty(t, appdatasim.DiffAppData(simulator, myIndexerAppData))
}
}
```
## Testing State Management Frameworks
The compliance of frameworks like `cosmossdk.io/collections` and `cosmossdk.io/orm` with `cosmossdk.io/schema` can be tested with this framework. One example of how this might be done is if there is a `KeyCodec` that represents an array of `schema.Field`s then `schematesting.ObjectKeyGen` might be used to generate a random object key which encoded and then decoded and then `schematesting.DiffObjectKeys` is used to compare the expected key with the decoded key. If such state management frameworks require that users that schema compliance when implementing things like `KeyCodec`s then those state management frameworks should specify best practices for users.

21
schema/testing/app.go Normal file
View File

@ -0,0 +1,21 @@
package schematesting
import (
"fmt"
"pgregory.net/rapid"
"cosmossdk.io/schema"
)
// AppSchemaGen generates random valid app schemas, essentially a map of module names to module schemas.
var AppSchemaGen = rapid.Custom(func(t *rapid.T) map[string]schema.ModuleSchema {
schema := make(map[string]schema.ModuleSchema)
numModules := rapid.IntRange(1, 10).Draw(t, "numModules")
for i := 0; i < numModules; i++ {
moduleName := NameGen.Draw(t, "moduleName")
moduleSchema := ModuleSchemaGen.Draw(t, fmt.Sprintf("moduleSchema[%s]", moduleName))
schema[moduleName] = moduleSchema
}
return schema
})

View File

@ -0,0 +1,18 @@
package schematesting
import (
"testing"
"github.com/stretchr/testify/require"
"pgregory.net/rapid"
)
func TestAppSchema(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
schema := AppSchemaGen.Draw(t, "schema")
for moduleName, moduleSchema := range schema {
require.NotEmpty(t, moduleName)
require.NoError(t, moduleSchema.Validate())
}
})
}

View File

@ -0,0 +1,161 @@
package appdatasim
import (
"fmt"
"sort"
"pgregory.net/rapid"
"cosmossdk.io/schema"
"cosmossdk.io/schema/appdata"
"cosmossdk.io/schema/testing/statesim"
)
// Options are the options for creating an app data simulator.
type Options struct {
// AppSchema is the schema to use. If it is nil, then schematesting.ExampleAppSchema
// will be used.
AppSchema map[string]schema.ModuleSchema
// Listener is the listener to output appdata updates to.
Listener appdata.Listener
// StateSimOptions are the options to pass to the statesim.App instance used under
// the hood.
StateSimOptions statesim.Options
}
// Simulator simulates a stream of app data. Currently, it only simulates InitializeModuleData, OnObjectUpdate,
// StartBlock and Commit callbacks but others will be added in the future.
type Simulator struct {
state *statesim.App
options Options
blockNum uint64
}
// BlockData represents the app data packets in a block.
type BlockData = []appdata.Packet
// NewSimulator creates a new app data simulator with the given options and runs its
// initialization methods.
func NewSimulator(options Options) (*Simulator, error) {
sim := &Simulator{
// we initialize the state simulator with no app schema because we'll do
// that in the first block
state: statesim.NewApp(nil, options.StateSimOptions),
options: options,
}
err := sim.initialize()
if err != nil {
return nil, err
}
return sim, nil
}
func (a *Simulator) initialize() error {
// in block "0" we only pass module initialization data and don't
// even generate any real block data
var keys []string
for key := range a.options.AppSchema {
keys = append(keys, key)
}
sort.Strings(keys)
for _, moduleName := range keys {
err := a.ProcessPacket(appdata.ModuleInitializationData{
ModuleName: moduleName,
Schema: a.options.AppSchema[moduleName],
})
if err != nil {
return err
}
}
return nil
}
// BlockDataGen generates random block data. It is expected that generated data is passed to ProcessBlockData
// to simulate the app data stream and advance app state based on the object updates in the block. The first
// packet in the block data will be a StartBlockData packet with the height set to the next block height.
func (a *Simulator) BlockDataGen() *rapid.Generator[BlockData] {
return a.BlockDataGenN(0, 100)
}
// BlockDataGenN creates a block data generator which allows specifying the maximum number of updates per block.
func (a *Simulator) BlockDataGenN(minUpdatesPerBlock, maxUpdatesPerBlock int) *rapid.Generator[BlockData] {
numUpdatesGen := rapid.IntRange(minUpdatesPerBlock, maxUpdatesPerBlock)
return rapid.Custom(func(t *rapid.T) BlockData {
var packets BlockData
packets = append(packets, appdata.StartBlockData{Height: a.blockNum + 1})
updateSet := map[string]bool{}
// filter out any updates to the same key from this block, otherwise we can end up with weird errors
updateGen := a.state.UpdateGen().Filter(func(data appdata.ObjectUpdateData) bool {
for _, update := range data.Updates {
_, existing := updateSet[fmt.Sprintf("%s:%v", data.ModuleName, update.Key)]
if existing {
return false
}
}
return true
})
numUpdates := numUpdatesGen.Draw(t, "numUpdates")
for i := 0; i < numUpdates; i++ {
data := updateGen.Draw(t, fmt.Sprintf("update[%d]", i))
for _, update := range data.Updates {
updateSet[fmt.Sprintf("%s:%v", data.ModuleName, update.Key)] = true
}
packets = append(packets, data)
}
packets = append(packets, appdata.CommitData{})
return packets
})
}
// ProcessBlockData processes the given block data, advancing the app state based on the object updates in the block
// and forwarding all packets to the attached listener. It is expected that the data passed came from BlockDataGen,
// however, other data can be passed as long as any StartBlockData packet has the height set to the current height + 1.
func (a *Simulator) ProcessBlockData(data BlockData) error {
for _, packet := range data {
err := a.ProcessPacket(packet)
if err != nil {
return err
}
}
return nil
}
// ProcessPacket processes a single packet, advancing the app state based on the data in the packet,
// and forwarding the packet to any listener.
func (a *Simulator) ProcessPacket(packet appdata.Packet) error {
err := a.options.Listener.SendPacket(packet)
if err != nil {
return err
}
switch packet := packet.(type) {
case appdata.StartBlockData:
if packet.Height != a.blockNum+1 {
return fmt.Errorf("invalid StartBlockData packet: %v", packet)
}
a.blockNum++
case appdata.ModuleInitializationData:
return a.state.InitializeModule(packet)
case appdata.ObjectUpdateData:
return a.state.ApplyUpdate(packet)
}
return nil
}
// AppState returns the current app state backing the simulator.
func (a *Simulator) AppState() statesim.AppState {
return a.state
}
// BlockNum returns the current block number of the simulator.
func (a *Simulator) BlockNum() uint64 {
return a.blockNum
}

View File

@ -0,0 +1,108 @@
package appdatasim
import (
"bytes"
"encoding/json"
"fmt"
"io"
"testing"
"github.com/stretchr/testify/require"
"gotest.tools/v3/golden"
"cosmossdk.io/schema/appdata"
schematesting "cosmossdk.io/schema/testing"
"cosmossdk.io/schema/testing/statesim"
)
func TestAppSimulator_mirror(t *testing.T) {
t.Run("default", func(t *testing.T) {
testAppSimulatorMirror(t, false)
})
t.Run("retain deletes", func(t *testing.T) {
testAppSimulatorMirror(t, true)
})
}
func testAppSimulatorMirror(t *testing.T, retainDeletes bool) { // nolint: thelper // this isn't a test helper function
stateSimOpts := statesim.Options{CanRetainDeletions: retainDeletes}
mirror, err := NewSimulator(Options{
StateSimOptions: stateSimOpts,
})
require.NoError(t, err)
appSim, err := NewSimulator(Options{
AppSchema: schematesting.ExampleAppSchema,
Listener: appdata.PacketForwarder(func(packet appdata.Packet) error {
return mirror.ProcessPacket(packet)
}),
StateSimOptions: stateSimOpts,
})
require.NoError(t, err)
blockDataGen := appSim.BlockDataGenN(50, 100)
for i := 0; i < 10; i++ {
data := blockDataGen.Example(i + 1)
require.NoError(t, appSim.ProcessBlockData(data))
require.Empty(t, DiffAppData(appSim, mirror))
}
}
func TestAppSimulator_exampleSchema(t *testing.T) {
out := &bytes.Buffer{}
appSim, err := NewSimulator(Options{
AppSchema: schematesting.ExampleAppSchema,
Listener: writerListener(out),
StateSimOptions: statesim.Options{},
})
require.NoError(t, err)
blockDataGen := appSim.BlockDataGenN(10, 20)
for i := 0; i < 10; i++ {
data := blockDataGen.Example(i + 1)
require.NoError(t, appSim.ProcessBlockData(data))
}
// we do a golden test so that we can have some human-readable proof that
// the simulator is emitting updates that look like what we expect
// make sure you check the golden tests when the simulator is changed
// this can be updated by running "go test . -update"
golden.Assert(t, out.String(), "app_sim_example_schema.txt")
}
// writerListener returns a listener that writes to the provided writer. It currently
// only covers callbacks which are called by the simulator, but others will be added
// as the simulator covers other cases.
func writerListener(w io.Writer) appdata.Listener {
return appdata.Listener{
StartBlock: func(data appdata.StartBlockData) error {
_, err := fmt.Fprintf(w, "StartBlock: %v\n", data)
return err
},
OnTx: nil,
OnEvent: nil,
OnKVPair: nil,
Commit: func(data appdata.CommitData) error {
_, err := fmt.Fprintf(w, "Commit: %v\n", data)
return err
},
InitializeModuleData: func(data appdata.ModuleInitializationData) error {
bz, err := json.Marshal(data)
if err != nil {
return err
}
_, err = fmt.Fprintf(w, "InitializeModuleData: %s\n", bz)
return err
},
OnObjectUpdate: func(data appdata.ObjectUpdateData) error {
bz, err := json.Marshal(data)
if err != nil {
return err
}
_, err = fmt.Fprintf(w, "OnObjectUpdate: %s\n", bz)
return err
},
}
}

View File

@ -0,0 +1,38 @@
package appdatasim
import (
"fmt"
"cosmossdk.io/schema/testing/statesim"
)
// HasAppData defines an interface for things that hold app data include app state.
// If an indexer implements this then DiffAppData can be used to compare it with
// the Simulator state which also implements this.
type HasAppData interface {
// AppState returns the app state.
AppState() statesim.AppState
// BlockNum returns the latest block number.
BlockNum() uint64
}
// DiffAppData compares the app data of two objects that implement HasAppData.
// This can be used by indexer to compare their state with the Simulator state
// if the indexer implements HasAppData.
// It returns a human-readable diff if the app data differs and the empty string
// if they are the same.
func DiffAppData(expected, actual HasAppData) string {
res := ""
if stateDiff := statesim.DiffAppStates(expected.AppState(), actual.AppState()); stateDiff != "" {
res += "App State Diff:\n"
res += stateDiff
}
if expected.BlockNum() != actual.BlockNum() {
res += fmt.Sprintf("BlockNum: expected %d, got %d\n", expected.BlockNum(), actual.BlockNum())
}
return res
}

View File

@ -0,0 +1,39 @@
package appdatasim
import (
"testing"
"github.com/stretchr/testify/require"
"gotest.tools/v3/golden"
"cosmossdk.io/schema"
schematesting "cosmossdk.io/schema/testing"
)
// this test checks that diffs in app data are deterministic and can be used for regression testing
func TestDiffAppData(t *testing.T) {
appSim, err := NewSimulator(Options{
AppSchema: schematesting.ExampleAppSchema,
})
require.NoError(t, err)
mirror, err := NewSimulator(Options{
// add just one module to the mirror
AppSchema: map[string]schema.ModuleSchema{
"all_kinds": schematesting.ExampleAppSchema["all_kinds"],
},
})
require.NoError(t, err)
// mirror one block
blockGen := appSim.BlockDataGenN(50, 100)
blockData := blockGen.Example(1)
require.NoError(t, appSim.ProcessBlockData(blockData))
require.NoError(t, mirror.ProcessBlockData(blockData))
// produce another block, but don't mirror it so that they're out of sync
blockData = blockGen.Example(2)
require.NoError(t, appSim.ProcessBlockData(blockData))
golden.Assert(t, DiffAppData(appSim, mirror), "diff_example.txt")
}

View File

@ -0,0 +1,2 @@
// Package appdatasim contains utilities for simulating valid streams of app data for testing indexer implementations.
package appdatasim

View File

@ -0,0 +1,161 @@
InitializeModuleData: {"ModuleName":"all_kinds","Schema":{}}
InitializeModuleData: {"ModuleName":"test_cases","Schema":{}}
StartBlock: {1 <nil> <nil>}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"RetainDeletions","Key":"","Value":[4602,"NwsAtcME5moByAKKwXU="],"Delete":false},{"TypeName":"Simple","Key":"","Value":[-89,"fgY="],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Singleton","Key":null,"Value":["֑Ⱥ|@!`",""],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"TwoKeys","Key":["a\u003c",-84],"Value":null,"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_integer","Key":"11598611","Value":["016807","-016339012"],"Delete":false},{"TypeName":"test_uint16","Key":9407,"Value":{"valNotNull":0,"valNullable":null},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_int16","Key":-4371,"Value":{"valNotNull":-3532,"valNullable":-15},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Simple","Key":"\u000b𝜛࣢Ⱥ+\u001c","Value":{"Value1":-28,"Value2":"AAE5AAAAATgB"},"Delete":false},{"TypeName":"RetainDeletions","Key":".(","Value":[116120837,"/wwIyAAUciAC"],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_bool","Key":false,"Value":[false,false],"Delete":false},{"TypeName":"test_float64","Key":0,"Value":[-0.819610595703125,0.08682777894820155],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ManyValues","Key":"+!𐅫a⍦","Value":[36,"fi07",0.000005021243239880513,2],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ThreeKeys","Key":["˖|󺪆𝅲鄖_.;ǀ⃣%;#~",16,512578],"Value":686,"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_int16","Key":-988,"Value":[6,null],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Simple","Key":"?൙ဴ𑇑\".+AB","Value":{"Value1":-75,"Value2":"FdQcnKoeAAIB"},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_int8","Key":6,"Value":[6,null],"Delete":false},{"TypeName":"test_int32","Key":-7573,"Value":[-57,null],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ThreeKeys","Key":["\tA𐞙?\t",-5317218,1],"Value":6450,"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_bytes","Key":"AwMbGS8=","Value":["AwQA3EBwHgCEABQBAw==",null],"Delete":false},{"TypeName":"test_bool","Key":true,"Value":{"valNotNull":true,"valNullable":null},"Delete":false}]}
Commit: {}
StartBlock: {2 <nil> <nil>}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"RetainDeletions","Key":"ᾢ","Value":[3,"AQQF3LYA"],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ManyValues","Key":"_; ᾚDzA{˭҄\nA^$?ᾦ,:\u003c\"?_\u0014;|","Value":{"Value1":-15,"Value2":"PED/","Value3":7.997156312768529e-26,"Value4":33975920899014},"Delete":false},{"TypeName":"Simple","Key":"","Value":[-2,"FwY="],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_address","Key":"AgDYbdMBAZ31DGsBs7UGnAp/BgX/BkQFAQ3Si9sd6x8Hrw==","Value":{"valNotNull":"/2ADvYQC/wATggAAAwYBLjQCAv8=","valNullable":null},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_address","Key":"GJQSAs0BGAILARUXAwIrnf8pBgIrRQOrSQNOEgfvA8ATAAEMVw8s/w==","Value":["GwADWP8AMB6z0AZCDgEDMv8DfQEQ","DAHaBAOt3g16AQAfNQEBeQYBAlv/AfgKUi0YAgg="],"Delete":false},{"TypeName":"test_duration","Key":468,"Value":[-52600,null],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Simple","Key":": Ⱥ","Value":[-14,"fmoD3wY="],"Delete":false},{"TypeName":"TwoKeys","Key":["Ⱥ꙱Lj",12],"Value":null,"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Simple","Key":"$","Value":[13,"Uf8VAgYltOwK"],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"TwoKeys","Key":["",-4],"Value":null,"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_bool","Key":true,"Value":{"valNotNull":false,"valNullable":null},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Simple","Key":"\t;𞄱𑨁௺ⅦA×~ႂᛯaA","Value":{"Value1":2147483647,"Value2":"AAMBAgADGA4="},"Delete":false},{"TypeName":"RetainDeletions","Key":"\nȺ*|𑀾","Value":{"Value1":0,"Value2":"ChoCY0w="},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Singleton","Key":null,"Value":["\u000b!","OQ=="],"Delete":false},{"TypeName":"Singleton","Key":null,"Value":["a",""],"Delete":false}]}
Commit: {}
StartBlock: {3 <nil> <nil>}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_int32","Key":-103,"Value":{"valNotNull":-1887959808,"valNullable":2096073436},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_bool","Key":true,"Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ManyValues","Key":"","Value":{"Value1":-4,"Value2":"","Value3":5.199354003997906e-290,"Value4":2703222758},"Delete":false},{"TypeName":"ThreeKeys","Key":["˖|󺪆𝅲鄖_.;ǀ⃣%;#~",16,512578],"Value":11281,"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_uint8","Key":7,"Value":{"valNotNull":1,"valNullable":150},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Simple","Key":"?൙ဴ𑇑\".+AB","Value":[-1,"LP8="],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_integer","Key":"72961530924372552","Value":["080207094","-598415299"],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_float64","Key":-1.7392669057403718e+166,"Value":[1.556643269146063e-16,-1.1920928955078125e-7],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"TwoKeys","Key":["ऻॉ~$𒐈+Xʱ:²-~?ʳ~$ₜ\\",-787],"Value":null,"Delete":false},{"TypeName":"Singleton","Key":null,"Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Simple","Key":"\u000b𝜛࣢Ⱥ+\u001c","Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_uint8","Key":14,"Value":[4,1],"Delete":false},{"TypeName":"test_address","Key":"AgDYbdMBAZ31DGsBs7UGnAp/BgX/BkQFAQ3Si9sd6x8Hrw==","Value":["BQEBAemXEjNqrx2kATMdGuUCESLnES4KAfAC//v/A9gJIaQNAQH/kcYdDw==","lMhHJAXnpQG+wgIzzAoNWjoAGTIABQ=="],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"TwoKeys","Key":["Ⱥ꙱Lj",12],"Value":null,"Delete":true}]}
Commit: {}
StartBlock: {4 <nil> <nil>}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_duration","Key":-152,"Value":{"valNotNull":1476419818092,"valNullable":-163469},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Simple","Key":"","Value":[-1,"GwE="],"Delete":false},{"TypeName":"ManyValues","Key":"","Value":[1,"",4.1017235364794545e-228,25],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"RetainDeletions","Key":".(","Value":null,"Delete":true},{"TypeName":"Singleton","Key":null,"Value":{"Value":"ᾫ+=[฿́\u001b\u003cʰ+`𑱐@\u001b*Dž‮#₻\u0001῎ !a܏ῼ","Value2":"AgI="},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Simple","Key":"?൙ဴ𑇑\".+AB","Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"RetainDeletions","Key":" ","Value":[-1,""],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ThreeKeys","Key":["",11,107],"Value":{"Value1":-31402597},"Delete":false},{"TypeName":"Simple","Key":"\t;𞄱𑨁௺ⅦA×~ႂᛯaA","Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_uint16","Key":0,"Value":{"valNotNull":68,"valNullable":null},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_address","Key":"PQYReIgDAAG/6fs+AVcXxgEGDXLQ30f0/w==","Value":["b+QAdJmb/0hGGAMzEoat/wYeAQcB/wO7Ae0BlgQFAP+i7A0rGA8ESIv+Oi+eFwIDHAMAygDjBogABwADAAC5Aw==",null],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ThreeKeys","Key":["\tA𐞙?\t",-5317218,1],"Value":{"Value1":-220},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_integer","Key":"617","Value":{"valNotNull":"688647620","valNullable":null},"Delete":false},{"TypeName":"test_bool","Key":false,"Value":null,"Delete":true}]}
Commit: {}
StartBlock: {5 <nil> <nil>}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_bool","Key":true,"Value":{"valNotNull":true,"valNullable":null},"Delete":false},{"TypeName":"test_uint64","Key":21,"Value":{"valNotNull":73,"valNullable":2},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_address","Key":"AgDYbdMBAZ31DGsBs7UGnAp/BgX/BkQFAQ3Si9sd6x8Hrw==","Value":["BQBsCf9MAgQAGfzKAAu1AYAClAADAhlDAP+oChQBYQA5AA0LT19MujQyf/8FbQMDawAM",null],"Delete":false},{"TypeName":"test_enum","Key":"foo","Value":{"valNotNull":"foo","valNullable":"foo"},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Singleton","Key":null,"Value":["𝔏؃\"ᵚ¡ $A\r",""],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ThreeKeys","Key":["𒑏𑿞=A?¯ \t~",-6,53],"Value":-2,"Delete":false},{"TypeName":"Simple","Key":"?","Value":{"Value1":12,"Value2":"HAIBmK0D"},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"RetainDeletions","Key":"⍦","Value":[-202,"EQTRAwAELg=="],"Delete":false},{"TypeName":"ManyValues","Key":"`aAះA~~⹗=\u000b","Value":[-17,"okMB1d0=",-3.643491752614398e+288,1382771530458],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_float64","Key":0,"Value":[-1014342049.3947449,null],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"TwoKeys","Key":["a\u003c",-84],"Value":null,"Delete":true},{"TypeName":"Simple","Key":"d⹘:","Value":[3016,"AQ=="],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_bytes","Key":"AwMbGS8=","Value":null,"Delete":true},{"TypeName":"test_decimal","Key":"-02","Value":["1219101",null],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_decimal","Key":"9.5E+8","Value":{"valNotNull":"13333140202.01031939e81","valNullable":"7210210.1e+1"},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_uint16","Key":11,"Value":[6,14912],"Delete":false},{"TypeName":"test_duration","Key":-152,"Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"RetainDeletions","Key":"`³Njॊ\u003c?ᾩ‮₦~","Value":{"Value1":2,"Value2":"lAAD4AAABQQbAwABAwHP"},"Delete":false},{"TypeName":"RetainDeletions","Key":"൴~𝔶ٞ蹯a_ ᛮ!؋aض©-?","Value":{"Value1":-6813,"Value2":"AhRPdlAC"},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_address","Key":"BkiVAAcAAJ6xA/dutlmcBe8DAA1UZAsB","Value":["AA57GQP/oifkJ8aYJENTAwLxPhPSAwEI1AA9xQMWAwEoBA==",null],"Delete":false},{"TypeName":"test_address","Key":"7QTt/24APN4FBA/TAG8B/wMBWOoCqP+HNg0FBQIdAw8F5AI=","Value":["BQNcFhh01gEBAm4BAfAlGwMKkCo=","aQQTfSUg2RkZARH/EP8IGAxENIBOGwbPFAA="],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_int8","Key":6,"Value":[-8,null],"Delete":false},{"TypeName":"test_address","Key":"AACHBAjyAgFHOQAABo+PGAK3Bj7TwwBb/wAB3gE=","Value":["ASwojxUABA8BAf/9AgUBIs4WAq9lqAEKAP8FAAgCGwEMDQKHZwEABA82AVZZAHO/ngS7AA==",null],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_address","Key":"AWvYAbSo5gQCAz8XAQYGjwCaRx0DSAUpAWQV","Value":["/z/eNBkL5QAgCwXergJOUCEC/+ICAp4BTgBsVw==","HhoELBAPigABQwIDAxsB7KEAGlIOEAAEYQL/GQA37QAJWg0A"],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"RetainDeletions","Key":" ","Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_decimal","Key":"79","Value":["-7872","53"],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_uint32","Key":1322158439,"Value":{"valNotNull":2415,"valNullable":null},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ManyValues","Key":"_; ᾚDzA{˭҄\nA^$?ᾦ,:\u003c\"?_\u0014;|","Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ThreeKeys","Key":["\tA𐞙?\t",-5317218,1],"Value":-2147483648,"Delete":false},{"TypeName":"ManyValues","Key":"‮۽𝅸\u003c#󠁋","Value":[-3,"",-5.385845077524242e-269,2468],"Delete":false}]}
Commit: {}
StartBlock: {6 <nil> <nil>}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"TwoKeys","Key":["A𞥟",981],"Value":null,"Delete":false},{"TypeName":"ManyValues","Key":"ᵕ؏­􏿽A","Value":{"Value1":-317,"Value2":"AA==","Value3":-37.62890625,"Value4":232},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_address","Key":"AACHBAjyAgFHOQAABo+PGAK3Bj7TwwBb/wAB3gE=","Value":{"valNotNull":"HBwBHAY6AAKO+UwDKRICAT0lgRRvCRvHFFoNAigBAUEDHoQUfB2qApRB/z41AAubARsBATQg3gCppQMAAQwHAQ=="},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_decimal","Key":"9.5E+8","Value":["-2","88111430.0122412446"],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_uint8","Key":7,"Value":null,"Delete":true},{"TypeName":"test_time","Key":"1970-01-01T00:59:59.999999999+01:00","Value":["1970-01-01T01:00:00.000000001+01:00",null],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"RetainDeletions","Key":"൴~𝔶ٞ蹯a_ ᛮ!؋aض©-?","Value":{"Value2":""},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_uint8","Key":14,"Value":{"valNotNull":116},"Delete":false},{"TypeName":"test_duration","Key":100403021838,"Value":[1547,null],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_int64","Key":-34196421,"Value":[56,224549431],"Delete":false},{"TypeName":"test_bool","Key":true,"Value":{"valNotNull":true,"valNullable":null},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ThreeKeys","Key":["ⅷ_ŕ,A",-467,98],"Value":{"Value1":145},"Delete":false},{"TypeName":"RetainDeletions","Key":"?aa₽A\u001b=⇂́ᯫ𖽦ᩣ","Value":{"Value1":0,"Value2":""},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"TwoKeys","Key":["",-4],"Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Simple","Key":"","Value":{"Value1":1174095848,"Value2":"AR//A0kBNVwGGGsHANYAAAAtJQ=="},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_uint64","Key":3,"Value":[14481555953,496],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Simple","Key":"ǃ^@❽\u2028!𑿞aa`","Value":{"Value1":-33730,"Value2":"qKQ="},"Delete":false},{"TypeName":"Singleton","Key":null,"Value":["𞤎𞤡","BAconQGXHRuHXQN/GTUCSQACEg=="],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ManyValues","Key":"ൌ{ ༿","Value":[1110340689,"AQ==",0.00018342199999210607,1],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_bytes","Key":"AwcJA//C","Value":{"valNotNull":"DQ==","valNullable":null},"Delete":false},{"TypeName":"test_duration","Key":210213542904,"Value":[-207349773999415086,null],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_float64","Key":-3.0664227080502325e-103,"Value":[2.125936378595003e-239,null],"Delete":false}]}
Commit: {}
StartBlock: {7 <nil> <nil>}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_bool","Key":true,"Value":{"valNotNull":true,"valNullable":true},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Simple","Key":"d⹘:","Value":{"Value2":""},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"TwoKeys","Key":["ऻॉ~$𒐈+Xʱ:²-~?ʳ~$ₜ\\",-787],"Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"TwoKeys","Key":["ः𒑨Dz؅",-2],"Value":null,"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ThreeKeys","Key":["@(\u0001\u0001\tᛰᾚ𐺭a'ᵆᾭaa",16,817744173394],"Value":{"Value1":-2},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_bytes","Key":"AwcJA//C","Value":{"valNotNull":"AWNXAw=="},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_decimal","Key":"-85","Value":["-2511998","-077.01427082957E-7"],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_int16","Key":-988,"Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_enum","Key":"foo","Value":null,"Delete":true},{"TypeName":"test_decimal","Key":"-02","Value":["-40892500970.58239","11e0"],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_string","Key":"","Value":{"valNotNull":"実.*𑁤!؋A\u000b.{;?󠀮_? *🄑󠇯","valNullable":"(Ⱥ#/\u003c_"},"Delete":false},{"TypeName":"test_integer","Key":"-1391361","Value":{"valNotNull":"-0105","valNullable":null},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_address","Key":"AWvYAbSo5gQCAz8XAQYGjwCaRx0DSAUpAWQV","Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_int8","Key":98,"Value":[-40,null],"Delete":false}]}
Commit: {}
StartBlock: {8 <nil> <nil>}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Simple","Key":"$","Value":[0,""],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_decimal","Key":"8E4","Value":{"valNotNull":"4043421E29","valNullable":null},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_address","Key":"AACHBAjyAgFHOQAABo+PGAK3Bj7TwwBb/wAB3gE=","Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"TwoKeys","Key":["A𞥟",981],"Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"TwoKeys","Key":["",55175],"Value":null,"Delete":false},{"TypeName":"RetainDeletions","Key":"`³Njॊ\u003c?ᾩ‮₦~","Value":{"Value1":3},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_duration","Key":468,"Value":[-4,-805402038367],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_bool","Key":true,"Value":null,"Delete":true},{"TypeName":"test_int32","Key":-24,"Value":[1,null],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"RetainDeletions","Key":"҉߃ ","Value":[-526,""],"Delete":false},{"TypeName":"Simple","Key":": Ⱥ","Value":[59,"Kw=="],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_enum","Key":"bar","Value":{"valNotNull":"foo","valNullable":null},"Delete":false},{"TypeName":"test_int64","Key":2481611475,"Value":[136,6],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_float64","Key":-3.0664227080502325e-103,"Value":[-0.34326171875,-1.9202818317669984e-13],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Singleton","Key":null,"Value":null,"Delete":true},{"TypeName":"Singleton","Key":null,"Value":{"Value":"","Value2":"ClAs"},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ManyValues","Key":"?b\r##/$ͥ","Value":[10,"",5.231528162956238,42],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Simple","Key":"ݙaس\u003cй?{E","Value":{"Value1":-15,"Value2":"o+MQ"},"Delete":false},{"TypeName":"RetainDeletions","Key":"൴~𝔶ٞ蹯a_ ᛮ!؋aض©-?","Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_bytes","Key":"AwcJA//C","Value":{"valNullable":""},"Delete":false},{"TypeName":"test_uint32","Key":1322158439,"Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"TwoKeys","Key":["[Ⰶ\u2029\u0026𒐗🕳c҉\u0026฿a\u0026",-79424],"Value":null,"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_int8","Key":-128,"Value":[7,null],"Delete":false},{"TypeName":"test_int32","Key":-103,"Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"RetainDeletions","Key":"ᾢ","Value":[-2356,"DA=="],"Delete":false},{"TypeName":"ManyValues","Key":"","Value":{"Value1":28,"Value2":"","Value3":-1.6098999622118156e+67,"Value4":14},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ManyValues","Key":"?≚a'","Value":{"Value1":-611,"Value2":"AgqTAG4=","Value3":-2.0360732649240822e+100,"Value4":0},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_float64","Key":0,"Value":[2.5625,null],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_uint64","Key":3,"Value":{"valNotNull":59,"valNullable":null},"Delete":false}]}
Commit: {}
StartBlock: {9 <nil> <nil>}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"TwoKeys","Key":["ः𒑨Dz؅",-2],"Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Simple","Key":"_<>é","Value":{"Value1":9,"Value2":"Ywc="},"Delete":false},{"TypeName":"Singleton","Key":null,"Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"RetainDeletions","Key":"A%aa ¹­ ᾏaĵ¨","Value":[9,"A/faBuYCCecZ3ATQAQcC3gAsizI="],"Delete":false},{"TypeName":"ThreeKeys","Key":[" {a",2790155,310794],"Value":{"Value1":312},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_int32","Key":892,"Value":[-3,null],"Delete":false},{"TypeName":"test_duration","Key":210213542904,"Value":[-722503,113854019],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_duration","Key":468,"Value":[12,null],"Delete":false},{"TypeName":"test_int16","Key":0,"Value":[3089,null],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_decimal","Key":"79","Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ThreeKeys","Key":["",11,107],"Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_bool","Key":false,"Value":[true,true],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Simple","Key":": Ⱥ","Value":{"Value1":-2147483648},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_uint8","Key":14,"Value":null,"Delete":true},{"TypeName":"test_integer","Key":"-1391361","Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"RetainDeletions","Key":"","Value":[-242379,"BngOEOsA"],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_int8","Key":6,"Value":null,"Delete":true},{"TypeName":"test_address","Key":"BkiVAAcAAJ6xA/dutlmcBe8DAA1UZAsB","Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Simple","Key":" 😖₱ ̄؀ा󠁿","Value":[7541152,""],"Delete":false},{"TypeName":"ThreeKeys","Key":["ⅷ_ŕ,A",-467,98],"Value":2,"Delete":false}]}
Commit: {}
StartBlock: {10 <nil> <nil>}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_duration","Key":87208838869,"Value":[-9725,4968373],"Delete":false},{"TypeName":"test_duration","Key":468,"Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"RetainDeletions","Key":"","Value":[-1,"FQIK"],"Delete":false},{"TypeName":"Singleton","Key":null,"Value":{"Value":"œLj$࿇ ᾙ☇؄ೲȺ","Value2":"ADei6AACZTMDDss="},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_bool","Key":false,"Value":[true,null],"Delete":false},{"TypeName":"test_enum","Key":"bar","Value":{"valNotNull":"baz","valNullable":"baz"},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ThreeKeys","Key":["\tA𐞙?\t",-5317218,1],"Value":{"Value1":1},"Delete":false},{"TypeName":"RetainDeletions","Key":"\u0026ٱȺ+҉@","Value":[63,"AAE="],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_float64","Key":-1.7392669057403718e+166,"Value":[-1.0781831287525041e+139,111.37014762980289],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_address","Key":"GJQSAs0BGAILARUXAwIrnf8pBgIrRQOrSQNOEgfvA8ATAAEMVw8s/w==","Value":{"valNotNull":"em2zQwR2O7EAAAYLk23QBADE/wA="},"Delete":false},{"TypeName":"test_address","Key":"PQYReIgDAAG/6fs+AVcXxgEGDXLQ30f0/w==","Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ManyValues","Key":"","Value":[-188,"",-2632691.375,17],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ThreeKeys","Key":["A]$",-125,43],"Value":12654289,"Delete":false},{"TypeName":"RetainDeletions","Key":"?aa₽A\u001b=⇂́ᯫ𖽦ᩣ","Value":{"Value2":"ARM="},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_uint8","Key":0,"Value":{"valNotNull":0,"valNullable":null},"Delete":false},{"TypeName":"test_float32","Key":1.7852577e+32,"Value":[1.6582896,null],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ManyValues","Key":"?b\r##/$ͥ","Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"TwoKeys","Key":["",55175],"Value":null,"Delete":true},{"TypeName":"ThreeKeys","Key":["˖|󺪆𝅲鄖_.;ǀ⃣%;#~",16,512578],"Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"RetainDeletions","Key":"A%aa ¹­ ᾏaĵ¨","Value":null,"Delete":true}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Simple","Key":"","Value":{"Value2":"DAcBeTgAFAED"},"Delete":false},{"TypeName":"RetainDeletions","Key":"ʵ² *ᾍA@҂b⭗@‮൞","Value":{"Value1":547,"Value2":""},"Delete":false}]}
OnObjectUpdate: {"ModuleName":"all_kinds","Updates":[{"TypeName":"test_uint8","Key":4,"Value":[17,null],"Delete":false},{"TypeName":"test_uint8","Key":8,"Value":[2,12],"Delete":false}]}
OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"ManyValues","Key":"҉♰ᾜȺ൜堯Ⱥ៵\"","Value":{"Value1":-10,"Value2":"jg==","Value3":-0.11867497289509932,"Value4":24065},"Delete":false}]}
Commit: {}

View File

@ -0,0 +1,79 @@
App State Diff:
MODULE COUNT ERROR: expected 2, got 1
Module all_kinds
Object Collection test_address
OBJECT COUNT ERROR: expected 16, got 14
Object key=0x0c6ff6530300000704ff285714816e0d0b03010d0102010302ff7400d60103c2110e3700c30105679f0910570048aa48f6b4680128821c98011d00240c2c00ff
valNotNull: expected [43 29 85 255 6 1 176 1 241 222 0 0 14 116 190 1 11 30 2 8 1 144 12 2 12 1 16 15 1 1 27 8 0 255 57 2 6 195 255 60 0 201 255 1 1 13 9 112 1 2 0 121 12 172 254 22 11 1 5 37 54 212 121 0], got [220 0 1 2 13 4 24 1 12 24 58 0 1 224 0 148 104 51 116 27 56 39 14 32 35 84 126 2 18 1 5 6]
valNullable: expected [189 125 119 246 192 0 3 170 24 6 10 123 0 7 34 118 1 212 187 7 1 249 110 146 10 14 0 3 223 134 65 0 15 248 153], got [29 201 1 2 1 63 13 0 191 75 1 2 240 10 209 58 15 137 184 141 148]
Object key=0x18f0037d00012c006d9c72096b011e01f600035108fe0303000100031f1f020f08002203000502010120060f1b0201180203006a00e0: NOT FOUND
Object key=0xa10f005de9c1010692980d213250ef13020697430007000bdc01010305054f4001b7ba39003a01d2ae0e59007eef4e19e9020e006974016d00001c00037b7028: NOT FOUND
Object key=0xff2619001c04b3031aa10d9d167f1f0046d6760216009c: NOT FOUND
Object Collection test_bytes
OBJECT COUNT ERROR: expected 5, got 4
Object key=0x000a4f0f6e9b67f6: NOT FOUND
Object key=0x0e0827
valNotNull: expected [159 2], got [41 6 2 75]
valNullable: expected [0 11 54 47 28], got [6]
Object key=0xff661b1c00
valNullable: expected [15 20 0 1 132 7 37 3 28 2], got [0 8 1 170 90 0 1 201 97 138 53 2]
Object Collection test_decimal
OBJECT COUNT ERROR: expected 5, got 3
Object key=-04360.6e32: NOT FOUND
Object key=-21918e-3: NOT FOUND
Object key=-37.02e01: NOT FOUND
Object key=41090120E-3
valNotNull: expected 04.13921382165470301184220430, got 0255559.705E-4
valNullable: expected -2e5, got nil
Object Collection test_duration
OBJECT COUNT ERROR: expected 1, got 2
Object Collection test_enum
OBJECT COUNT ERROR: expected 1, got 2
Object Collection test_float32
OBJECT COUNT ERROR: expected 3, got 4
Object Collection test_int16
OBJECT COUNT ERROR: expected 9, got 8
Object key=-44: NOT FOUND
Object Collection test_int32
OBJECT COUNT ERROR: expected 2, got 1
Object key=-453: NOT FOUND
Object key=205: NOT FOUND
Object Collection test_int64
OBJECT COUNT ERROR: expected 3, got 5
Object key=-41
valNotNull: expected 244, got -5717
valNullable: expected nil, got 0
Object Collection test_int8
OBJECT COUNT ERROR: expected 4, got 3
Object key=-2: NOT FOUND
Object key=53
valNotNull: expected 27, got 58
valNullable: expected nil, got -10
Object Collection test_integer
OBJECT COUNT ERROR: expected 7, got 6
Object key=-31083911818: NOT FOUND
Object key=191
valNotNull: expected 62, got -47110784
valNullable: expected 9297555, got nil
Object Collection test_string
OBJECT COUNT ERROR: expected 2, got 1
Object key=š℠¼々¢~;-Ⱥ!˃a[ʰᾌ?{ᪧ৵%ᾯ¦〈: NOT FOUND
Object Collection test_time
OBJECT COUNT ERROR: expected 4, got 3
Object key=1970-01-01 01:00:00.000000005 +0100 CET: NOT FOUND
Object key=1970-01-01 01:00:00.001598687 +0100 CET
valNotNull: expected 1970-01-01 01:00:00.007727197 +0100 CET, got 1970-01-01 01:00:00.034531678 +0100 CET
valNullable: expected 1970-01-01 01:00:00.000000484 +0100 CET, got 1970-01-01 01:00:00.000000033 +0100 CET
Object Collection test_uint16
OBJECT COUNT ERROR: expected 4, got 3
Object key=23712: NOT FOUND
Object Collection test_uint32
OBJECT COUNT ERROR: expected 3, got 2
Object key=0: NOT FOUND
Object Collection test_uint64
OBJECT COUNT ERROR: expected 1, got 2
Object Collection test_uint8
OBJECT COUNT ERROR: expected 3, got 2
Object key=1: NOT FOUND
Module test_cases: NOT FOUND
BlockNum: expected 2, got 1

100
schema/testing/diff.go Normal file
View File

@ -0,0 +1,100 @@
package schematesting
import (
"bytes"
"fmt"
"cosmossdk.io/schema"
)
// DiffObjectKeys compares the values as object keys for the provided field and returns a diff if they
// differ or an empty string if they are equal.
func DiffObjectKeys(fields []schema.Field, expected, actual any) string {
n := len(fields)
switch n {
case 0:
return ""
case 1:
return DiffFieldValues(fields[0], expected, actual)
default:
actualValues, ok := actual.([]interface{})
if !ok {
return fmt.Sprintf("ERROR: expected array of values for actual, got %v\n", actual)
}
expectedValues, ok := expected.([]interface{})
if !ok {
return fmt.Sprintf("ERROR: expected array of values for expected, got %v\n", actual)
}
res := ""
for i := 0; i < n; i++ {
res += DiffFieldValues(fields[i], expectedValues[i], actualValues[i])
}
return res
}
}
// DiffObjectValues compares the values as object values for the provided field and returns a diff if they
// differ or an empty string if they are equal. Object values cannot be ValueUpdates for this comparison.
func DiffObjectValues(fields []schema.Field, expected, actual any) string {
if len(fields) == 0 {
return ""
}
_, ok := expected.(schema.ValueUpdates)
_, ok2 := expected.(schema.ValueUpdates)
if ok || ok2 {
return "ValueUpdates is not expected when comparing state"
}
return DiffObjectKeys(fields, expected, actual)
}
// DiffFieldValues compares the values for the provided field and returns a diff if they differ or an empty
// string if they are equal.
func DiffFieldValues(field schema.Field, expected, actual any) string {
if field.Nullable {
if expected == nil {
if actual == nil {
return ""
} else {
return fmt.Sprintf("%s: expected nil, got %v\n", field.Name, actual)
}
} else if actual == nil {
return fmt.Sprintf("%s: expected %v, got nil\n", field.Name, expected)
}
}
eq, err := CompareKindValues(field.Kind, actual, expected)
if err != nil {
return fmt.Sprintf("%s: ERROR: %v\n", field.Name, err)
}
if !eq {
return fmt.Sprintf("%s: expected %v, got %v\n", field.Name, expected, actual)
}
return ""
}
// CompareKindValues compares the expected and actual values for the provided kind and returns true if they are equal,
// false if they are not, and an error if the types are not valid for the kind.
func CompareKindValues(kind schema.Kind, expected, actual any) (bool, error) {
if kind.ValidateValueType(expected) != nil {
return false, fmt.Errorf("unexpected type %T for kind %s", expected, kind)
}
if kind.ValidateValueType(actual) != nil {
return false, fmt.Errorf("unexpected type %T for kind %s", actual, kind)
}
switch kind {
case schema.BytesKind, schema.JSONKind, schema.AddressKind:
if !bytes.Equal(expected.([]byte), actual.([]byte)) {
return false, nil
}
default:
if expected != actual {
return false, nil
}
}
return true, nil
}

3
schema/testing/doc.go Normal file
View File

@ -0,0 +1,3 @@
// Package schematesting includes property-based testing generators for creating random valid data
// for testing schemas and state representing those schemas.
package schematesting

19
schema/testing/enum.go Normal file
View File

@ -0,0 +1,19 @@
package schematesting
import (
"pgregory.net/rapid"
"cosmossdk.io/schema"
)
var enumValuesGen = rapid.SliceOfNDistinct(NameGen, 1, 10, func(x string) string { return x })
// EnumType generates random valid EnumTypes.
var EnumType = rapid.Custom(func(t *rapid.T) schema.EnumType {
enum := schema.EnumType{
Name: NameGen.Draw(t, "name"),
Values: enumValuesGen.Draw(t, "values"),
}
return enum
})

View File

@ -0,0 +1,15 @@
package schematesting
import (
"testing"
"github.com/stretchr/testify/require"
"pgregory.net/rapid"
)
func TestEnumType(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
enumType := EnumType.Draw(t, "enum")
require.NoError(t, enumType.Validate())
})
}

View File

@ -0,0 +1,171 @@
package schematesting
import (
"fmt"
"cosmossdk.io/schema"
)
// ExampleAppSchema is an example app schema that intends to cover all schema cases that indexers should handle
// that can be used in reproducible unit testing and property based testing.
var ExampleAppSchema = map[string]schema.ModuleSchema{
"all_kinds": mkAllKindsModule(),
"test_cases": MustNewModuleSchema([]schema.ObjectType{
{
Name: "Singleton",
KeyFields: []schema.Field{},
ValueFields: []schema.Field{
{
Name: "Value",
Kind: schema.StringKind,
},
{
Name: "Value2",
Kind: schema.BytesKind,
},
},
},
{
Name: "Simple",
KeyFields: []schema.Field{
{
Name: "Key",
Kind: schema.StringKind,
},
},
ValueFields: []schema.Field{
{
Name: "Value1",
Kind: schema.Int32Kind,
},
{
Name: "Value2",
Kind: schema.BytesKind,
},
},
},
{
Name: "TwoKeys",
KeyFields: []schema.Field{
{
Name: "Key1",
Kind: schema.StringKind,
},
{
Name: "Key2",
Kind: schema.Int32Kind,
},
},
},
{
Name: "ThreeKeys",
KeyFields: []schema.Field{
{
Name: "Key1",
Kind: schema.StringKind,
},
{
Name: "Key2",
Kind: schema.Int32Kind,
},
{
Name: "Key3",
Kind: schema.Uint64Kind,
},
},
ValueFields: []schema.Field{
{
Name: "Value1",
Kind: schema.Int32Kind,
},
},
},
{
Name: "ManyValues",
KeyFields: []schema.Field{
{
Name: "Key",
Kind: schema.StringKind,
},
},
ValueFields: []schema.Field{
{
Name: "Value1",
Kind: schema.Int32Kind,
},
{
Name: "Value2",
Kind: schema.BytesKind,
},
{
Name: "Value3",
Kind: schema.Float64Kind,
},
{
Name: "Value4",
Kind: schema.Uint64Kind,
},
},
},
{
Name: "RetainDeletions",
KeyFields: []schema.Field{
{
Name: "Key",
Kind: schema.StringKind,
},
},
ValueFields: []schema.Field{
{
Name: "Value1",
Kind: schema.Int32Kind,
},
{
Name: "Value2",
Kind: schema.BytesKind,
},
},
RetainDeletions: true,
},
}),
}
func mkAllKindsModule() schema.ModuleSchema {
var objTypes []schema.ObjectType
for i := 1; i < int(schema.MAX_VALID_KIND); i++ {
kind := schema.Kind(i)
typ := mkTestObjectType(kind)
objTypes = append(objTypes, typ)
}
return MustNewModuleSchema(objTypes)
}
func mkTestObjectType(kind schema.Kind) schema.ObjectType {
field := schema.Field{
Kind: kind,
}
if kind == schema.EnumKind {
field.EnumType = testEnum
}
keyField := field
keyField.Name = "key"
val1Field := field
val1Field.Name = "valNotNull"
val2Field := field
val2Field.Name = "valNullable"
val2Field.Nullable = true
return schema.ObjectType{
Name: fmt.Sprintf("test_%v", kind),
KeyFields: []schema.Field{keyField},
ValueFields: []schema.Field{val1Field, val2Field},
}
}
var testEnum = schema.EnumType{
Name: "test_enum_type",
Values: []string{"foo", "bar", "baz"},
}

175
schema/testing/field.go Normal file
View File

@ -0,0 +1,175 @@
package schematesting
import (
"fmt"
"time"
"pgregory.net/rapid"
"cosmossdk.io/schema"
)
var (
kindGen = rapid.Map(rapid.IntRange(int(schema.InvalidKind+1), int(schema.MAX_VALID_KIND-1)),
func(i int) schema.Kind {
return schema.Kind(i)
})
boolGen = rapid.Bool()
)
// FieldGen generates random Field's based on the validity criteria of fields.
var FieldGen = rapid.Custom(func(t *rapid.T) schema.Field {
kind := kindGen.Draw(t, "kind")
field := schema.Field{
Name: NameGen.Draw(t, "name"),
Kind: kind,
Nullable: boolGen.Draw(t, "nullable"),
}
switch kind {
case schema.EnumKind:
field.EnumType = EnumType.Draw(t, "enumDefinition")
default:
}
return field
})
// FieldValueGen generates random valid values for the field, aiming to exercise the full range of possible
// values for the field.
func FieldValueGen(field schema.Field) *rapid.Generator[any] {
gen := baseFieldValue(field)
if field.Nullable {
return rapid.OneOf(gen, rapid.Just[any](nil)).AsAny()
}
return gen
}
func baseFieldValue(field schema.Field) *rapid.Generator[any] {
switch field.Kind {
case schema.StringKind:
return rapid.StringOf(rapid.Rune().Filter(func(r rune) bool {
return r != 0 // filter out NULL characters
})).AsAny()
case schema.BytesKind:
return rapid.SliceOf(rapid.Byte()).AsAny()
case schema.Int8Kind:
return rapid.Int8().AsAny()
case schema.Int16Kind:
return rapid.Int16().AsAny()
case schema.Uint8Kind:
return rapid.Uint8().AsAny()
case schema.Uint16Kind:
return rapid.Uint16().AsAny()
case schema.Int32Kind:
return rapid.Int32().AsAny()
case schema.Uint32Kind:
return rapid.Uint32().AsAny()
case schema.Int64Kind:
return rapid.Int64().AsAny()
case schema.Uint64Kind:
return rapid.Uint64().AsAny()
case schema.Float32Kind:
return rapid.Float32().AsAny()
case schema.Float64Kind:
return rapid.Float64().AsAny()
case schema.IntegerStringKind:
return rapid.StringMatching(schema.IntegerFormat).AsAny()
case schema.DecimalStringKind:
return rapid.StringMatching(schema.DecimalFormat).AsAny()
case schema.BoolKind:
return rapid.Bool().AsAny()
case schema.TimeKind:
return rapid.Map(rapid.Int64(), func(i int64) time.Time {
return time.Unix(0, i)
}).AsAny()
case schema.DurationKind:
return rapid.Map(rapid.Int64(), func(i int64) time.Duration {
return time.Duration(i)
}).AsAny()
case schema.AddressKind:
return rapid.SliceOfN(rapid.Byte(), 20, 64).AsAny()
case schema.EnumKind:
return rapid.SampledFrom(field.EnumType.Values).AsAny()
default:
panic(fmt.Errorf("unexpected kind: %v", field.Kind))
}
}
// ObjectKeyGen generates a value that is valid for the provided object key fields.
func ObjectKeyGen(keyFields []schema.Field) *rapid.Generator[any] {
if len(keyFields) == 0 {
return rapid.Just[any](nil)
}
if len(keyFields) == 1 {
return FieldValueGen(keyFields[0])
}
gens := make([]*rapid.Generator[any], len(keyFields))
for i, field := range keyFields {
gens[i] = FieldValueGen(field)
}
return rapid.Custom(func(t *rapid.T) any {
values := make([]any, len(keyFields))
for i, gen := range gens {
values[i] = gen.Draw(t, keyFields[i].Name)
}
return values
})
}
// ObjectValueGen generates a value that is valid for the provided object value fields. The
// forUpdate parameter indicates whether the generator should generate value that
// are valid for insertion (in the case forUpdate is false) or for update (in the case forUpdate is true).
// Values that are for update may skip some fields in a ValueUpdates instance whereas values for insertion
// will always contain all values.
func ObjectValueGen(valueFields []schema.Field, forUpdate bool) *rapid.Generator[any] {
// special case where there are no value fields
// we shouldn't end up here, but just in case
if len(valueFields) == 0 {
return rapid.Just[any](nil)
}
gens := make([]*rapid.Generator[any], len(valueFields))
for i, field := range valueFields {
gens[i] = FieldValueGen(field)
}
return rapid.Custom(func(t *rapid.T) any {
// return ValueUpdates 50% of the time
if boolGen.Draw(t, "valueUpdates") {
updates := map[string]any{}
n := len(valueFields)
for i, gen := range gens {
lastField := i == n-1
haveUpdates := len(updates) > 0
// skip 50% of the time if this is an update
// but check if we have updates by the time we reach the last field
// so we don't have an empty update
if forUpdate &&
(!lastField || haveUpdates) &&
boolGen.Draw(t, fmt.Sprintf("skip_%s", valueFields[i].Name)) {
continue
}
updates[valueFields[i].Name] = gen.Draw(t, valueFields[i].Name)
}
return schema.MapValueUpdates(updates)
} else {
if len(valueFields) == 1 {
return gens[0].Draw(t, valueFields[0].Name)
}
values := make([]any, len(valueFields))
for i, gen := range gens {
values[i] = gen.Draw(t, valueFields[i].Name)
}
return values
}
})
}

View File

@ -0,0 +1,26 @@
package schematesting
import (
"testing"
"github.com/stretchr/testify/require"
"pgregory.net/rapid"
)
func TestField(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
field := FieldGen.Draw(t, "field")
require.NoError(t, field.Validate())
})
}
func TestFieldValue(t *testing.T) {
rapid.Check(t, checkFieldValue)
}
var checkFieldValue = func(t *rapid.T) {
field := FieldGen.Draw(t, "field")
require.NoError(t, field.Validate())
fieldValue := FieldValueGen(field).Draw(t, "fieldValue")
require.NoError(t, field.ValidateValue(fieldValue))
}

20
schema/testing/go.mod Normal file
View File

@ -0,0 +1,20 @@
module cosmossdk.io/schema/testing
require (
cosmossdk.io/schema v0.0.0
github.com/stretchr/testify v1.9.0
github.com/tidwall/btree v1.7.0
gotest.tools/v3 v3.5.1
pgregory.net/rapid v1.1.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace cosmossdk.io/schema => ./..
go 1.22

18
schema/testing/go.sum Normal file
View File

@ -0,0 +1,18 @@
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/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/btree v1.7.0 h1:L1fkJH/AuEh5zBnnBbmTwQ5Lt+bRJ5A8EWecslvo9iI=
github.com/tidwall/btree v1.7.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EUQ2cKY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
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=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
pgregory.net/rapid v1.1.0 h1:CMa0sjHSru3puNx+J0MIAuiiEV4N0qj8/cMWGBBCsjw=
pgregory.net/rapid v1.1.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=

View File

@ -0,0 +1,51 @@
package schematesting
import (
"fmt"
"pgregory.net/rapid"
"cosmossdk.io/schema"
)
// ModuleSchemaGen generates random ModuleSchema's based on the validity criteria of module schemas.
var ModuleSchemaGen = rapid.Custom(func(t *rapid.T) schema.ModuleSchema {
objectTypes := objectTypesGen.Draw(t, "objectTypes")
modSchema, err := schema.NewModuleSchema(objectTypes)
if err != nil {
t.Fatal(err)
}
return modSchema
})
var objectTypesGen = rapid.Custom(func(t *rapid.T) []schema.ObjectType {
var objectTypes []schema.ObjectType
numObjectTypes := rapid.IntRange(1, 10).Draw(t, "numObjectTypes")
for i := 0; i < numObjectTypes; i++ {
objectType := ObjectTypeGen.Draw(t, fmt.Sprintf("objectType[%d]", i))
objectTypes = append(objectTypes, objectType)
}
return objectTypes
}).Filter(func(objectTypes []schema.ObjectType) bool {
typeNames := map[string]bool{}
for _, objectType := range objectTypes {
if hasDuplicateNames(typeNames, objectType.KeyFields) || hasDuplicateNames(typeNames, objectType.ValueFields) {
return false
}
if typeNames[objectType.Name] {
return false
}
typeNames[objectType.Name] = true
}
return true
})
// MustNewModuleSchema calls NewModuleSchema and panics if there's an error. This should generally be used
// only in tests or initialization code.
func MustNewModuleSchema(objectTypes []schema.ObjectType) schema.ModuleSchema {
schema, err := schema.NewModuleSchema(objectTypes)
if err != nil {
panic(err)
}
return schema
}

View File

@ -0,0 +1,15 @@
package schematesting
import (
"testing"
"github.com/stretchr/testify/require"
"pgregory.net/rapid"
)
func TestModuleSchema(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
schema := ModuleSchemaGen.Draw(t, "schema")
require.NoError(t, schema.Validate())
})
}

10
schema/testing/name.go Normal file
View File

@ -0,0 +1,10 @@
package schematesting
import (
"pgregory.net/rapid"
"cosmossdk.io/schema"
)
// NameGen validates valid names that match the NameFormat regex.
var NameGen = rapid.StringMatching(schema.NameFormat)

View File

@ -0,0 +1,17 @@
package schematesting
import (
"testing"
"github.com/stretchr/testify/require"
"pgregory.net/rapid"
"cosmossdk.io/schema"
)
func TestName(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
name := NameGen.Draw(t, "name")
require.True(t, schema.ValidateName(name))
})
}

127
schema/testing/object.go Normal file
View File

@ -0,0 +1,127 @@
package schematesting
import (
"github.com/tidwall/btree"
"pgregory.net/rapid"
"cosmossdk.io/schema"
)
var fieldsGen = rapid.SliceOfNDistinct(FieldGen, 1, 12, func(f schema.Field) string {
return f.Name
})
// ObjectTypeGen generates random ObjectType's based on the validity criteria of object types.
var ObjectTypeGen = rapid.Custom(func(t *rapid.T) schema.ObjectType {
typ := schema.ObjectType{
Name: NameGen.Draw(t, "name"),
}
fields := fieldsGen.Draw(t, "fields")
numKeyFields := rapid.IntRange(0, len(fields)).Draw(t, "numKeyFields")
typ.KeyFields = fields[:numKeyFields]
for i := range typ.KeyFields {
// key fields can't be nullable
typ.KeyFields[i].Nullable = false
}
typ.ValueFields = fields[numKeyFields:]
typ.RetainDeletions = boolGen.Draw(t, "retainDeletions")
return typ
}).Filter(func(typ schema.ObjectType) bool {
// filter out duplicate enum names
typeNames := map[string]bool{typ.Name: true}
if hasDuplicateNames(typeNames, typ.KeyFields) {
return false
}
if hasDuplicateNames(typeNames, typ.ValueFields) {
return false
}
return true
})
// hasDuplicateNames checks if there is type name in the fields
func hasDuplicateNames(typeNames map[string]bool, fields []schema.Field) bool {
for _, field := range fields {
if field.Kind != schema.EnumKind {
continue
}
if _, ok := typeNames[field.EnumType.Name]; ok {
return true
}
typeNames[field.EnumType.Name] = true
}
return false
}
// ObjectInsertGen generates object updates that are valid for insertion.
func ObjectInsertGen(objectType schema.ObjectType) *rapid.Generator[schema.ObjectUpdate] {
return ObjectUpdateGen(objectType, nil)
}
// ObjectUpdateGen generates object updates that are valid for updates using the provided state map as a source
// of valid existing keys.
func ObjectUpdateGen(objectType schema.ObjectType, state *btree.Map[string, schema.ObjectUpdate]) *rapid.Generator[schema.ObjectUpdate] {
keyGen := ObjectKeyGen(objectType.KeyFields)
if len(objectType.ValueFields) == 0 {
// special case where there are no value fields,
// so we just insert or delete, no updates
return rapid.Custom(func(t *rapid.T) schema.ObjectUpdate {
update := schema.ObjectUpdate{
TypeName: objectType.Name,
}
// 50% of the time delete existing key (when there are keys)
n := 0
if state != nil {
n = state.Len()
}
if n > 0 && boolGen.Draw(t, "delete") {
i := rapid.IntRange(0, n-1).Draw(t, "index")
update.Key = state.Values()[i].Key
update.Delete = true
} else {
update.Key = keyGen.Draw(t, "key")
}
return update
})
} else {
insertValueGen := ObjectValueGen(objectType.ValueFields, false)
updateValueGen := ObjectValueGen(objectType.ValueFields, true)
return rapid.Custom(func(t *rapid.T) schema.ObjectUpdate {
update := schema.ObjectUpdate{
TypeName: objectType.Name,
}
// 50% of the time use existing key (when there are keys)
n := 0
if state != nil {
n = state.Len()
}
if n > 0 && boolGen.Draw(t, "existingKey") {
i := rapid.IntRange(0, n-1).Draw(t, "index")
update.Key = state.Values()[i].Key
// delete 50% of the time
if boolGen.Draw(t, "delete") {
update.Delete = true
} else {
update.Value = updateValueGen.Draw(t, "value")
}
} else {
update.Key = keyGen.Draw(t, "key")
update.Value = insertValueGen.Draw(t, "value")
}
return update
})
}
}

View File

@ -0,0 +1,24 @@
package schematesting
import (
"testing"
"github.com/stretchr/testify/require"
"pgregory.net/rapid"
)
func TestObject(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
objectType := ObjectTypeGen.Draw(t, "object")
require.NoError(t, objectType.Validate())
})
}
func TestObjectUpdate(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
objectType := ObjectTypeGen.Draw(t, "object")
require.NoError(t, objectType.Validate())
update := ObjectInsertGen(objectType).Draw(t, "update")
require.NoError(t, objectType.ValidateObjectUpdate(update))
})
}

View File

@ -0,0 +1,16 @@
sonar.projectKey=cosmos-sdk-schema-testing
sonar.organization=cosmos
sonar.projectName=Cosmos SDK - Schema Testing
sonar.project.monorepo.enabled=true
sonar.sources=.
sonar.exclusions=**/*_test.go,**/*.pb.go,**/*.pulsar.go,**/*.pb.gw.go
sonar.coverage.exclusions=**/*_test.go,**/testutil/**,**/*.pb.go,**/*.pb.gw.go,**/*.pulsar.go,test_helpers.go,docs/**
sonar.tests=.
sonar.test.inclusions=**/*_test.go
sonar.go.coverage.reportPaths=coverage.out
sonar.sourceEncoding=UTF-8
sonar.scm.provider=git
sonar.scm.forceReloadAll=true

View File

@ -0,0 +1,108 @@
package statesim
import (
"fmt"
"github.com/stretchr/testify/require"
"github.com/tidwall/btree"
"pgregory.net/rapid"
"cosmossdk.io/schema"
"cosmossdk.io/schema/appdata"
)
// App is a collection of simulated module states corresponding to an app's schema for testing purposes.
type App struct {
options Options
moduleStates *btree.Map[string, *Module]
updateGen *rapid.Generator[appdata.ObjectUpdateData]
}
// NewApp creates a new simulation App for the given app schema. The app schema can be nil
// if the user desires initializing modules with InitializeModule instead.
func NewApp(appSchema map[string]schema.ModuleSchema, options Options) *App {
app := &App{
options: options,
moduleStates: &btree.Map[string, *Module]{},
}
for moduleName, moduleSchema := range appSchema {
moduleState := NewModule(moduleSchema, options)
app.moduleStates.Set(moduleName, moduleState)
}
moduleNameSelector := rapid.Custom(func(t *rapid.T) string {
return rapid.SampledFrom(app.moduleStates.Keys()).Draw(t, "moduleName")
})
numUpdatesGen := rapid.IntRange(1, 2)
app.updateGen = rapid.Custom(func(t *rapid.T) appdata.ObjectUpdateData {
moduleName := moduleNameSelector.Draw(t, "moduleName")
moduleState, ok := app.moduleStates.Get(moduleName)
require.True(t, ok)
numUpdates := numUpdatesGen.Draw(t, "numUpdates")
updates := make([]schema.ObjectUpdate, numUpdates)
for i := 0; i < numUpdates; i++ {
update := moduleState.UpdateGen().Draw(t, fmt.Sprintf("update[%d]", i))
updates[i] = update
}
return appdata.ObjectUpdateData{
ModuleName: moduleName,
Updates: updates,
}
})
return app
}
// InitializeModule initializes the module with the provided schema. This returns an error if the
// module is already initialized in state.
func (a *App) InitializeModule(data appdata.ModuleInitializationData) error {
if _, ok := a.moduleStates.Get(data.ModuleName); ok {
return fmt.Errorf("module %s already initialized", data.ModuleName)
}
a.moduleStates.Set(data.ModuleName, NewModule(data.Schema, a.options))
return nil
}
// ApplyUpdate applies the given object update to the module.
func (a *App) ApplyUpdate(data appdata.ObjectUpdateData) error {
moduleState, ok := a.moduleStates.Get(data.ModuleName)
if !ok {
// we don't have this module so skip the update
return nil
}
for _, update := range data.Updates {
err := moduleState.ApplyUpdate(update)
if err != nil {
return err
}
}
return nil
}
// UpdateGen is a generator for object update data against the app. It is stateful and includes a certain number of
// updates and deletions to existing objects.
func (a *App) UpdateGen() *rapid.Generator[appdata.ObjectUpdateData] {
return a.updateGen
}
// GetModule returns the module state for the given module name.
func (a *App) GetModule(moduleName string) (ModuleState, bool) {
return a.moduleStates.Get(moduleName)
}
// Modules iterates over all the module state instances in the app.
func (a *App) Modules(f func(moduleName string, modState ModuleState) bool) {
a.moduleStates.Scan(func(key string, value *Module) bool {
return f(key, value)
})
}
// NumModules returns the number of modules in the app.
func (a *App) NumModules() int {
return a.moduleStates.Len()
}

View File

@ -0,0 +1,43 @@
package statesim
import "fmt"
// AppState defines an interface for things that represent application state in schema format.
type AppState interface {
// GetModule returns the module state for the given module name.
GetModule(moduleName string) (ModuleState, bool)
// Modules iterates over all the module state instances in the app.
Modules(f func(moduleName string, modState ModuleState) bool)
// NumModules returns the number of modules in the app.
NumModules() int
}
// DiffAppStates compares the app state of two objects that implement AppState and returns a string with a diff if they
// are different or the empty string if they are the same.
func DiffAppStates(expected, actual AppState) string {
res := ""
if expected.NumModules() != actual.NumModules() {
res += fmt.Sprintf("MODULE COUNT ERROR: expected %d, got %d\n", expected.NumModules(), actual.NumModules())
}
expected.Modules(func(moduleName string, expectedMod ModuleState) bool {
actualMod, found := actual.GetModule(moduleName)
if !found {
res += fmt.Sprintf("Module %s: NOT FOUND\n", moduleName)
return true
}
diff := DiffModuleStates(expectedMod, actualMod)
if diff != "" {
res += "Module " + moduleName + "\n"
res += indentAllLines(diff)
}
return true
})
return res
}

View File

@ -0,0 +1,3 @@
// Package statesim contains utilities for simulating state based on ObjectType's and ModuleSchema's for testing
// the conformance of state management libraries and indexers to schema rules.
package statesim

View File

@ -0,0 +1,84 @@
package statesim
import (
"fmt"
"github.com/stretchr/testify/require"
"github.com/tidwall/btree"
"pgregory.net/rapid"
"cosmossdk.io/schema"
)
// Module is a collection of object collections corresponding to a module's schema for testing purposes.
type Module struct {
moduleSchema schema.ModuleSchema
objectCollections *btree.Map[string, *ObjectCollection]
updateGen *rapid.Generator[schema.ObjectUpdate]
}
// NewModule creates a new Module for the given module schema.
func NewModule(moduleSchema schema.ModuleSchema, options Options) *Module {
objectCollections := &btree.Map[string, *ObjectCollection]{}
var objectTypeNames []string
moduleSchema.ObjectTypes(func(objectType schema.ObjectType) bool {
objectCollection := NewObjectCollection(objectType, options)
objectCollections.Set(objectType.Name, objectCollection)
objectTypeNames = append(objectTypeNames, objectType.Name)
return true
})
objectTypeSelector := rapid.SampledFrom(objectTypeNames)
updateGen := rapid.Custom(func(t *rapid.T) schema.ObjectUpdate {
objectType := objectTypeSelector.Draw(t, "objectType")
objectColl, ok := objectCollections.Get(objectType)
require.True(t, ok)
return objectColl.UpdateGen().Draw(t, "update")
})
return &Module{
moduleSchema: moduleSchema,
updateGen: updateGen,
objectCollections: objectCollections,
}
}
// ApplyUpdate applies the given object update to the module.
func (o *Module) ApplyUpdate(update schema.ObjectUpdate) error {
objState, ok := o.objectCollections.Get(update.TypeName)
if !ok {
return fmt.Errorf("object type %s not found in module", update.TypeName)
}
return objState.ApplyUpdate(update)
}
// UpdateGen returns a generator for object updates. The generator is stateful and returns
// a certain number of updates and deletes of existing objects in the module.
func (o *Module) UpdateGen() *rapid.Generator[schema.ObjectUpdate] {
return o.updateGen
}
// ModuleSchema returns the module schema for the module.
func (o *Module) ModuleSchema() schema.ModuleSchema {
return o.moduleSchema
}
// GetObjectCollection returns the object collection for the given object type.
func (o *Module) GetObjectCollection(objectType string) (ObjectCollectionState, bool) {
return o.objectCollections.Get(objectType)
}
// ObjectCollections iterates over all object collections in the module.
func (o *Module) ObjectCollections(f func(value ObjectCollectionState) bool) {
o.objectCollections.Scan(func(key string, value *ObjectCollection) bool {
return f(value)
})
}
// NumObjectCollections returns the number of object collections in the module.
func (o *Module) NumObjectCollections() int {
return o.objectCollections.Len()
}

View File

@ -0,0 +1,51 @@
package statesim
import (
"fmt"
"cosmossdk.io/schema"
)
// ModuleState defines an interface for things that represent module state in schema format.
type ModuleState interface {
// ModuleSchema returns the schema for the module.
ModuleSchema() schema.ModuleSchema
// GetObjectCollection returns the object collection state for the given object type.
GetObjectCollection(objectType string) (ObjectCollectionState, bool)
// ObjectCollections iterates over all the object collection states in the module.
ObjectCollections(f func(value ObjectCollectionState) bool)
// NumObjectCollections returns the number of object collections in the module.
NumObjectCollections() int
}
// DiffModuleStates compares the module state of two objects that implement ModuleState and returns a string with a diff if they
// are different or the empty string if they are the same.
func DiffModuleStates(expected, actual ModuleState) string {
res := ""
if expected.NumObjectCollections() != actual.NumObjectCollections() {
res += fmt.Sprintf("OBJECT COLLECTION COUNT ERROR: expected %d, got %d\n", expected.NumObjectCollections(), actual.NumObjectCollections())
}
expected.ObjectCollections(func(expectedColl ObjectCollectionState) bool {
objTypeName := expectedColl.ObjectType().Name
actualColl, found := actual.GetObjectCollection(objTypeName)
if !found {
res += fmt.Sprintf("Object Collection %s: NOT FOUND\n", objTypeName)
return true
}
diff := DiffObjectCollections(expectedColl, actualColl)
if diff != "" {
res += "Object Collection " + objTypeName + "\n"
res += indentAllLines(diff)
}
return true
})
return res
}

View File

@ -0,0 +1,167 @@
package statesim
import (
"fmt"
"github.com/tidwall/btree"
"pgregory.net/rapid"
"cosmossdk.io/schema"
schematesting "cosmossdk.io/schema/testing"
)
// ObjectCollection is a collection of objects of a specific type for testing purposes.
type ObjectCollection struct {
options Options
objectType schema.ObjectType
objects *btree.Map[string, schema.ObjectUpdate]
updateGen *rapid.Generator[schema.ObjectUpdate]
valueFieldIndices map[string]int
}
// NewObjectCollection creates a new ObjectCollection for the given object type.
func NewObjectCollection(objectType schema.ObjectType, options Options) *ObjectCollection {
objects := &btree.Map[string, schema.ObjectUpdate]{}
updateGen := schematesting.ObjectUpdateGen(objectType, objects)
valueFieldIndices := make(map[string]int, len(objectType.ValueFields))
for i, field := range objectType.ValueFields {
valueFieldIndices[field.Name] = i
}
return &ObjectCollection{
options: options,
objectType: objectType,
objects: objects,
updateGen: updateGen,
valueFieldIndices: valueFieldIndices,
}
}
// ApplyUpdate applies the given object update to the collection.
func (o *ObjectCollection) ApplyUpdate(update schema.ObjectUpdate) error {
if update.TypeName != o.objectType.Name {
return fmt.Errorf("update type name %q does not match object type name %q", update.TypeName, o.objectType.Name)
}
err := o.objectType.ValidateObjectUpdate(update)
if err != nil {
return err
}
keyStr := fmtObjectKey(o.objectType, update.Key)
cur, exists := o.objects.Get(keyStr)
if update.Delete {
if o.objectType.RetainDeletions && o.options.CanRetainDeletions {
if !exists {
return fmt.Errorf("object not found for deletion: %v", update.Key)
}
cur.Delete = true
o.objects.Set(keyStr, cur)
} else {
o.objects.Delete(keyStr)
}
} else {
// convert value updates to array
if valueUpdates, ok := update.Value.(schema.ValueUpdates); ok {
var values []interface{}
n := len(o.objectType.ValueFields)
if exists {
if n == 1 {
values = []interface{}{cur.Value}
} else {
values = cur.Value.([]interface{})
}
} else {
values = make([]interface{}, len(o.objectType.ValueFields))
}
err = valueUpdates.Iterate(func(fieldName string, value interface{}) bool {
fieldIndex, ok := o.valueFieldIndices[fieldName]
if !ok {
panic(fmt.Sprintf("field %q not found in object type %q", fieldName, o.objectType.Name))
}
values[fieldIndex] = value
return true
})
if err != nil {
return err
}
if n == 1 {
update.Value = values[0]
} else {
update.Value = values
}
}
o.objects.Set(keyStr, update)
}
return nil
}
// UpdateGen returns a generator for random object updates against the collection. This generator
// is stateful and returns a certain number of updates and deletes to existing objects.
func (o *ObjectCollection) UpdateGen() *rapid.Generator[schema.ObjectUpdate] {
return o.updateGen
}
// AllState iterates over the state of the collection by calling the given function with each item in
// state represented as an object update.
func (o *ObjectCollection) AllState(f func(schema.ObjectUpdate) bool) {
o.objects.Scan(func(_ string, v schema.ObjectUpdate) bool {
return f(v)
})
}
// GetObject returns the object with the given key from the collection represented as an ObjectUpdate
// itself. Deletions that are retained are returned as ObjectUpdate's with delete set to true.
func (o *ObjectCollection) GetObject(key any) (update schema.ObjectUpdate, found bool) {
return o.objects.Get(fmtObjectKey(o.objectType, key))
}
// ObjectType returns the object type of the collection.
func (o *ObjectCollection) ObjectType() schema.ObjectType {
return o.objectType
}
// Len returns the number of objects in the collection.
func (o *ObjectCollection) Len() int {
return o.objects.Len()
}
func fmtObjectKey(objectType schema.ObjectType, key any) string {
keyFields := objectType.KeyFields
n := len(keyFields)
switch n {
case 0:
return ""
case 1:
valStr := fmtValue(keyFields[0].Kind, key)
return fmt.Sprintf("%s=%v", keyFields[0].Name, valStr)
default:
ks := key.([]interface{})
res := ""
for i := 0; i < n; i++ {
if i != 0 {
res += ", "
}
valStr := fmtValue(keyFields[i].Kind, ks[i])
res += fmt.Sprintf("%s=%v", keyFields[i].Name, valStr)
}
return res
}
}
func fmtValue(kind schema.Kind, value any) string {
switch kind {
case schema.BytesKind, schema.AddressKind:
return fmt.Sprintf("0x%x", value)
case schema.JSONKind:
return fmt.Sprintf("%s", value)
default:
return fmt.Sprintf("%v", value)
}
}

View File

@ -0,0 +1,75 @@
package statesim
import (
"fmt"
"strings"
"cosmossdk.io/schema"
schematesting "cosmossdk.io/schema/testing"
)
// ObjectCollectionState is the interface for the state of an object collection
// represented by ObjectUpdate's for an ObjectType. ObjectUpdates must not include
// ValueUpdates in the Value field. When ValueUpdates are applied they must be
// converted to individual value or array format depending on the number of fields in
// the value. For collections which retain deletions, ObjectUpdate's with the Delete
// field set to true should be returned with the latest Value still intact.
type ObjectCollectionState interface {
// ObjectType returns the object type for the collection.
ObjectType() schema.ObjectType
// GetObject returns the object update for the given key if it exists.
GetObject(key any) (update schema.ObjectUpdate, found bool)
// AllState iterates over the state of the collection by calling the given function with each item in
// state represented as an object update.
AllState(f func(schema.ObjectUpdate) bool)
// Len returns the number of objects in the collection.
Len() int
}
// DiffObjectCollections compares the object collection state of two objects that implement ObjectCollectionState and returns a string with a diff if they
// are different or the empty string if they are the same.
func DiffObjectCollections(expected, actual ObjectCollectionState) string {
res := ""
if expected.Len() != actual.Len() {
res += fmt.Sprintf("OBJECT COUNT ERROR: expected %d, got %d\n", expected.Len(), actual.Len())
}
expected.AllState(func(expectedUpdate schema.ObjectUpdate) bool {
actualUpdate, found := actual.GetObject(expectedUpdate.Key)
if !found {
res += fmt.Sprintf("Object %s: NOT FOUND\n", fmtObjectKey(expected.ObjectType(), expectedUpdate.Key))
return true
}
if expectedUpdate.Delete != actualUpdate.Delete {
res += fmt.Sprintf("Object %s: Deleted mismatch, expected %v, got %v\n", fmtObjectKey(expected.ObjectType(), expectedUpdate.Key), expectedUpdate.Delete, actualUpdate.Delete)
}
if expectedUpdate.Delete {
return true
}
valueDiff := schematesting.DiffObjectValues(expected.ObjectType().ValueFields, expectedUpdate.Value, actualUpdate.Value)
if valueDiff != "" {
res += "Object "
res += fmtObjectKey(expected.ObjectType(), expectedUpdate.Key)
res += "\n"
res += indentAllLines(valueDiff)
}
return true
})
return res
}
func indentAllLines(s string) string {
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = " " + line
}
return strings.Join(lines, "\n")
}

View File

@ -0,0 +1,9 @@
package statesim
// Options are options for object, module and app state simulators.
type Options struct {
// CanRetainDeletions indicates that the simulator can retain deletions when that flag is enabled
// on object types. This should be set to match the indexers ability to retain deletions or not
// for accurately testing the expected state in the simulator with the indexer's actual state.
CanRetainDeletions bool
}