From 4f7c3bc29056e26d1474b58695610fea13279839 Mon Sep 17 00:00:00 2001 From: ascandone Date: Mon, 3 Aug 2026 14:23:18 +0200 Subject: [PATCH] feat: expose migration tool --- internal/cmd/test_init.go | 2 +- .../__snapshots__/pretty_print_meta_test.snap | 14 +- .../interpreter/accounts_metadata_test.go | 4 +- internal/interpreter/interpreter.go | 24 ++- .../interpreter/pretty_print_meta_test.go | 12 +- internal/interpreter/set_accounts_metadata.go | 41 +--- .../experimental/meta-calc.num.specs.json | 2 +- .../script-tests/add-numbers.num.specs.json | 2 +- .../account-interp.num.specs.json | 2 +- .../get-amount-function.num.specs.json | 2 +- .../get-asset-function.num.specs.json | 2 +- .../reach-zero.num.specs.json | 2 +- .../set-account-meta.num.specs.json | 4 +- .../override-account-meta.num.specs.json | 4 +- .../set-account-meta.num.specs.json | 12 +- .../script-tests/set-tx-meta.num.specs.json | 10 +- .../sub-monetaries.num.specs.json | 2 +- .../script-tests/sub-numbers.num.specs.json | 2 +- .../variables-json.num.specs.json | 6 +- .../script-tests/variables.num.specs.json | 4 +- internal/interpreter/value.go | 182 +++--------------- internal/interpreter/value_test.go | 128 +++++------- internal/specs_format/index.go | 41 ++-- numscript_test.go | 2 +- v1.specs.schema.json | 73 ++----- 25 files changed, 164 insertions(+), 415 deletions(-) diff --git a/internal/cmd/test_init.go b/internal/cmd/test_init.go index f66cc806..60cc21b6 100644 --- a/internal/cmd/test_init.go +++ b/internal/cmd/test_init.go @@ -161,7 +161,7 @@ func makeSpecsFile( } specs := specs_format.Specs{ - Schema: "https://raw.githubusercontent.com/formancehq/numscript/main/v1.specs.schema.json", + Schema: specs_format.SchemaURL, Balances: store.StaticStore.Balances, Vars: vars, FeatureFlags: featureFlags_, diff --git a/internal/interpreter/__snapshots__/pretty_print_meta_test.snap b/internal/interpreter/__snapshots__/pretty_print_meta_test.snap index 2e9f3599..aecd36ca 100755 --- a/internal/interpreter/__snapshots__/pretty_print_meta_test.snap +++ b/internal/interpreter/__snapshots__/pretty_print_meta_test.snap @@ -1,12 +1,12 @@ [TestPrettyPrintMeta/renders_plain_values - 1] -| Name  | Value  | -| count | 42 | -| greeting | "hello" | +| Name  | Value | +| count | 42 | +| greeting | hello | --- -[TestPrettyPrintMeta/renders_a_scoped_account_value_in_its_source_form - 1] -| Name  | Value  | -| greeting | "hello" | -| owner | scoped(alice, "reserve") | +[TestPrettyPrintMeta/renders_an_account_value_as_its_bare_name - 1] +| Name  | Value | +| greeting | hello | +| owner | alice | --- diff --git a/internal/interpreter/accounts_metadata_test.go b/internal/interpreter/accounts_metadata_test.go index 91d0f5ee..7a2c529c 100644 --- a/internal/interpreter/accounts_metadata_test.go +++ b/internal/interpreter/accounts_metadata_test.go @@ -8,8 +8,8 @@ import ( ) func TestCompareSetAccountsMetadata(t *testing.T) { - x := SetAccountMetadataRow{Account: "a", Key: "k", Value: NewMonetaryInt(1)} - y := SetAccountMetadataRow{Account: "a", Key: "k", Value: NewMonetaryInt(2)} + x := SetAccountMetadataRow{Account: "a", Key: "k", Value: "1"} + y := SetAccountMetadataRow{Account: "a", Key: "k", Value: "2"} t.Run("equal regardless of order", func(t *testing.T) { require.True(t, CompareSetAccountsMetadata( diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index ed6f8c50..b06a7fe3 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -21,7 +21,10 @@ type InterpreterError interface { parser.Ranged } -type Metadata = map[string]Value +// Metadata is the external representation of transaction metadata: keys mapped +// to their rendered value (see MetaString). The runtime keeps the typed values in +// programState.TxMeta and renders them when building the ExecutionResult. +type Metadata = map[string]string type Posting struct { Source string `json:"source"` @@ -225,7 +228,7 @@ func RunProgram( res := &ExecutionResult{ Postings: st.Postings, - Metadata: st.TxMeta, + Metadata: st.txMetaToRendered(), AccountsMetadata: st.SetAccountsMeta.toRows(), } return res, nil @@ -248,6 +251,16 @@ type programState struct { CurrentBalanceQuery BalanceQuery } +// txMetaToRendered renders the typed transaction metadata into the external, +// untyped Metadata form. +func (st *programState) txMetaToRendered() Metadata { + meta := make(Metadata, len(st.TxMeta)) + for k, v := range st.TxMeta { + meta[k] = MetaString(v) + } + return meta +} + func (st *programState) pushSender(name AccountAddress, monetary MonetaryInt, color String) { monetaryBi := big.Int(monetary) @@ -1057,10 +1070,5 @@ func PrettyPrintPostings(postings []Posting) string { } func PrettyPrintMeta(meta Metadata) string { - m := map[string]string{} - for k, v := range meta { - m[k] = v.String() - } - - return utils.CsvPrettyMap("Name", "Value", m) + return utils.CsvPrettyMap("Name", "Value", meta) } diff --git a/internal/interpreter/pretty_print_meta_test.go b/internal/interpreter/pretty_print_meta_test.go index 1667d6bf..dd323011 100644 --- a/internal/interpreter/pretty_print_meta_test.go +++ b/internal/interpreter/pretty_print_meta_test.go @@ -6,20 +6,22 @@ import ( "github.com/gkampitakis/go-snaps/snaps" ) +// Metadata holds already-rendered values (see MetaString), so PrettyPrintMeta +// only has the table layout left to get right. func TestPrettyPrintMeta(t *testing.T) { t.Run("renders plain values", func(t *testing.T) { meta := Metadata{ - "greeting": String("hello"), - "count": NewMonetaryInt(42), + "greeting": MetaString(String("hello")), + "count": MetaString(NewMonetaryInt(42)), } snaps.MatchSnapshot(t, PrettyPrintMeta(meta)) }) - t.Run("renders a scoped account value in its source form", func(t *testing.T) { + t.Run("renders an account value as its bare name", func(t *testing.T) { meta := Metadata{ - "greeting": String("hello"), - "owner": AccountAddress{Name: "alice", Scope: "reserve"}, + "greeting": MetaString(String("hello")), + "owner": MetaString(AccountAddress{Name: "alice"}), } snaps.MatchSnapshot(t, PrettyPrintMeta(meta)) diff --git a/internal/interpreter/set_accounts_metadata.go b/internal/interpreter/set_accounts_metadata.go index da696fbe..a30f7efb 100644 --- a/internal/interpreter/set_accounts_metadata.go +++ b/internal/interpreter/set_accounts_metadata.go @@ -1,18 +1,17 @@ package interpreter import ( - "encoding/json" "sort" ) // SetAccountMetadataRow is a single piece of account metadata set by the script -// during execution. Unlike the input metadata (which is opaque and string-valued, -// since its serialization format isn't always known), the set value's type is -// known, so it is carried as a typed Value and serialized in the tagged form. +// during execution. The value is carried as the rendered text produced by +// MetaString, matching the input metadata: the wire format is untyped, so a +// value's type is not part of what gets stored or asserted. type SetAccountMetadataRow struct { Account string `json:"account"` Key string `json:"key"` - Value Value `json:"value"` + Value string `json:"value"` Scope string `json:"scope,omitempty"` } @@ -20,37 +19,14 @@ type SetAccountMetadataRow struct { // execution result's accountsMeta, and a spec's expect.metadata). type SetAccountsMetadata []SetAccountMetadataRow -func (r *SetAccountMetadataRow) UnmarshalJSON(data []byte) error { - var raw struct { - Account string `json:"account"` - Key string `json:"key"` - Value json.RawMessage `json:"value"` - Scope string `json:"scope"` - } - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - value, err := ParseTaggedValue(raw.Value) - if err != nil { - return err - } - r.Account, r.Key, r.Scope, r.Value = raw.Account, raw.Key, raw.Scope, value - return nil -} - // CompareSetAccountsMetadata reports whether two lists hold the same rows, -// ignoring order but respecting multiplicity (so [x, x] != [x, y]). Values are -// compared on their canonical source form. +// ignoring order but respecting multiplicity (so [x, x] != [x, y]). func CompareSetAccountsMetadata(a SetAccountsMetadata, b SetAccountsMetadata) bool { if len(a) != len(b) { return false } key := func(r SetAccountMetadataRow) string { - value := "" - if r.Value != nil { - value = r.Value.String() - } - return r.Account + "\x00" + r.Key + "\x00" + r.Scope + "\x00" + value + return r.Account + "\x00" + r.Key + "\x00" + r.Scope + "\x00" + r.Value } counts := make(map[string]int, len(a)) for _, r := range a { @@ -75,7 +51,8 @@ func (m internalSetAccountsMeta) Set(account, scope, key string, value Value) { } // toRows flattens the set metadata into the external representation, sorted by -// (account, scope, key) for deterministic output. +// (account, scope, key) for deterministic output. Values are rendered here: the +// runtime keeps them typed, the wire format does not. func (m internalSetAccountsMeta) toRows() SetAccountsMetadata { rows := make(SetAccountsMetadata, 0, len(m)) for k, value := range m { @@ -83,7 +60,7 @@ func (m internalSetAccountsMeta) toRows() SetAccountsMetadata { Account: k.Account, Scope: k.Scope, Key: k.Key, - Value: value, + Value: MetaString(value), }) } sort.Slice(rows, func(i, j int) bool { diff --git a/internal/interpreter/testdata/numscript-cookbook/experimental/meta-calc.num.specs.json b/internal/interpreter/testdata/numscript-cookbook/experimental/meta-calc.num.specs.json index a4eb2851..e96f9a86 100644 --- a/internal/interpreter/testdata/numscript-cookbook/experimental/meta-calc.num.specs.json +++ b/internal/interpreter/testdata/numscript-cookbook/experimental/meta-calc.num.specs.json @@ -9,7 +9,7 @@ { "it": "sets the expected meta", "expect.txMetadata": [ - { "key": "key", "value": { "type": "portion", "numerator": "11", "denominator": "2" } } + { "key": "key", "value": "11/2" } ] } ] diff --git a/internal/interpreter/testdata/script-tests/add-numbers.num.specs.json b/internal/interpreter/testdata/script-tests/add-numbers.num.specs.json index ff59a703..d53ad417 100644 --- a/internal/interpreter/testdata/script-tests/add-numbers.num.specs.json +++ b/internal/interpreter/testdata/script-tests/add-numbers.num.specs.json @@ -3,7 +3,7 @@ { "it": "-", "expect.txMetadata": [ - { "key": "k", "value": { "type": "number", "value": "3" } } + { "key": "k", "value": "3" } ] } ] diff --git a/internal/interpreter/testdata/script-tests/experimental/account-interpolation/account-interp.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/account-interpolation/account-interp.num.specs.json index 03bb8d1a..bdc4e86a 100644 --- a/internal/interpreter/testdata/script-tests/experimental/account-interpolation/account-interp.num.specs.json +++ b/internal/interpreter/testdata/script-tests/experimental/account-interpolation/account-interp.num.specs.json @@ -11,7 +11,7 @@ "status": "pending" }, "expect.txMetadata": [ - { "key": "k", "value": { "type": "account", "name": "acc:42:pending:user:001" } } + { "key": "k", "value": "acc:42:pending:user:001" } ] } ] diff --git a/internal/interpreter/testdata/script-tests/experimental/get-amount-function/get-amount-function.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/get-amount-function/get-amount-function.num.specs.json index a11119ac..86fbf20b 100644 --- a/internal/interpreter/testdata/script-tests/experimental/get-amount-function/get-amount-function.num.specs.json +++ b/internal/interpreter/testdata/script-tests/experimental/get-amount-function/get-amount-function.num.specs.json @@ -6,7 +6,7 @@ { "it": "-", "expect.txMetadata": [ - { "key": "amt", "value": { "type": "number", "value": "100" } } + { "key": "amt", "value": "100" } ] } ] diff --git a/internal/interpreter/testdata/script-tests/experimental/get-asset-function/get-asset-function.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/get-asset-function/get-asset-function.num.specs.json index a77662e4..5227e24d 100644 --- a/internal/interpreter/testdata/script-tests/experimental/get-asset-function/get-asset-function.num.specs.json +++ b/internal/interpreter/testdata/script-tests/experimental/get-asset-function/get-asset-function.num.specs.json @@ -6,7 +6,7 @@ { "it": "-", "expect.txMetadata": [ - { "key": "asset", "value": { "type": "asset", "name": "ABC" } } + { "key": "asset", "value": "ABC" } ] } ] diff --git a/internal/interpreter/testdata/script-tests/experimental/overdraft-function/reach-zero.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/overdraft-function/reach-zero.num.specs.json index 51053c5f..dc18ef41 100644 --- a/internal/interpreter/testdata/script-tests/experimental/overdraft-function/reach-zero.num.specs.json +++ b/internal/interpreter/testdata/script-tests/experimental/overdraft-function/reach-zero.num.specs.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/formancehq/numscript/29a351f9aa5ae03d72b85f55981e52dd57e81c07/specs.schema.json", + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/v1.specs.schema.json", "variables": { "amt": "100" }, diff --git a/internal/interpreter/testdata/script-tests/experimental/scoped-function/set-account-meta.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/scoped-function/set-account-meta.num.specs.json index 22ac13b7..28c01103 100644 --- a/internal/interpreter/testdata/script-tests/experimental/scoped-function/set-account-meta.num.specs.json +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/set-account-meta.num.specs.json @@ -8,8 +8,8 @@ { "it": "sets metadata scoped by the account's scope, distinct from the unscoped entry", "expect.metadata": [ - { "account": "acc", "key": "k", "value": { "type": "string", "value": "unscoped" } }, - { "account": "acc", "scope": "myscope", "key": "k", "value": { "type": "string", "value": "scoped" } } + { "account": "acc", "key": "k", "value": "unscoped" }, + { "account": "acc", "scope": "myscope", "key": "k", "value": "scoped" } ] } ] diff --git a/internal/interpreter/testdata/script-tests/override-account-meta.num.specs.json b/internal/interpreter/testdata/script-tests/override-account-meta.num.specs.json index 959bb872..9c1aa29f 100644 --- a/internal/interpreter/testdata/script-tests/override-account-meta.num.specs.json +++ b/internal/interpreter/testdata/script-tests/override-account-meta.num.specs.json @@ -7,8 +7,8 @@ { "account": "acc", "key": "overridden", "value": "1" } ], "expect.metadata": [ - { "account": "acc", "key": "new", "value": { "type": "number", "value": "2" } }, - { "account": "acc", "key": "overridden", "value": { "type": "number", "value": "100" } } + { "account": "acc", "key": "new", "value": "2" }, + { "account": "acc", "key": "overridden", "value": "100" } ] } ] diff --git a/internal/interpreter/testdata/script-tests/set-account-meta.num.specs.json b/internal/interpreter/testdata/script-tests/set-account-meta.num.specs.json index a9b04a7f..6b187ba7 100644 --- a/internal/interpreter/testdata/script-tests/set-account-meta.num.specs.json +++ b/internal/interpreter/testdata/script-tests/set-account-meta.num.specs.json @@ -3,12 +3,12 @@ { "it": "-", "expect.metadata": [ - { "account": "acc", "key": "account", "value": { "type": "account", "name": "acc" } }, - { "account": "acc", "key": "asset", "value": { "type": "asset", "name": "COIN" } }, - { "account": "acc", "key": "num", "value": { "type": "number", "value": "42" } }, - { "account": "acc", "key": "portion", "value": { "type": "portion", "numerator": "2", "denominator": "7" } }, - { "account": "acc", "key": "portion-perc", "value": { "type": "portion", "numerator": "1", "denominator": "100" } }, - { "account": "acc", "key": "str", "value": { "type": "string", "value": "abc" } } + { "account": "acc", "key": "account", "value": "acc" }, + { "account": "acc", "key": "asset", "value": "COIN" }, + { "account": "acc", "key": "num", "value": "42" }, + { "account": "acc", "key": "portion", "value": "2/7" }, + { "account": "acc", "key": "portion-perc", "value": "1/100" }, + { "account": "acc", "key": "str", "value": "abc" } ] } ] diff --git a/internal/interpreter/testdata/script-tests/set-tx-meta.num.specs.json b/internal/interpreter/testdata/script-tests/set-tx-meta.num.specs.json index 5b32a882..c31bb36f 100644 --- a/internal/interpreter/testdata/script-tests/set-tx-meta.num.specs.json +++ b/internal/interpreter/testdata/script-tests/set-tx-meta.num.specs.json @@ -3,11 +3,11 @@ { "it": "-", "expect.txMetadata": [ - { "key": "account", "value": { "type": "account", "name": "acc" } }, - { "key": "asset", "value": { "type": "asset", "name": "COIN" } }, - { "key": "num", "value": { "type": "number", "value": "42" } }, - { "key": "portion", "value": { "type": "portion", "numerator": "3", "denominator": "25" } }, - { "key": "str", "value": { "type": "string", "value": "abc" } } + { "key": "account", "value": "acc" }, + { "key": "asset", "value": "COIN" }, + { "key": "num", "value": "42" }, + { "key": "portion", "value": "3/25" }, + { "key": "str", "value": "abc" } ] } ] diff --git a/internal/interpreter/testdata/script-tests/sub-monetaries.num.specs.json b/internal/interpreter/testdata/script-tests/sub-monetaries.num.specs.json index 0c431eb3..c774cfeb 100644 --- a/internal/interpreter/testdata/script-tests/sub-monetaries.num.specs.json +++ b/internal/interpreter/testdata/script-tests/sub-monetaries.num.specs.json @@ -3,7 +3,7 @@ { "it": "-", "expect.txMetadata": [ - { "key": "k", "value": { "type": "monetary", "asset": "USD/2", "amount": "7" } } + { "key": "k", "value": "USD/2 7" } ] } ] diff --git a/internal/interpreter/testdata/script-tests/sub-numbers.num.specs.json b/internal/interpreter/testdata/script-tests/sub-numbers.num.specs.json index 8b2f11ee..15b29342 100644 --- a/internal/interpreter/testdata/script-tests/sub-numbers.num.specs.json +++ b/internal/interpreter/testdata/script-tests/sub-numbers.num.specs.json @@ -3,7 +3,7 @@ { "it": "-", "expect.txMetadata": [ - { "key": "k", "value": { "type": "number", "value": "9" } } + { "key": "k", "value": "9" } ] } ] diff --git a/internal/interpreter/testdata/script-tests/variables-json.num.specs.json b/internal/interpreter/testdata/script-tests/variables-json.num.specs.json index 6875d3e5..3336afdd 100644 --- a/internal/interpreter/testdata/script-tests/variables-json.num.specs.json +++ b/internal/interpreter/testdata/script-tests/variables-json.num.specs.json @@ -26,9 +26,9 @@ } ], "expect.txMetadata": [ - { "key": "description", "value": { "type": "string", "value": "midnight ride" } }, - { "key": "por", "value": { "type": "portion", "numerator": "21", "denominator": "50" } }, - { "key": "ride", "value": { "type": "number", "value": "1" } } + { "key": "description", "value": "midnight ride" }, + { "key": "por", "value": "21/50" }, + { "key": "ride", "value": "1" } ] } ] diff --git a/internal/interpreter/testdata/script-tests/variables.num.specs.json b/internal/interpreter/testdata/script-tests/variables.num.specs.json index 760a7ca3..815bce1f 100644 --- a/internal/interpreter/testdata/script-tests/variables.num.specs.json +++ b/internal/interpreter/testdata/script-tests/variables.num.specs.json @@ -25,8 +25,8 @@ } ], "expect.txMetadata": [ - { "key": "description", "value": { "type": "string", "value": "midnight ride" } }, - { "key": "ride", "value": { "type": "number", "value": "1" } } + { "key": "description", "value": "midnight ride" }, + { "key": "ride", "value": "1" } ] } ] diff --git a/internal/interpreter/value.go b/internal/interpreter/value.go index e0c13507..c1dcc8e2 100644 --- a/internal/interpreter/value.go +++ b/internal/interpreter/value.go @@ -1,7 +1,6 @@ package interpreter import ( - "encoding/json" "fmt" "math/big" "strconv" @@ -55,171 +54,34 @@ func NewAsset(src string) (Asset, InterpreterError) { return Asset(src), nil } -// A Value is (de)serialized as a tagged-JSON discriminated union, keyed by -// "type", so the on-wire form is type-explicit and unambiguous (e.g. the string -// "42" and the number 42 are distinguishable), rather than stringly-typed: +// MetaString renders v as it appears in the metadata wire format: the value +// written the way it would be in source, with no added delimiters. // -// string -> { "type": "string", "value": "abc" } -// number -> { "type": "number", "value": "42" } -// asset -> { "type": "asset", "name": "COIN" } -// account -> { "type": "account", "name": "x", "scope": "s" } // scope optional -// monetary -> { "type": "monetary", "asset": "COIN", "amount": "100" } -// portion -> { "type": "portion", "numerator": "1", "denominator": "2" } -const ( - valueTypeString = "string" - valueTypeNumber = "number" - valueTypeAsset = "asset" - valueTypeAccount = "account" - valueTypeMonetary = "monetary" - valueTypePortion = "portion" -) - -// The per-shape tagged-JSON structs below are each shared by their type's -// MarshalJSON and by ParseTaggedValue, so the layout is defined once. +// This is deliberately not String(). String() delimits values so a reader can +// tell them apart — it quotes strings and writes accounts as @name — which suits +// diagnostics. The metadata format is untyped: a value is stored as its rendered +// text and assertions compare that text, so set_tx_meta("k", "42") and +// set_tx_meta("k", 42) are indistinguishable there. That is the same behaviour +// the format had before values were briefly tagged. // -// scalar (string/number) -> { "type": ..., "value": "..." } -// asset -> { "type": "asset", "name": "COIN" } -// account -> { "type": "account", "name": "x", "scope": "s" } -// monetary -> { "type": "monetary", "asset": "COIN", "amount": "100" } -// portion -> { "type": "portion", "numerator": "1", "denominator": "2" } -type ( - taggedScalar struct { - Type string `json:"type"` - Value string `json:"value"` - } - taggedAsset struct { - Type string `json:"type"` - Name string `json:"name"` - } - taggedAccount struct { - Type string `json:"type"` - Name string `json:"name"` - Scope string `json:"scope,omitempty"` - } - taggedMonetary struct { - Type string `json:"type"` - Asset string `json:"asset"` - Amount string `json:"amount"` - } - taggedPortion struct { - Type string `json:"type"` - Numerator string `json:"numerator"` - Denominator string `json:"denominator"` - } -) - -// ParseTaggedValue decodes the tagged-JSON representation of a Value. It reads -// the "type" discriminator (json can't unmarshal directly into the Value -// interface), then decodes into the struct shared with that type's MarshalJSON. -func ParseTaggedValue(data []byte) (Value, error) { - var tag struct { - Type string `json:"type"` - } - if err := json.Unmarshal(data, &tag); err != nil { - return nil, err - } - - switch tag.Type { - case valueTypeString: - var v taggedScalar - if err := json.Unmarshal(data, &v); err != nil { - return nil, err - } - return String(v.Value), nil - - case valueTypeAccount: - var v taggedAccount - if err := json.Unmarshal(data, &v); err != nil { - return nil, err - } - if !checkAccountName(v.Name) { - return nil, fmt.Errorf("invalid account name: %q", v.Name) - } - if !checkScopeName(v.Scope) { - return nil, fmt.Errorf("invalid account scope: %q", v.Scope) - } - return AccountAddress{Name: v.Name, Scope: v.Scope}, nil - - case valueTypeAsset: - var v taggedAsset - if err := json.Unmarshal(data, &v); err != nil { - return nil, err - } - return Asset(v.Name), nil - - case valueTypeNumber: - var v taggedScalar - if err := json.Unmarshal(data, &v); err != nil { - return nil, err - } - n, ok := new(big.Int).SetString(v.Value, 10) - if !ok { - return nil, fmt.Errorf("invalid number value: %q", v.Value) - } - return MonetaryInt(*n), nil - - case valueTypeMonetary: - var v taggedMonetary - if err := json.Unmarshal(data, &v); err != nil { - return nil, err - } - n, ok := new(big.Int).SetString(v.Amount, 10) - if !ok { - return nil, fmt.Errorf("invalid monetary amount: %q", v.Amount) - } - return Monetary{Asset: Asset(v.Asset), Amount: MonetaryInt(*n)}, nil - - case valueTypePortion: - var v taggedPortion - if err := json.Unmarshal(data, &v); err != nil { - return nil, err - } - num, ok := new(big.Int).SetString(v.Numerator, 10) - if !ok { - return nil, fmt.Errorf("invalid portion numerator: %q", v.Numerator) - } - denom, ok := new(big.Int).SetString(v.Denominator, 10) - if !ok { - return nil, fmt.Errorf("invalid portion denominator: %q", v.Denominator) - } - if denom.Sign() == 0 { - return nil, fmt.Errorf("invalid portion: zero denominator") - } - return Portion(*new(big.Rat).SetFrac(num, denom)), nil - - case "": - return nil, fmt.Errorf("missing value type") +// An account value is rendered as its bare name. Nothing is lost: a scoped +// account cannot reach metadata at all, since setTxMeta and setAccountMeta both +// reject one via rejectScopedAccountMeta. +func MetaString(v Value) string { + switch v := v.(type) { + case String: + return string(v) + case AccountAddress: + return v.Name + case Asset: + return string(v) default: - return nil, fmt.Errorf("unknown value type: %q", tag.Type) + // the remaining types (MonetaryInt, Monetary, Portion) already render + // without delimiters + return v.String() } } -func (v String) MarshalJSON() ([]byte, error) { - return json.Marshal(taggedScalar{valueTypeString, string(v)}) -} - -func (v Asset) MarshalJSON() ([]byte, error) { - return json.Marshal(taggedAsset{valueTypeAsset, string(v)}) -} - -func (v AccountAddress) MarshalJSON() ([]byte, error) { - return json.Marshal(taggedAccount{valueTypeAccount, v.Name, v.Scope}) -} - -func (v MonetaryInt) MarshalJSON() ([]byte, error) { - bi := big.Int(v) - return json.Marshal(taggedScalar{valueTypeNumber, bi.String()}) -} - -func (v Portion) MarshalJSON() ([]byte, error) { - r := big.Rat(v) - return json.Marshal(taggedPortion{valueTypePortion, r.Num().String(), r.Denom().String()}) -} - -func (v Monetary) MarshalJSON() ([]byte, error) { - return json.Marshal(taggedMonetary{valueTypeMonetary, string(v.Asset), v.Amount.String()}) -} - func (v String) String() string { return fmt.Sprintf(`"%s"`, string(v)) } diff --git a/internal/interpreter/value_test.go b/internal/interpreter/value_test.go index d122d577..2a728da3 100644 --- a/internal/interpreter/value_test.go +++ b/internal/interpreter/value_test.go @@ -1,7 +1,6 @@ package interpreter_test import ( - "encoding/json" "math/big" "testing" @@ -10,98 +9,63 @@ import ( "github.com/stretchr/testify/require" ) -func TestMarshalMonetaryInt(t *testing.T) { +func TestMetaString(t *testing.T) { t.Parallel() - x := interpreter.NewMonetaryInt(42) - - j, err := json.Marshal(x) - require.Nil(t, err) - require.JSONEq(t, `{"type":"number","value":"42"}`, string(j)) -} - -func TestMarshalString(t *testing.T) { - t.Parallel() - - x := interpreter.String("abc") - - j, err := json.Marshal(x) - require.Nil(t, err) - require.JSONEq(t, `{"type":"string","value":"abc"}`, string(j)) -} - -func TestMarshalAsset(t *testing.T) { - t.Parallel() - - x := interpreter.Asset("EUR/2") - - j, err := json.Marshal(x) - require.Nil(t, err) - require.JSONEq(t, `{"type":"asset","name":"EUR/2"}`, string(j)) -} - -func TestMarshalAddress(t *testing.T) { - t.Parallel() - - j, err := json.Marshal(interpreter.AccountAddress{Name: "abc"}) - require.Nil(t, err) - require.JSONEq(t, `{"type":"account","name":"abc"}`, string(j)) - - j, err = json.Marshal(interpreter.AccountAddress{Name: "abc", Scope: "s"}) - require.Nil(t, err) - require.JSONEq(t, `{"type":"account","name":"abc","scope":"s"}`, string(j)) -} - -func TestMarshalPortion(t *testing.T) { - t.Parallel() - - x := interpreter.Portion(*big.NewRat(2, 3)) - - j, err := json.Marshal(x) - require.Nil(t, err) - require.JSONEq(t, `{"type":"portion","numerator":"2","denominator":"3"}`, string(j)) -} - -func TestMarshalMonetary(t *testing.T) { - t.Parallel() - - x := interpreter.Monetary{ - Asset: interpreter.Asset("USD/2"), - Amount: interpreter.NewMonetaryInt(100), + for _, tc := range []struct { + name string + value interpreter.Value + expected string + }{ + {"string is not quoted", interpreter.String("abc"), "abc"}, + {"empty string", interpreter.String(""), ""}, + {"a string that looks like a number", interpreter.String("42"), "42"}, + {"asset", interpreter.Asset("EUR/2"), "EUR/2"}, + {"account has no @ prefix", interpreter.AccountAddress{Name: "alice"}, "alice"}, + {"number", interpreter.NewMonetaryInt(42), "42"}, + {"negative number", interpreter.NewMonetaryInt(-7), "-7"}, + {"monetary", interpreter.Monetary{Asset: "USD/2", Amount: interpreter.NewMonetaryInt(100)}, "USD/2 100"}, + {"portion", interpreter.Portion(*big.NewRat(2, 3)), "2/3"}, + {"portion is normalized", interpreter.Portion(*big.NewRat(2, 4)), "1/2"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.expected, interpreter.MetaString(tc.value)) + }) } - - j, err := json.Marshal(x) - require.Nil(t, err) - require.JSONEq(t, `{"type":"monetary","asset":"USD/2","amount":"100"}`, string(j)) } -func TestParseTaggedValueRoundTrip(t *testing.T) { +// The metadata format is untyped on purpose: it stores the rendered value, so +// values of different types that render the same are indistinguishable there. +// This is the trade-off that keeps the format stringly-typed, and it is what the +// pre-tagged format did too. +func TestMetaStringConflatesTypesThatRenderAlike(t *testing.T) { t.Parallel() - values := []interpreter.Value{ - interpreter.String("abc"), - interpreter.Asset("EUR/2"), - interpreter.AccountAddress{Name: "alice"}, - interpreter.AccountAddress{Name: "alice", Scope: "reserve"}, - interpreter.NewMonetaryInt(42), - interpreter.Monetary{Asset: "USD/2", Amount: interpreter.NewMonetaryInt(100)}, - interpreter.Portion(*big.NewRat(2, 3)), - } + require.Equal(t, + interpreter.MetaString(interpreter.String("42")), + interpreter.MetaString(interpreter.NewMonetaryInt(42)), + ) - for _, v := range values { - j, err := json.Marshal(v) - require.Nil(t, err) + require.Equal(t, + interpreter.MetaString(interpreter.String("COIN")), + interpreter.MetaString(interpreter.Asset("COIN")), + ) - parsed, err := interpreter.ParseTaggedValue(j) - require.Nil(t, err) - // compare on the canonical source form - require.Equal(t, v.String(), parsed.String()) - } + require.Equal(t, + interpreter.MetaString(interpreter.String("alice")), + interpreter.MetaString(interpreter.AccountAddress{Name: "alice"}), + ) } -func TestParseTaggedValueRejectsUnknownType(t *testing.T) { +// String() is the diagnostic form and stays delimited, so error messages can +// still tell a string from an account. MetaString is the wire form. +func TestStringDiffersFromMetaString(t *testing.T) { t.Parallel() - _, err := interpreter.ParseTaggedValue([]byte(`{"type":"bogus"}`)) - require.Error(t, err) + require.Equal(t, `"abc"`, interpreter.String("abc").String()) + require.Equal(t, "abc", interpreter.MetaString(interpreter.String("abc"))) + + require.Equal(t, "@alice", interpreter.AccountAddress{Name: "alice"}.String()) + require.Equal(t, "alice", interpreter.MetaString(interpreter.AccountAddress{Name: "alice"})) } diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index 0dbe691b..aa19146d 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -2,7 +2,6 @@ package specs_format import ( "context" - "encoding/json" "fmt" "math/big" "reflect" @@ -14,46 +13,25 @@ import ( ) // TxMetadataRow is a single transaction metadata entry. Like SetAccountMetadataRow, -// the value's type is known, so it is carried as a typed Value written in the tagged -// value format (e.g. {"type":"account","name":"x"}). +// the value is the rendered text produced by interpreter.MetaString: the wire +// format is untyped. type TxMetadataRow struct { - Key string `json:"key"` - Value interpreter.Value `json:"value"` + Key string `json:"key"` + Value string `json:"value"` } // ExpectedTxMeta is a test case's expected transaction metadata: a list of rows, // mirroring expect.metadata. Comparison ignores order (see compareTxMeta). type ExpectedTxMeta []TxMetadataRow -func (r *TxMetadataRow) UnmarshalJSON(data []byte) error { - var raw struct { - Key string `json:"key"` - Value json.RawMessage `json:"value"` - } - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - value, err := interpreter.ParseTaggedValue(raw.Value) - if err != nil { - return err - } - r.Key, r.Value = raw.Key, value - return nil -} - // compareTxMeta reports whether two lists hold the same rows, ignoring order but -// respecting multiplicity (so [x, x] != [x, y]). Values are compared on their -// canonical source form, so a string "42" and the number 42 are not conflated. +// respecting multiplicity (so [x, x] != [x, y]). func compareTxMeta(a ExpectedTxMeta, b ExpectedTxMeta) bool { if len(a) != len(b) { return false } key := func(r TxMetadataRow) string { - value := "" - if r.Value != nil { - value = r.Value.String() - } - return r.Key + "\x00" + value + return r.Key + "\x00" + r.Value } counts := make(map[string]int, len(a)) for _, r := range a { @@ -79,6 +57,13 @@ func txMetaToRows(m interpreter.Metadata) ExpectedTxMeta { return rows } +// SchemaURL is the canonical $schema of the current specs format. Generated +// files point at it, and it is what a migration rewrites a stale $schema to. +const SchemaURL = "https://raw.githubusercontent.com/formancehq/numscript/main/v1.specs.schema.json" + +// InputsSchemaURL is the equivalent for the inputs format. +const InputsSchemaURL = "https://raw.githubusercontent.com/formancehq/numscript/main/v1.inputs.schema.json" + // --- Specs: type Specs struct { Schema string `json:"$schema,omitempty"` diff --git a/numscript_test.go b/numscript_test.go index fa24e76a..972f17e5 100644 --- a/numscript_test.go +++ b/numscript_test.go @@ -455,7 +455,7 @@ set_tx_meta( require.Nil(t, err) require.Equal(t, interpreter.Metadata{ - "k": interpreter.NewMonetary("USD/2", 100), + "k": "USD/2 100", }, res.Metadata) require.Equal(t, diff --git a/v1.specs.schema.json b/v1.specs.schema.json index 09298617..d4da83db 100644 --- a/v1.specs.schema.json +++ b/v1.specs.schema.json @@ -78,6 +78,10 @@ "expect.error.missingFunds": { "type": "boolean" + }, + + "expect.error.negativeAmount": { + "type": "boolean" } } }, @@ -156,13 +160,13 @@ "SetAccountsMetadata": { "type": "array", - "description": "List of account metadata entries set during execution. Each value is a tagged value.", + "description": "List of account metadata entries set during execution. The (account, key, scope) tuple of each entry must be unique within the list.", "items": { "$ref": "#/definitions/SetAccountMetadataRow" } }, "SetAccountMetadataRow": { "type": "object", - "description": "A single set metadata entry: the tagged value of a given (account, key, scope)", + "description": "A single set metadata entry: the value of a given (account, key, scope)", "additionalProperties": false, "required": ["account", "key", "value"], "properties": { @@ -174,7 +178,7 @@ "type": "string" }, "value": { - "$ref": "#/definitions/Value" + "$ref": "#/definitions/MetaValue" }, "scope": { "type": "string", @@ -191,7 +195,7 @@ "TxMetadataRow": { "type": "object", - "description": "A single transaction metadata entry: the tagged value of a given key", + "description": "A single transaction metadata entry: the value of a given key", "additionalProperties": false, "required": ["key", "value"], "properties": { @@ -199,67 +203,14 @@ "type": "string" }, "value": { - "$ref": "#/definitions/Value" + "$ref": "#/definitions/MetaValue" } } }, - "Value": { - "type": "object", - "description": "A tagged value, discriminated by its \"type\"", - "oneOf": [ - { - "additionalProperties": false, - "required": ["type", "value"], - "properties": { - "type": { "const": "string" }, - "value": { "type": "string" } - } - }, - { - "additionalProperties": false, - "required": ["type", "value"], - "properties": { - "type": { "const": "number" }, - "value": { "type": "string", "pattern": "^-?[0-9]+$" } - } - }, - { - "additionalProperties": false, - "required": ["type", "name"], - "properties": { - "type": { "const": "asset" }, - "name": { "type": "string", "pattern": "^([A-Z]+(/[0-9]+)?)$" } - } - }, - { - "additionalProperties": false, - "required": ["type", "name"], - "properties": { - "type": { "const": "account" }, - "name": { "type": "string", "pattern": "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$" }, - "scope": { "type": "string", "pattern": "^[a-z0-9_]*$" } - } - }, - { - "additionalProperties": false, - "required": ["type", "asset", "amount"], - "properties": { - "type": { "const": "monetary" }, - "asset": { "type": "string", "pattern": "^([A-Z]+(/[0-9]+)?)$" }, - "amount": { "type": "string", "pattern": "^-?[0-9]+$" } - } - }, - { - "additionalProperties": false, - "required": ["type", "numerator", "denominator"], - "properties": { - "type": { "const": "portion" }, - "numerator": { "type": "string", "pattern": "^-?[0-9]+$" }, - "denominator": { "type": "string", "pattern": "^-?[0-9]+$" } - } - } - ] + "MetaValue": { + "type": "string", + "description": "A metadata value, as rendered in source form: a string as-is (abc), an account as its bare name (alice), an asset (COIN, USD/2), a number (42, -7), a monetary (USD/2 100), or a portion (1/2). The format is untyped: values of different types that render alike are not distinguished." }, "Movements": {