Aptos Tutorial Episode 4: Let’s Table This For Now (Part 1)

|
by: Wayne Culbreth

Life moves fast in the Aptos world. In the few weeks I’ve been writing these tutorials, the Aptos team has introduced an entirely new module: Tables. And a few days after they introduced Tables, they introduced Iterable Tables (not to be confused with “irratable tables” which generally describes me in my kitchen pre-coffee). Then Bucket Tables. Tables can make our development lives much simpler. Let’s revisit our Tickets scenario and rebuild the module with some Tables.

(full Git repo for this episode is here).

So, what exactly is a ‘Table’ in the world of Aptos & Move? A table is a way to store data with a specific identifier. You’ll see it in Move code as:

Table<K, V>

Where K is a type of key and V is a type of value. Both K and V can be any valid Move type. Take a simple JSON object as an example:

{   name: "Wayne Culbreth",   discord: "Magnum6#6523"}

We could store this in a Move table with K and V being ASCII::String types, or Table<ASCII::String, ASCII::String>. In actuality, a Table is much closer to a Javascript Map than a regular Object. In an Javascript Object — keys have to be string whereas in a Map, they can be pretty much any type, which is what we get with the Move Table.

What does this look like in a practical use scenario? Well, in our Tickets module, it would be helpful if we could access tickets by row and seat number — since that’s how the entire free world identifies tickets. So, let’s create a new SeatIdentifier struct as follows:

struct SeatIdentifier has store, drop {   row: ASCII::String,   seat_number: u64}

First off — what’s this ASCII::String thing doing here? Std::ASCII is a standard libary module that provides wrapper functions around vector<u8> types. It writes to the Aptos blockchain as regular strings. The blockchain itself doesn’t care how you store the data — but when we start looking at data through the Aptos Explorer (to be discussed soon), it makes that data human readable (so that we don’t have to decipher hex to ASCII in our heads, no matter how smart we may be).

Now that we can identify our seat, let’s rewrite the ConcertTicket struct as:

struct ConcertTicket has key, store, drop {   identifier: SeatIdentifier,   ticket_code: ASCII::String,   price: u64}

And lastly our Venue as:

struct Venue has key {   available_tickets: Table<SeatIdentifier, ConcertTicket>,   max_seats: u64}

When we initialize our Venue, rather than creating an empty vector, we’re going to do the same thing with an empty table:

public(script) fun init_venue(venue_owner: &signer, max_seats: u64) {   let available_tickets = Table::new<SeatIdentifier, ConcertTicket>();   move_to<Venue>(venue_owner, Venue {available_tickets, max_seats})}

This is very similar to initializing a vector, but we’re using the AptosFramework::Table module to create a new table (be sure to add use AptosFramework::Table to your uses at the top of the module).

Next we’ll need to update our available_ticket_count function to look at the table length rather than vector:

public(script) fun available_ticket_count(venue_owner_addr: address): u64 acquires Venue {   let venue = borrow_global<Venue>(venue_owner_addr);   Table::length<SeatIdentifier, ConcertTicket>(&venue.available_tickets)}

Again, very similar to Vector, but using the same type of call from the Table module. We’re cooking with gas now. All looks pretty much the same — almost a search/replace of Vector/Table.

We can quickly rebuild our create_ticket function with:

public(script) fun create_ticket(venue_owner: &signer, row: vector<u8>, seat_number: u64, ticket_code: vector<u8>, price: u64) acquires Venue {   let venue_owner_addr = Signer::address_of(venue_owner);   assert!(exists<Venue>(venue_owner_addr), ENO_VENUE);   let current_seat_count = available_ticket_count(venue_owner_addr);   let venue = borrow_global_mut<Venue>(venue_owner_addr);   assert!(current_seat_count < venue.max_seats, EMAX_SEATS);   let identifier = SeatIdentifier { row: (ASCII::string(row)), seat_number };   let ticket = ConcertTicket { identifier, ticket_code: (ASCII::string(ticket_code)), price};   Table::add(&mut venue.available_tickets, &identifier, ticket)}

Now, most of this looks familiar, but lets break down what’s going on. The first thing you’ll notice is we’ve broken out seat into row and seat_number. If you’re looking closely, you’ll see that row is a vector<u8> rather than ASCII::String. The simple reason is that, as of the date of this writing, we cannot pass structs as arguments from the client. There are a number of reasons for that which are beyond the scope of this article, so just trust me that we can’t do it (yet — there may be a process down the road). So, we’ve got to pass the string in as our old vector<u8> for now.

The next line that’s different is

let identifier = SeatIdentifier { row: ASCII::string(row), seat_number };

Here we are simply creating our SeatIdentifier that will be both the key in our table as well as being part of our ConcertTicket. You’ll notice that we pass the row variable to the struct constructor as row: ASCII::string(row). There are a few things worth pointing out in this little bit of code. First, we want to convert that vector<u8> into type ASCII::String. The Std::ASCII module gives us a function to do that, the homonymous function ASCII::string (you have no idea how much I’m dorking out over using the word ‘homonymous’ in a sentence; I’m really fun at parties). The interface for the string function in Std::ASCII is:

public fun string(bytes: vector<u8>): String

We feed it a vector<u8> and it hands us back a string we (humans) can read directly in Explorer. We do the same thing in creating our ConcertTicket in the next line and then add it to our table with:

Table::add(&mut venue.available_tickets, &identifier, ticket);

A few things to point out here: 1) in the first parameter of add we need to pass a mutable reference to the table we’re adding to, hence the &mut in front of the venue.available_tickets. We also need to pass a reference to our key, which in this case is &identifier. The third parameter is the ticket we just created. And, just like that, we’ve added the ticket.

Now, we going to completely drop our get_ticket_info function as we can get all the data we need from the REST API (more on that soon). Same story on get_ticket_price. The only reason we might leave those functions in is if we wanted to let other modules query, or if we utilized the info in unit testing. But since I’m trying to get more ‘concise’ in my tutorials, we’re just going to leave those out today.

So that means the only other function we have to update is purchase_ticket. Here’s the full function then we’ll break it down step by step:

public(script) fun purchase_ticket(buyer: &signer, venue_owner_addr: address, row: vector<u8>, seat_number: u64) acquires Venue, TicketEnvelope {   let buyer_addr = Signer::address_of(buyer);   let target_seat_id = SeatIdentifier { row: ASCII::string(row), seat_number };   let venue = borrow_global_mut<Venue>(venue_owner_addr);   assert!(Table::contains<SeatIdentifier, ConcertTicket>(&venue.available_tickets, &target_seat_id), EINVALID_TICKET);   let target_ticket = Table::borrow<SeatIdentifier, ConcertTicket>(&venue.available_tickets, &target_seat_id);   Coin::transfer<TestCoin>(buyer, venue_owner_addr, target_ticket.price);   let ticket = Table::remove<SeatIdentifier, ConcertTicket>(&mut venue.available_tickets, &target_seat_id);   if (!exists<TicketEnvelope>(buyer_addr)) {      move_to<TicketEnvelope>(buyer, TicketEnvelope {tickets: Vector::empty<ConcertTicket>()});   };   let envelope = borrow_global_mut<TicketEnvelope>(buyer_addr);   Vector::push_back<ConcertTicket>(&mut envelope.tickets, ticket);}

As with create_ticket, we’ve split the seat selection into row and seat_number. We create our identifier with:

let target_seat_id = SeatIdentifier { row: ASCII::string(row), seat_number };

We grab our Venue out of global storage, then we need to check and see if the ticket our customer is trying to buy is available. We no longer need our search function because we can use the SeatIdentifier and Table::contains to see if the seat exists in available_tickets:

assert!(Table::contains<SeatIdentifier, ConcertTicket>(&venue.available_tickets, &target_seat_id), EINVALID_TICKET);

Like previous Table functions, we’re providing SeatIdentifier and ConcertTicket as our type parameters, along with a reference to the table we’re checking (&venue.available_tickets) and a reference to the key (&target_seat_id).

Similar to grabbing a resource from global storage, if we want to do something with a Table value, we need to borrow that value with either a mutable or non-mutable reference. We’re leaving that value in the table, we just want to do something with it. That first something for us is figuring out the price of the ticket being purchased. Since we just need to read the price data (as opposed to changing the price data), we can just borrow the value with:

let target_ticket = Table::borrow<SeatIdentifier, ConcertTicket>(&venue.available_tickets, &target_seat_id);

Now target_ticket contains a reference to the value that still resides inside the table. It’s helpful to look at the three ways we can look at a value in a table from the Table.move module:

public fun borrow<K: copy + drop, V>(table: &Table<K, V>, key: K): &Vpublic fun borrow_mut<K: copy + drop, V>(table: &mut Table<K, V>, key: K): &mut Vpublic fun remove<K: copy + drop, V>(table: &mut Table<K, V>, key: K): V

If you’ve been working through these and the other Move tutorials, this isn’t new info — but this is a fundamental concept in Move worth revisiting. The first two functions, Table::borrow and Table:borrow_mut both return a reference to the value V either immutable &V or mutable &mut V. Whatever variable we assign that output to has the ability to look at that value or change it as the case may be, but that value V is still owned by the table. The third function Table:remove returns the value V explicitly. That value now lives within that variable. In our function:

let ticket = Table::remove<SeatIdentifier, ConcertTicket>(&mut venue.available_tickets, &target_seat_id);

the variable ticket now holds/owns/contains that ConcertTicket value. We can only perform operations with ticket that our ConcertTicket struct stated abilities allow. To really build fluency in Move, you need to hammer home this concept of ownership and references (which may be a bit foreign to some of us). Pay close attention to parameters & return values because &V vs &mut V vs V makes a pretty big impact as to what is going on with the function/data.

From here, the rest of our function and module is essentially the same as previous episodes — with one major disclaimer: all of the TestCoin code has been changed. As we’ve learned in this Aptos world — things move incredibly fast. We started of with TestCoin and a faucet so we could all start building. The development team has matured that code now into AptosFramework::Coin and AptosFramework::ManagedCoin to provide a common infrastructure and code base so that we can all create coins on the Aptos blockchain that behave and transact in the same way. TestCoin.move is still a thing — but it’s really more just a model now and we can create whatever coins we want.

That new code has necessitated changes in how we initialize our mint, fund and transfer. I’m going to punt on those explanations for this Episode as I’m going to visit that topic separately. However, there is a great intro on www.aptos.dev where you can dive into this directly (likely WAAYYY less verbose than I’ll be) at Your first Coin | Aptos Labs.

Most of the coin related code changes are actually in the module unit testing. A big thanks to MoveKevin#3155 from the Aptos team for his assistance on Discord last night helping me work through initializing a faucet.

Let’s test and publish our code with the Aptos CLI:

% aptos move test

which should return you the success message:

CACHED MoveStdlibCACHED AptosFrameworkBUILDING tutorialsRunning Move unit tests[ PASS    ] 0xe110::Tickets::sender_can_buy_ticketTest result: OK. Total tests: 1; passed: 1; failed: 0{  "Result": "Success"}

So, we’ve got our data in the table — now how do we get it out? I’m going to skip ahead a bit and take a peek at how our Venue resource will look once we publish the module and call our functions (don’t worry — we’ll get to how to do that in a minute). After publishing the module, calling init_venue and create_ticket, we can look at our Venue resource via the API with the url format /accounts/{address}/resources\ (see example here). Looking specifically at the Venue resource, we see:

{"type":"0xb0db254c80d3e747ff9cfc0d68878c27c0365ebec49834e92e27cd398327999e::Tickets::Venue","data":{"available_tickets":{"handle":"73930855317629770285937999552628814464","length":"3"},"max_seats":"50"}}]

This is our Tickets::Venue resource. We can see the struct properties available_tickets and max_seats. The max_seats makes sense, but all we have for our table is a handle and length. Shortly after introducing tables, the Aptos team introduced granular storage. When accessing a resource, the entire resource is loaded. If a resource contains a particularly large table, that could become very expensive in terms of compute and ultimately gas fees. To mitigate this problem, granular storage stores the table, with it’s associated keys and values, elsewhere. The handle is simply a pointer to where that table is stored. To access the data in the table, we can make a POST request to the API, providing the handle as well at the key type, value type and the key itself.

So, the question has been asked in the Aptos Discord, “what if I don’t know the keys?” or “how do I list all the keys that exist in the table?” Well, I hate to tell you, if you don’t have a client side record of the keys you added to the table, those things are goner than your $LUNA bags! (too soon???) You’ve got to have the keys in order to retrieve the values from Table.move.

That seems like it will be a problem. Not to worry, that’s why we have Iterable Tables. Let’s change our use statements in our move Module to use Iterable Tables with:

use AptosFramework::IterableTable::{Self, IterableTable};

and we can quite literally do a search/replace in our code replacing “Table” with “IterableTable”, run our test again, and everything is good. You see, IterableTable.move is just a wrapper of Table.move with the addition of a head and tail property (and some related methods) that let us traverse through the table keys. If we run our model with IterableTable, then our Venue resource will look like:

{"type":"0x79092432fe4a612e0847679da04cb27a412ff040d6956fb2dfb3c7cf3eabc0ef::Tickets::Venue","data":{"available_tickets":{"head":{"vec":[{"row":"A","seat_number":"26"}]},"inner":{"handle":"289142972286243442477484653357973409217","length":"3"},"tail":{"vec":[{"row":"A","seat_number":"28"}]}},"max_seats":"50"}}

We see our same handle and max_seats, but we also have head and tail which contain SeatIdentifier keys. IterableTable adds new functions, borrow_iter and borrow_iter_mut which function pretty much exactly the same as borrow and borrow_mut, but instead of returning just the value, they return the IterableValue:

struct IterableValue<K: copy + store + drop, V: store> has store {   val: V,   prev: Option<K>,   next: Option<K>,}

so we get the V value for the key we’ve queried, plus we get keys for the previous key in our table and the next key in our table. This would allow us to start with a borrow_iter of the head key from the IterableTable struct in our resource, and walk our way through all of they keys until we get to the last key. (The Option<K> type is how Move accounts for optional values — we’ll cover that separately at some point).

This is helpful — but you likely realized that if our table has 100 keys, we’re making 100 API calls to just get a list of the keys. That can be an issue for UI responsiveness. It would be great if we could just get a list of all the keys up front with only one API call. Well, if we need something, we don’t have to wait on the Aptos team to build it. Just for fun, let’s build it ourselves (I’m telling you — you want me at parties). Just like IterableTable was a wrapper to Table, let’s create MapTable.move and add our keys as a property. We can create our MapTable struct as:

struct MapTable<K: copy + store + drop, V: store> has store {   inner: Table<K, MapValue<K, V>>,   head: Option<K>,   tail: Option<K>,   keys: vector<K>}

with keys simply being a vector we can store copies of all our keys. We can add one line of code to pubic fun add as:

Vector::push_back(&mut table.keys, *key);

and everytime we add a new key/value pair, the key will be stored in our vector. I won’t take the time to go through everyline of code, but we also have to remove the key from our vector when we remove a table item, but it’s all pretty straightforward. The full module is in this episode’s repo here. If we run our init_venue and create_ticket using our MapTable, we can this outpout for our Venue resource:

"type":"0xe9821ba7a7f9d6fb8dc3a15daa23a331a454943b0fa6e56b945ab8f73aac7b77::Tickets::Venue","data":{"available_tickets":{"head":{"vec":[{"row":"A","seat_number":"26"}]},"inner":{"handle":"272944073802849437608077849813780304779","length":"3"},"keys":[{"row":"A","seat_number":"26"},{"row":"A","seat_number":"27"},{"row":"A","seat_number":"28"}],"tail":{"vec":[{"row":"A","seat_number":"28"}]}},"max_seats":"50"}}

and here we see the addition of our keys property with a vector of all the keys we added to the table. We can get all of our keys with one API call.

Now, this may or may not be the right solution for your particular application. This is where we start to get into design considerations where we need to balance gas fees with UI responsiveness. With a small number of keys, having a resource vector with all of the keys likely makes sense. If we’re creating a venue with 100,000 seats like a US college football stadium, maybe that doesn’t make sense. If you look at BucketTables, you have further options for speed/cost tradeoffs. The key point is this:

Key Point: the composability and extensibility of the Move language allow us to create specific solutions optimized to our goals with very little additional code.

Given the length of this one already, I’m going to break Tables up into two parts. In Part 2, we’ll deploy our module and call our functions via the relatively new Aptos Typescript API.