Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions inputs.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@
"definitions": {
"Balances": {
"type": "array",
"description": "List of account balances. The (account, asset, color) triple of each entry must be unique within the list.",
"description": "List of account balances. The (account, asset, color, scope) tuple of each entry must be unique within the list.",
"items": { "$ref": "#/definitions/BalanceRow" }
},

"BalanceRow": {
"type": "object",
"description": "The balance of a given (account, asset, color)",
"description": "The balance of a given (account, asset, color, scope)",
"additionalProperties": false,
"required": ["account", "asset", "amount"],
"properties": {
Expand All @@ -46,6 +46,10 @@
"color": {
"type": "string",
"pattern": "^[A-Z]*$"
},
"scope": {
"type": "string",
"pattern": "^[a-z0-9_]*$"
}
}
},
Expand All @@ -60,13 +64,30 @@
},

"AccountsMetadata": {
"type": "array",
"description": "List of account metadata entries. The (account, key, scope) tuple of each entry must be unique within the list.",
"items": { "$ref": "#/definitions/AccountMetadataRow" }
},

"AccountMetadataRow": {
"type": "object",
"description": "Map of an account metadata to the account's metadata",
"description": "A single metadata entry: the value of a given (account, key, scope)",
"additionalProperties": false,
"patternProperties": {
"^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$": {
"type": "object",
"additionalProperties": { "type": "string" }
"required": ["account", "key", "value"],
"properties": {
"account": {
"type": "string",
"pattern": "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$"
},
"key": {
"type": "string"
},
"value": {
"type": "string"
},
"scope": {
"type": "string",
"pattern": "^[a-z0-9_]*$"
}
}
},
Expand Down
31 changes: 24 additions & 7 deletions internal/analysis/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,19 @@ func (StatementFnCallResolution) fnCallResolution() {}
func (r VarOriginFnCallResolution) GetParams() []string { return r.Params }
func (r StatementFnCallResolution) GetParams() []string { return r.Params }

const FnSetTxMeta = "set_tx_meta"
const FnSetAccountMeta = "set_account_meta"
const FnVarOriginMeta = "meta"
const FnVarOriginBalance = "balance"
const FnVarOriginOverdraft = "overdraft"
const FnVarOriginGetAsset = "get_asset"
const FnVarOriginGetAmount = "get_amount"
const (
// Statemetn fns
FnSetTxMeta = "set_tx_meta"
FnSetAccountMeta = "set_account_meta"

// Expr fns
FnVarOriginMeta = "meta"
FnVarOriginBalance = "balance"
FnVarOriginOverdraft = "overdraft"
FnVarOriginGetAsset = "get_asset"
FnVarOriginGetAmount = "get_amount"
FnVarOriginScoped = "scoped"
)

var Builtins = map[string]FnCallResolution{
FnSetTxMeta: StatementFnCallResolution{
Expand Down Expand Up @@ -114,6 +120,17 @@ var Builtins = map[string]FnCallResolution{
},
},
},
FnVarOriginScoped: VarOriginFnCallResolution{
Comment thread
ascandone marked this conversation as resolved.
Comment thread
ascandone marked this conversation as resolved.
Comment thread
ascandone marked this conversation as resolved.
Comment thread
ascandone marked this conversation as resolved.
Comment thread
ascandone marked this conversation as resolved.
Params: []string{TypeAccount, TypeString},
Return: TypeAccount,
Docs: "returns the scoped version of that account. Empty string means no scope. Overwrites the previous scope",
VersionConstraints: []VersionClause{
{
Version: parser.NewVersionInterpreter(0, 0, 25),
Comment thread
ascandone marked this conversation as resolved.
Comment thread
ascandone marked this conversation as resolved.
Comment thread
ascandone marked this conversation as resolved.
FeatureFlag: flags.ExperimentalScopedFunction,
Comment thread
ascandone marked this conversation as resolved.
},
},
},
}

type Diagnostic struct {
Expand Down
14 changes: 13 additions & 1 deletion internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,27 @@ func run(scriptPath string, opts RunArgs) error {
}

// Reject a malformed inputs file before running anything: a balance list is a
// map keyed by (account, asset, color), so a repeated key is ambiguous.
// map keyed by (account, asset, color, scope), so a repeated key is ambiguous.
if dup, ok := inputs.Balances.FirstDuplicate(); ok {
key := fmt.Sprintf("account=%q asset=%q", dup.Account, dup.Asset)
if dup.Color != "" {
key += fmt.Sprintf(" color=%q", dup.Color)
}
if dup.Scope != "" {
key += fmt.Sprintf(" scope=%q", dup.Scope)
}
return fmt.Errorf("invalid inputs file '%s': balances must not contain duplicate entries: duplicate entry for %s", inputsPath, key)
}

// Likewise, a metadata list is keyed by (account, key, scope).
if dup, ok := inputs.Meta.FirstDuplicate(); ok {
key := fmt.Sprintf("account=%q key=%q", dup.Account, dup.Key)
if dup.Scope != "" {
key += fmt.Sprintf(" scope=%q", dup.Scope)
}
return fmt.Errorf("invalid inputs file '%s': metadata must not contain duplicate entries: duplicate entry for %s", inputsPath, key)
}

featureFlags := map[string]struct{}{}
for _, flag := range inputs.FeatureFlags {
featureFlags[flag] = struct{}{}
Expand Down
17 changes: 9 additions & 8 deletions internal/cmd/test_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ func makeSpecsFile(
DefaultBalance: defaultBalance,
StaticStore: interpreter.StaticStore{
Balances: interpreter.Balances{},
Meta: make(interpreter.AccountsMetadata),
Meta: interpreter.AccountsMetadata{},
},
}

Expand Down Expand Up @@ -210,25 +210,25 @@ func (s *TestInitStore) GetBalances(ctx context.Context, q interpreter.BalanceQu
return nil, err
}

type key struct{ account, asset, color string }
type key struct{ account, asset, color, scope string }

// StaticStore.GetBalances materializes a zero-amount row for every queried
// (account, asset, color), so its output can't tell a known account from an
// unknown one. Track what we've actually funded ourselves instead.
// (account, asset, color, scope), so its output can't tell a known account from
// an unknown one. Track what we've actually funded ourselves instead.
stored := make(map[key]struct{}, len(s.StaticStore.Balances))
for _, b := range s.StaticStore.Balances {
stored[key{b.Account, b.Asset, b.Color}] = struct{}{}
stored[key{b.Account, b.Asset, b.Color, b.Scope}] = struct{}{}
}

for i := range balances {
b := &balances[i]
k := key{b.Account, b.Asset, b.Color}
k := key{b.Account, b.Asset, b.Color, b.Scope}
if _, ok := stored[k]; ok {
continue
}

// Unknown (account, asset, color): fund it with the default balance, and
// remember it so later queries (and the generated specs file) see it.
// Unknown (account, asset, color, scope): fund it with the default balance,
// and remember it so later queries (and the generated specs file) see it.
amount := new(big.Int)
if s.DefaultBalance != nil {
amount.Set(s.DefaultBalance)
Expand All @@ -239,6 +239,7 @@ func (s *TestInitStore) GetBalances(ctx context.Context, q interpreter.BalanceQu
Account: b.Account,
Asset: b.Asset,
Color: b.Color,
Scope: b.Scope,
Amount: new(big.Int).Set(amount),
})
stored[k] = struct{}{}
Expand Down
14 changes: 14 additions & 0 deletions internal/cmd/test_init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ func TestMakeSpecsFileRetryForMissingFunds(t *testing.T) {
}, out.Balances)
}

func TestMakeSpecsFileFundsScopedBalance(t *testing.T) {
out, err := cmd.MakeSpecsFile(`
send [USD/2 10000] (
source = scoped(@alice, "reserve")
destination = @bob
)
`)

require.Nil(t, err)
require.Equal(t, interpreter.Balances{
{Account: "alice", Asset: "USD/2", Scope: "reserve", Amount: big.NewInt(10000)},
}, out.Balances)
}

func TestUnusedVars(t *testing.T) {
out, err := cmd.MakeSpecsFile(`
vars { monetary $m }
Expand Down
2 changes: 2 additions & 0 deletions internal/flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const (
ExperimentalOverdraftFunctionFeatureFlag FeatureFlag = "experimental-overdraft-function"
ExperimentalGetAssetFunctionFeatureFlag FeatureFlag = "experimental-get-asset-function"
ExperimentalGetAmountFunctionFeatureFlag FeatureFlag = "experimental-get-amount-function"
ExperimentalScopedFunction FeatureFlag = "experimental-scoped-function"
ExperimentalOneofFeatureFlag FeatureFlag = "experimental-oneof"
ExperimentalAccountInterpolationFlag FeatureFlag = "experimental-account-interpolation"
ExperimentalMidScriptFunctionCall FeatureFlag = "experimental-mid-script-function-call"
Expand All @@ -17,6 +18,7 @@ var AllFlags []string = []string{
ExperimentalOverdraftFunctionFeatureFlag,
ExperimentalGetAssetFunctionFeatureFlag,
ExperimentalGetAmountFunctionFeatureFlag,
ExperimentalScopedFunction,
ExperimentalOneofFeatureFlag,
ExperimentalAccountInterpolationFlag,
ExperimentalMidScriptFunctionCall,
Expand Down
13 changes: 13 additions & 0 deletions internal/interpreter/__snapshots__/accounts_metadata_test.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@

[TestPrettyPrintAccountsMetadata/without_scope_(no_Scope_column) - 1]
| Account | Name | Value  |
| alice | kyc | verified |
| bob | tier | gold |
---

[TestPrettyPrintAccountsMetadata/with_scope_(Scope_column_shown) - 1]
| Account | Scope | Name | Value  |
| alice | eu | kyc | pending |
| alice | | kyc | verified |
| bob | | tier | gold |
---
16 changes: 16 additions & 0 deletions internal/interpreter/__snapshots__/pretty_print_postings_test.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@

[TestPrettyPrintPostings/no_scope,_no_color_(no_optional_columns) - 1]
| Source | Destination | Asset | Amount |
| world | alice | EUR/2 | 100 |
---

[TestPrettyPrintPostings/only_source_scope_(only_Source_Scope_column_shown) - 1]
| Source | Source Scope | Destination | Asset | Amount |
| src | x | dest | USD | 10 |
| world | | dest | USD | 5 |
---

[TestPrettyPrintPostings/both_scopes_(both_Scope_columns_shown) - 1]
| Source | Source Scope | Destination | Destination Scope | Asset | Amount |
| src | x | dest | y | USD | 10 |
---
84 changes: 50 additions & 34 deletions internal/interpreter/accounts_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,50 +4,66 @@ import (
"github.com/formancehq/numscript/internal/utils"
)

type AccountMetadata = map[string]string
type AccountsMetadata map[string]AccountMetadata

func (m AccountsMetadata) fetchAccountMetadata(account string) AccountMetadata {
return utils.MapGetOrPutDefault(m, account, func() AccountMetadata {
return AccountMetadata{}
})
}

func (m AccountsMetadata) DeepClone() AccountsMetadata {
cloned := make(AccountsMetadata)
for account, accountBalances := range m {
for asset, metadataValue := range accountBalances {
clonedAccountBalances := cloned.fetchAccountMetadata(account)
utils.MapGetOrPutDefault(clonedAccountBalances, asset, func() string {
return metadataValue
})
}
}
return cloned
type AccountMetadataRow struct {
Account string `json:"account"`
Key string `json:"key"`
Value string `json:"value"`
Scope string `json:"scope,omitempty"`
}

func (m AccountsMetadata) Merge(update AccountsMetadata) {
for acc, accBalances := range update {
cachedAcc := utils.MapGetOrPutDefault(m, acc, func() AccountMetadata {
return AccountMetadata{}
})
// AccountsMetadata is the external, serialized representation of account
// metadata. The runtime works with the in-memory InternalAccountsMetadata and
// converts to this at the boundaries (store queries, execution result).
type AccountsMetadata []AccountMetadataRow

for curr, amt := range accBalances {
cachedAcc[curr] = amt
// FirstDuplicate returns the first row whose (account, key, scope) key already
// appeared earlier in the list, if any. That triple is the identity of a
// metadata entry and the value is its content, so a repeated key is an
// ambiguous, malformed input.
func (rows AccountsMetadata) FirstDuplicate() (AccountMetadataRow, bool) {
seen := make(map[[3]string]struct{}, len(rows))
for _, row := range rows {
key := [3]string{row.Account, row.Key, row.Scope}
if _, ok := seen[key]; ok {
return row, true
}
seen[key] = struct{}{}
}
return AccountMetadataRow{}, false
}

func (m AccountsMetadata) PrettyPrint() string {
header := []string{"Account", "Name", "Value"}
// the Scope column is dropped automatically when no entry has a scope
header := []string{"Account", "Scope", "Name", "Value"}

var rows [][]string
for account, accMetadata := range m {
for name, value := range accMetadata {
row := []string{account, name, value}
rows = append(rows, row)
}
for _, row := range m {
rows = append(rows, []string{row.Account, row.Scope, row.Key, row.Value})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [minor] Keep required metadata value column when values are empty

If account metadata entries all have an empty string value, CsvPrettyOmitEmptyCols drops the Value column because every cell is empty, even though only Scope is optional here. This makes pretty-printed metadata with explicit empty values indistinguishable from metadata without a value column, whereas the previous pretty printer always displayed the value field.

}

return utils.CsvPretty(header, rows, true)
return utils.CsvPrettyOmitEmptyCols(header, rows, true)
}

// CompareAccountsMetadata reports whether two metadata lists hold the same rows,
// ignoring order but respecting multiplicity. A duplicated row in one list must
// be matched by the same number of occurrences in the other, so e.g. [x, x] is
// not considered equal to [x, y].
func CompareAccountsMetadata(a AccountsMetadata, b AccountsMetadata) bool {
if len(a) != len(b) {
return false
}
// AccountMetadataRow is an all-string (comparable) struct, so it can key the
// multiset directly.
counts := make(map[AccountMetadataRow]int, len(a))
for _, row := range a {
Comment thread
ascandone marked this conversation as resolved.
counts[row]++
}
for _, row := range b {
counts[row]--
if counts[row] < 0 {
return false
}
}
// equal lengths + every b row consumed a distinct a row => exact multiset match
return true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading