From e7844e640cc9804b5cff0bb8a80d3c2d8299fef0 Mon Sep 17 00:00:00 2001 From: Aaron Craelius Date: Wed, 31 Jul 2024 08:58:30 +0200 Subject: [PATCH] feat(schema): testing utilities (#20705) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 30 +++ schema/appdata/forwarder.go | 15 ++ schema/appdata/forwarder_test.go | 39 ++++ schema/kind.go | 2 +- schema/kind_test.go | 2 +- schema/testing/CHANGELOG.md | 37 ++++ schema/testing/README.md | 50 +++++ schema/testing/app.go | 21 +++ schema/testing/app_test.go | 18 ++ schema/testing/appdatasim/app_data.go | 161 ++++++++++++++++ schema/testing/appdatasim/app_data_test.go | 108 +++++++++++ schema/testing/appdatasim/diff.go | 38 ++++ schema/testing/appdatasim/diff_test.go | 39 ++++ schema/testing/appdatasim/doc.go | 2 + .../testdata/app_sim_example_schema.txt | 161 ++++++++++++++++ .../appdatasim/testdata/diff_example.txt | 79 ++++++++ schema/testing/diff.go | 100 ++++++++++ schema/testing/doc.go | 3 + schema/testing/enum.go | 19 ++ schema/testing/enum_test.go | 15 ++ schema/testing/example_schema.go | 171 +++++++++++++++++ schema/testing/field.go | 175 ++++++++++++++++++ schema/testing/field_test.go | 26 +++ schema/testing/go.mod | 20 ++ schema/testing/go.sum | 18 ++ schema/testing/module_schema.go | 51 +++++ schema/testing/module_schema_test.go | 15 ++ schema/testing/name.go | 10 + schema/testing/name_test.go | 17 ++ schema/testing/object.go | 127 +++++++++++++ schema/testing/object_test.go | 24 +++ schema/testing/sonar-project.properties | 16 ++ schema/testing/statesim/app.go | 108 +++++++++++ schema/testing/statesim/app_diff.go | 43 +++++ schema/testing/statesim/doc.go | 3 + schema/testing/statesim/module.go | 84 +++++++++ schema/testing/statesim/module_diff.go | 51 +++++ schema/testing/statesim/object_coll.go | 167 +++++++++++++++++ schema/testing/statesim/object_coll_diff.go | 75 ++++++++ schema/testing/statesim/options.go | 9 + 40 files changed, 2147 insertions(+), 2 deletions(-) create mode 100644 schema/appdata/forwarder.go create mode 100644 schema/appdata/forwarder_test.go create mode 100644 schema/testing/CHANGELOG.md create mode 100644 schema/testing/README.md create mode 100644 schema/testing/app.go create mode 100644 schema/testing/app_test.go create mode 100644 schema/testing/appdatasim/app_data.go create mode 100644 schema/testing/appdatasim/app_data_test.go create mode 100644 schema/testing/appdatasim/diff.go create mode 100644 schema/testing/appdatasim/diff_test.go create mode 100644 schema/testing/appdatasim/doc.go create mode 100644 schema/testing/appdatasim/testdata/app_sim_example_schema.txt create mode 100644 schema/testing/appdatasim/testdata/diff_example.txt create mode 100644 schema/testing/diff.go create mode 100644 schema/testing/doc.go create mode 100644 schema/testing/enum.go create mode 100644 schema/testing/enum_test.go create mode 100644 schema/testing/example_schema.go create mode 100644 schema/testing/field.go create mode 100644 schema/testing/field_test.go create mode 100644 schema/testing/go.mod create mode 100644 schema/testing/go.sum create mode 100644 schema/testing/module_schema.go create mode 100644 schema/testing/module_schema_test.go create mode 100644 schema/testing/name.go create mode 100644 schema/testing/name_test.go create mode 100644 schema/testing/object.go create mode 100644 schema/testing/object_test.go create mode 100644 schema/testing/sonar-project.properties create mode 100644 schema/testing/statesim/app.go create mode 100644 schema/testing/statesim/app_diff.go create mode 100644 schema/testing/statesim/doc.go create mode 100644 schema/testing/statesim/module.go create mode 100644 schema/testing/statesim/module_diff.go create mode 100644 schema/testing/statesim/object_coll.go create mode 100644 schema/testing/statesim/object_coll_diff.go create mode 100644 schema/testing/statesim/options.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9deffe53d6..92c9914c14 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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: diff --git a/schema/appdata/forwarder.go b/schema/appdata/forwarder.go new file mode 100644 index 0000000000..2fb4113c85 --- /dev/null +++ b/schema/appdata/forwarder.go @@ -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) }, + } +} diff --git a/schema/appdata/forwarder_test.go b/schema/appdata/forwarder_test.go new file mode 100644 index 0000000000..aaf792f717 --- /dev/null +++ b/schema/appdata/forwarder_test.go @@ -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) + } + } +} diff --git a/schema/kind.go b/schema/kind.go index 1cdffc7b71..44d18e100f 100644 --- a/schema/kind.go +++ b/schema/kind.go @@ -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: diff --git a/schema/kind_test.go b/schema/kind_test.go index 113762ec2e..a337ba2783 100644 --- a/schema/kind_test.go +++ b/schema/kind_test.go @@ -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 { diff --git a/schema/testing/CHANGELOG.md b/schema/testing/CHANGELOG.md new file mode 100644 index 0000000000..0c3c9d0385 --- /dev/null +++ b/schema/testing/CHANGELOG.md @@ -0,0 +1,37 @@ + + +# Changelog + +## [Unreleased] diff --git a/schema/testing/README.md b/schema/testing/README.md new file mode 100644 index 0000000000..4a9ac31988 --- /dev/null +++ b/schema/testing/README.md @@ -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. \ No newline at end of file diff --git a/schema/testing/app.go b/schema/testing/app.go new file mode 100644 index 0000000000..feda6d0363 --- /dev/null +++ b/schema/testing/app.go @@ -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 +}) diff --git a/schema/testing/app_test.go b/schema/testing/app_test.go new file mode 100644 index 0000000000..ac2600e238 --- /dev/null +++ b/schema/testing/app_test.go @@ -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()) + } + }) +} diff --git a/schema/testing/appdatasim/app_data.go b/schema/testing/appdatasim/app_data.go new file mode 100644 index 0000000000..db82d285de --- /dev/null +++ b/schema/testing/appdatasim/app_data.go @@ -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 +} diff --git a/schema/testing/appdatasim/app_data_test.go b/schema/testing/appdatasim/app_data_test.go new file mode 100644 index 0000000000..29701c4411 --- /dev/null +++ b/schema/testing/appdatasim/app_data_test.go @@ -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 + }, + } +} diff --git a/schema/testing/appdatasim/diff.go b/schema/testing/appdatasim/diff.go new file mode 100644 index 0000000000..5896331d54 --- /dev/null +++ b/schema/testing/appdatasim/diff.go @@ -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 +} diff --git a/schema/testing/appdatasim/diff_test.go b/schema/testing/appdatasim/diff_test.go new file mode 100644 index 0000000000..cb9ecee9eb --- /dev/null +++ b/schema/testing/appdatasim/diff_test.go @@ -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") +} diff --git a/schema/testing/appdatasim/doc.go b/schema/testing/appdatasim/doc.go new file mode 100644 index 0000000000..7f3493e252 --- /dev/null +++ b/schema/testing/appdatasim/doc.go @@ -0,0 +1,2 @@ +// Package appdatasim contains utilities for simulating valid streams of app data for testing indexer implementations. +package appdatasim diff --git a/schema/testing/appdatasim/testdata/app_sim_example_schema.txt b/schema/testing/appdatasim/testdata/app_sim_example_schema.txt new file mode 100644 index 0000000000..32aa613514 --- /dev/null +++ b/schema/testing/appdatasim/testdata/app_sim_example_schema.txt @@ -0,0 +1,161 @@ +InitializeModuleData: {"ModuleName":"all_kinds","Schema":{}} +InitializeModuleData: {"ModuleName":"test_cases","Schema":{}} +StartBlock: {1 } +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 } +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 } +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 } +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 } +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 } +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!𑿞a‮a`","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 } +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 } +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 } +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 } +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: {} diff --git a/schema/testing/appdatasim/testdata/diff_example.txt b/schema/testing/appdatasim/testdata/diff_example.txt new file mode 100644 index 0000000000..636e388937 --- /dev/null +++ b/schema/testing/appdatasim/testdata/diff_example.txt @@ -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 diff --git a/schema/testing/diff.go b/schema/testing/diff.go new file mode 100644 index 0000000000..413a9384ee --- /dev/null +++ b/schema/testing/diff.go @@ -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 +} diff --git a/schema/testing/doc.go b/schema/testing/doc.go new file mode 100644 index 0000000000..05f3dcb1b4 --- /dev/null +++ b/schema/testing/doc.go @@ -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 diff --git a/schema/testing/enum.go b/schema/testing/enum.go new file mode 100644 index 0000000000..40d3f6b35f --- /dev/null +++ b/schema/testing/enum.go @@ -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 +}) diff --git a/schema/testing/enum_test.go b/schema/testing/enum_test.go new file mode 100644 index 0000000000..4b84559cc5 --- /dev/null +++ b/schema/testing/enum_test.go @@ -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()) + }) +} diff --git a/schema/testing/example_schema.go b/schema/testing/example_schema.go new file mode 100644 index 0000000000..60ae8bba54 --- /dev/null +++ b/schema/testing/example_schema.go @@ -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"}, +} diff --git a/schema/testing/field.go b/schema/testing/field.go new file mode 100644 index 0000000000..670f9356a6 --- /dev/null +++ b/schema/testing/field.go @@ -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 + } + }) +} diff --git a/schema/testing/field_test.go b/schema/testing/field_test.go new file mode 100644 index 0000000000..7e264fcebc --- /dev/null +++ b/schema/testing/field_test.go @@ -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)) +} diff --git a/schema/testing/go.mod b/schema/testing/go.mod new file mode 100644 index 0000000000..e1cfc343bc --- /dev/null +++ b/schema/testing/go.mod @@ -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 diff --git a/schema/testing/go.sum b/schema/testing/go.sum new file mode 100644 index 0000000000..393b537c2d --- /dev/null +++ b/schema/testing/go.sum @@ -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= diff --git a/schema/testing/module_schema.go b/schema/testing/module_schema.go new file mode 100644 index 0000000000..9f62bfd2d2 --- /dev/null +++ b/schema/testing/module_schema.go @@ -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 +} diff --git a/schema/testing/module_schema_test.go b/schema/testing/module_schema_test.go new file mode 100644 index 0000000000..91196d59aa --- /dev/null +++ b/schema/testing/module_schema_test.go @@ -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()) + }) +} diff --git a/schema/testing/name.go b/schema/testing/name.go new file mode 100644 index 0000000000..144347e5db --- /dev/null +++ b/schema/testing/name.go @@ -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) diff --git a/schema/testing/name_test.go b/schema/testing/name_test.go new file mode 100644 index 0000000000..b4d9a44ea6 --- /dev/null +++ b/schema/testing/name_test.go @@ -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)) + }) +} diff --git a/schema/testing/object.go b/schema/testing/object.go new file mode 100644 index 0000000000..8d7bd37a2a --- /dev/null +++ b/schema/testing/object.go @@ -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 + }) + } +} diff --git a/schema/testing/object_test.go b/schema/testing/object_test.go new file mode 100644 index 0000000000..9106878a7f --- /dev/null +++ b/schema/testing/object_test.go @@ -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)) + }) +} diff --git a/schema/testing/sonar-project.properties b/schema/testing/sonar-project.properties new file mode 100644 index 0000000000..6d4dd00504 --- /dev/null +++ b/schema/testing/sonar-project.properties @@ -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 diff --git a/schema/testing/statesim/app.go b/schema/testing/statesim/app.go new file mode 100644 index 0000000000..127af5431b --- /dev/null +++ b/schema/testing/statesim/app.go @@ -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() +} diff --git a/schema/testing/statesim/app_diff.go b/schema/testing/statesim/app_diff.go new file mode 100644 index 0000000000..6dfa222d06 --- /dev/null +++ b/schema/testing/statesim/app_diff.go @@ -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 +} diff --git a/schema/testing/statesim/doc.go b/schema/testing/statesim/doc.go new file mode 100644 index 0000000000..9f06ff8eb7 --- /dev/null +++ b/schema/testing/statesim/doc.go @@ -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 diff --git a/schema/testing/statesim/module.go b/schema/testing/statesim/module.go new file mode 100644 index 0000000000..6a33f19d58 --- /dev/null +++ b/schema/testing/statesim/module.go @@ -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() +} diff --git a/schema/testing/statesim/module_diff.go b/schema/testing/statesim/module_diff.go new file mode 100644 index 0000000000..0907f974df --- /dev/null +++ b/schema/testing/statesim/module_diff.go @@ -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 +} diff --git a/schema/testing/statesim/object_coll.go b/schema/testing/statesim/object_coll.go new file mode 100644 index 0000000000..56b09fe758 --- /dev/null +++ b/schema/testing/statesim/object_coll.go @@ -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) + } +} diff --git a/schema/testing/statesim/object_coll_diff.go b/schema/testing/statesim/object_coll_diff.go new file mode 100644 index 0000000000..0a9be9f222 --- /dev/null +++ b/schema/testing/statesim/object_coll_diff.go @@ -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") +} diff --git a/schema/testing/statesim/options.go b/schema/testing/statesim/options.go new file mode 100644 index 0000000000..c19ad56539 --- /dev/null +++ b/schema/testing/statesim/options.go @@ -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 +}