onflow / cadence

Cadence, the resource-oriented smart contract programming language 🏃‍♂️
https://cadence-lang.org
Apache License 2.0
533 stars 138 forks source link

A Type that can be used to pass in grouped data of different types as arguments. (tuples maybe?) #1016

Open orodio opened 3 years ago

orodio commented 3 years ago

Say you have some information outside of the chain and outside of cadence. The information represents something you want to create on chain, like an NFT. For each creation of the given NFT, three pieces of information is required, this information lives off chain and must be passed into the transaction, for each NFT. If you are only creating a single NFT per transaction the solution is straight forward. But if you are wanting to create multiple NFTs per transaction the solution is anything but straight forward.

If we were to represent this data in javascript we might do so like this:

var nft1 = { name: "bob", occupation: "builder", quantity: 12 }
var nft2 = { name: "pat", occupation: "postman", quantity: 4 }
var nft3 = { name: "pilchard", occupation: "cat", quantity: 1 }

var nfts = [nft1, nft2, nft3]

The part that makes passing the above into cadence as an argument hard is that Integer used to represent the quantity. By that value simply being a different type than the values for name and occupation, we can no longer use a dictionary to represent this.

Technically a struct could represent this, but for us to pass a struct in as an argument the struct needs to already publicly exist on chain as part of a contract. This is because the json-cdc requires the structs value.id to be a defined struct at a given address, ie: A.0xba1132bc08f82fe2.Profile.ReadOnly.

THERE IS CURRENTLY NO WAY TO SEND AN ARGUMENT TO FLOW IN AN AD-HOC MANNER THAT INCLUDES GROUPED DATA OF A DIFFERENT TYPE

The current easiest way to do this is by splitting all the fields up into multiple arguments that are arrays and then reassemble them in the cadence.

In the above example we would take nfts and probably create something like this:

var argumentGroups = nfts.reduce((acc, nft) => {
  acc.names.push(nft.name)
  acc.occupations.push(nft.occupations)
  acc.quantities.push(nft.quantity)
}, { names: [], occupations: [], quantities: [] })

// all so we can end up with an object that looks like
{
  names: ["bob", "pat", "pilchard"],
  occupations: ["builder", "postman", "cat"],
  quantities: [12, 4, 1]
}

// all so we can pass them into arguments like this:
fcl.args([
  fcl.arg(argumentGroups.names, t.Array(t.String)),
  fcl.arg(argumentGroups.occupations, t.Array(t.String)),
  fcl.arg(argumentGroups.quantities, t.Array(t.Int)),
]),

// all so we can have a transaction like this:
transaction(names: [String], occupations: [String], quantities: [Int]) {
  prepare(acct: AuthAccount) {
    var i = 0
    while i < names.length {
      let name = names[i]
      let occupation = occupations[i]
      let quantity = quantities[i]

      // we have the data together again in a single place hooray!!!
    }
  }
}

I can't even begin to think about how much of a nightmare the above strategy would be for more complex nested data.

The above is error prone, it feels more complicated then it should be, it is annoying, it is non-obvious solution to a problem that feels like it shouldn't exist in the first place.

I do not know the answer, it could be some how making the structs so they can be defined in the transaction or a script. It could also be a new type like a tuple that allows for a set number of individually typed things. It could be something complete different to those just mentioned.

I have been thinking about this sort of thing for a while now. And i think we already have two syntaxes that are already kind of close to providing this sort of thing. One is the way you express types <T, K>, the other is arguments (a: T, b: K). Obviously these are just ideas, possibly inspiration, y'all are the experts so will leave things with you to do what you do best, but below is how I could imagine using <T, K> and (a: T, b: K) could work in practice.

<T, K>

// honestly I still feel this one is rough and error prone
transaction(nfts: [<String, String, Int>]) {
  prepare(acct: AuthAccount) {
    for nft in nfts {
      let name = nft[0]
      let occupation = nft[1]
      let quantity = nft[2]
    }
  }
}

// in javascript
fcl.args([
  fcl.arg(nfts.map(d => [d.name, d.occupation, d.quantity]), t.Tuple([t.String, t.String, t.Int]))
])

(a: T, b: K)

// the more i think about the more i like this one, but could see why it would be much harder
transaction(nfts: [(name: String, occupation: String, quantity: Int)]) {
  prepare(acct: AuthAccount) {
    for nft in nfts {
      let name = nft.name
      let occupation = nft.occupation
      let quantity = nft.quantity
    }
  }
}

// in javascript
fcl.args({
  fcl.arg(nfts, t.Tuple({ name: t.String, occupation: t.String, quantity: t.Int }))
})

All that being said, at least to me it really doesn't matter what it looks like in cadence, the ability to do the above in transactions and scripts with out a predefined shape existing on chain somewhere, and not having to explode all the values and then reassemble all the tiny pieces would make a world of difference to many people (I wrote this issue today because it came up again today in discord). Thank you in advance.

bluesign commented 3 years ago

Doesn't AnyStruct dictionary work here? if Dictionary is {String: AnyStruct}

SupunS commented 3 years ago

Agree that's a powerful language feature to have. It's also a good alternative for multi-returns in functions (e.g: in Go).

I think we could even expand it more, by not limiting it to two types, but rather could be any number of types. e.g: <T1, T2, T3, ...>.

Swift, Rust, and Python have some good implementations of tuples.

One area we might need to closely think of is the type equivalency. e.g: vs arrays - given that both are lists of types/values. e.g: should [Int;2] and <Int, Int> be type-equivalent? (easy solution: Nope, they are not equal :smile: )

turbolent commented 3 years ago

The part that makes passing the above into cadence as an argument hard is that Integer used to represent the quantity. By that value simply being a different type than the values for name and occupation, we can no longer use a dictionary to represent this.

It is possible to have dictionaries which have values of mixed concrete types by specifying their common supertype. In your example, the common supertype for Int and String is AnyStruct, so you can declare a dictionary like so:

let nft: {String: AnyStruct} = {
    "name": "bob", 
    "occupation": "builder", 
    "quantity": 12
}

The downside of using a dictionary to pass in data, is that the access to the dictionary is cumbersome and not as "strongly typed" as passing the data in a struct: The access of the data returns an optional, which must be handled in the transaction/script, and an invalid access (e.g. due to a typo) just silently fails (results in a nil).

Technically a struct could represent this, but for us to pass a struct in as an argument the struct needs to already publicly exist on chain as part of a contract.

Passing the data as a struct would improve on the two issues, but like you pointed out, currently this requires the type of the struct to be deployed prior to submitting the transaction/script.

[...] making the structs so they can be defined in the transaction or a script.

Right, if types could be declared in a transaction/script, then they could be used to pass in arguments. This is currently not supported, because the types are only temporary, and if values referring to these types are stored (e.g. a struct, or a capability or meta type referring to the static type, etc), they could not be loaded from storage anymore.

We've recently made a lot of progress on rejecting values from being stored that cannot be, e.g. functions.

I think we could extend this functionality (checking storability) to temporarily defined struct types, and thus allow them to be used for temporary code (e.g. to pass in arguments to transactions/scripts), but still reject them from being stored.

This would be relatively little work: We would have to enable struct declarations in transactions, flag the type that is declared through such a temporary declaration as non-storable, and then ensure non-storable struct types are not stored.

Adding tuples ("anonymous composite types"), ideally with names/labels, could be another/additional solution to this problem, but this would be a lot of work and we would have to come up with a design: there are many choices that can be made here, for example subtyping relationships. Personally I would say that the effort to design and implement tuples would not be worth the benefits. In addition, I've often observed that in other languages that provide tuples, users/developers are unsure/conflicted about when to use them for what purposes.

bluesign commented 3 years ago

If the plan is rejecting them from storing, doesn't it make a bit confusing? I mean if you declare on contract you can store but in transaction cannot be stored.

Why not hide struct from the user? I think inline defining a struct can be useful. They can be non-storable as they will not be confused with struct.

Something like this:

var x: {name: String, occupation: String, quantity: Int} = {name:"bob", occupation: "builder", quantity: 12}

will basically translate to:

pub struct someTempStruct {
    pub var name: String
    pub var occupation: String 
    pub var quantity: Int

    init(name: String, occupation: String, quantity: Int) 
    {
        self.name = name
        self.occupation = occupation  
        self.quantity = quantity
    }
}
var x: someTempStruct = someTempStruct(name:"bob", occupation: "builder", quantity: 12)
turbolent commented 3 years ago

@bluesign yes, this would be nice. We could reuse / piggy-back all the code we already have for structures.

Regarding the syntax proposal: The literal in the example ({name:"bob", ...) is the same as for a dictionary where the keys are variables, so we would have to find an alternative

bjartek commented 2 years ago

What is the issue with sending in a struct defined onChain? We do that in go now with code that reads struct tags to convert to cadence names/skip values and a resolver function to generate the QualifiedIdentifier. It works marvels. I even added the most common MetadataViews as predefined structs here https://github.com/bjartek/overflow/blob/main/metadata.go

bluesign commented 2 years ago

What is the issue with sending in a struct defined onChain?

I say it is a little ugly for functions; imagine I have a function that will return 2 values, without a tuple, I need to define this struct in a contract. Then we will end up with {functionName}Response kind of structs. But actually, they can be generated on the fly.

When you already have the struct on the chain, it is not a problem ( like metadata, etc ) but iImagine a basic transaction sending airdrop: address, nftid , instead of creating struct on chain for this, you can easily use tuple.

But in general this tuple concept also brings a lot of problems for sure. Need to dig deeper.