Índices

Los índices son estructuras de claves que permiten iterar sobre claves primarias utilizando información sobre valores. Consideremos un modelo en el que hay varios tokens en el sistema y cada token tiene un propietario único. En este escenario, un propietario debe estar asociado a un token, y los tokens deben poder ser consultados por sus respectivos propietarios.

struct Token {
  pub owner: Addr,
  pub ticker: String
}

Los tokens pueden identificarse mediante una clave autoincrementada, que se utilizará como clave primaria. Esto garantiza que cada ficha sea única.

(TokenPK) -> Token

El índice para el propietario se estructuraría así:

(owner, TokenPK) -> Token

TokenPK apunta a los datos del token, y la clave owner:TokenPK apunta a un token concreto. Con dos accesos a la base de datos, se puede acceder a los datos de los tokens. Para recuperar todos los tokens gestionados por un propietario, podemos ejecutar una consulta de rango de prefijos como se muestra arriba.

pub const TOKENS: Map<U8Key, Token> = Map::new("tokens");
// (owner Address, Token PK) -> u8 key
pub const OWNER_INDEX: Map<(&Addr, U8Key), &[u8]> = Map::new("owner_tokenpk");

Con la información del propietario como clave, se puede acceder fácilmente a los tokens. Sin embargo, cada vez que cambia el estado de TOKENS, el campo del propietario también debe actualizarse en consecuencia.

storage-plus indexing

La solución anterior es funcional, pero subóptima debido al alto nivel de complejidad del código. Aquí es donde entra en juego storage-plus/IndexedMap. IndexedMap es un gestor de almacenamiento que proporciona indexación interna. Ofrece dos tipos de índices: Índices Únicos y Multiíndices.

Índices únicos

Garantizar la unicidad de un campo de datos en una base de datos es un requisito habitual. UniqueIndex es un ayudante de indexación que puede ayudar a conseguir esta funcionalidad."

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Token {
  pub owner: Addr,
  pub ticker: String,
  pub identifier: u8, // <---- unique value
}
// TokenIndexes structs keeps a list of indexers
pub struct TokenIndexes<'a> {
  // token.identifier
  pub identifier: UniqueIndex<'a, U8Key, Token>,
}
// IndexList is just boilerplate code for fetching a struct's indexes
impl<'a> IndexList<Token> for TokenIndexes<'a> {
  fn get_indexes(&'_ self) -> Box<dyn Iterator<Item=&'_ dyn Index<Token>> + '_> {
    let v: Vec<&dyn Index<Token>> = vec![&self.identifier];
    Box::new(v.into_iter())
  }
}
// tokens() is the storage access function.
pub fn tokens<'a>() -> IndexedMap<'a, &'a [u8], Token, TokenIndexes<'a>> {
  let indexes = TokenIndexes {
    identifier: UniqueIndex::new(|d| U8Key::new(d.identifier), "token_identifier"),
  };
  IndexedMap::new(TOKEN_NAMESPACE, indexes)
}

Repasemos el código paso a paso:

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Token {
  pub owner: Addr,
  pub ticker: String,
  pub identifier: u8, // <---- unique value
}

Un Token tiene varios valores, y entre ellos, el identifier es un valor único.

// TokenIndexes structs keeps a list of indexers
pub struct TokenIndexes<'a> {
  // token.identifier
  pub identifier: UniqueIndex<'a, U8Key, Token>,
}

TokenIndexes es una estructura para definir índices de la estructura Token.

impl<'a> IndexList<Token> for TokenIndexes<'a> {
  fn get_indexes(&'_ self) -> Box<dyn Iterator<Item=&'_ dyn Index<Token>> + '_> {
    let v: Vec<&dyn Index<Token>> = vec![&self.identifier];
    Box::new(v.into_iter())
  }
}

IndexList es una interfaz para construir los índices.

pub fn tokens<'a>() -> IndexedMap<'a, &'a [u8], Token, TokenIndexes<'a>> {
  let indexes = TokenIndexes {
    identifier: UniqueIndex::new(|d| U8Key::new(d.identifier), "token_identifier"),
  };
  IndexedMap::new(TOKEN_NAMESPACE, indexes)
}

tokens() es la función de almacenamiento utilizada para construir IndexedMap.

 identifier: UniqueIndex::new( | d| U8Key::new(d.identifier), "token_identifier"),

El código anterior es una función constructora de índices. Construye claves compuestas con la función dada, y acepta una clave para identificar el cubo del índice.

Véase el código de prueba a continuación:

#[test]
fn test_tokens() {
  let mut store = MockStorage::new();
  let owner1 = Addr::unchecked("addr1");
  let ticker1 = "TOKEN1".to_string();
  let token1 = Token {
    owner: owner1.clone(),
    ticker: ticker1,
    identifier: 0,
  };
  let token_id = increment_tokens(store.borrow_mut()).unwrap();
  tokens().save(store.borrow_mut(), &U64Key::from(token_id).joined_key(), &token1).unwrap();
  let ticker2 = "TOKEN2".to_string();
  let token2 = Token {
    owner: owner1.clone(),
    ticker: ticker2,
    identifier: 0,
  };
  let token_id = increment_tokens(store.borrow_mut()).unwrap();
  // identifier clashes, must return error
  tokens().save(store.borrow_mut(), &U64Key::from(token_id).joined_key(), &token1).unwrap();
}

La última línea se bloqueará con un error:

called `Result::unwrap()` on an **Err** value: GenericErr { msg: "Violates unique constraint on index" }
thread 'state::test::test_tokens' panicked at 'called `Result::unwrap()` on an **Err** value: GenericErr { msg: "Violates unique constraint on index" }', src/state.rs:197:90
stack backtrace:

Multiíndices

Los multiíndices se utilizan cuando la estructura está indexada por valores no únicos. Este es un caso del contrato inteligente cw721:

pub struct TokenIndexes<'a> {
  // secondary index by owner address
  // the last U64Key is the primary key which is an auto incremented token counter
  pub owner: MultiIndex<'a, (Vec<u8>, Vec<u8>), Token>,
}
// this may become a macro, not important just boilerplate, builds the list of indexes for later use
impl<'a> IndexList<Token> for TokenIndexes<'a> {
  fn get_indexes(&'_ self) -> Box<dyn Iterator<Item=&'_ dyn Index<Token>> + '_> {
    let v: Vec<&dyn Index<Token>> = vec![&self.owner];
    Box::new(v.into_iter())
  }
}
const TOKEN_NAMESPACE: &str = "tokens";
pub fn tokens<'a>() -> IndexedMap<'a, &'a [u8], Token, TokenIndexes<'a>> {
  let indexes = TokenIndexes {
    owner: MultiIndex::new(
      |d, k| (index_string(d.owner.as_str()), k),
      TOKEN_NAMESPACE,
      "tokens__owner",
    )
  };
  IndexedMap::new(TOKEN_NAMESPACE, indexes)
}

Vemos que el índice propietario es un Multiíndice. Un multiíndice puede tener valores repetidos como claves. Por eso la clave primaria se añade como último elemento de la clave multiíndice. Como su nombre indica, se trata de un índice sobre tokens, por propietario. Dado que un propietario puede tener varios tokens, necesitamos un MultiIndex para poder listar / iterar sobre todos los tokens que tiene un propietario dado.

Es importante tener en cuenta que la clave (y sus componentes, en el caso de una clave combinada) debe implementar el rasgo PrimaryKey. En este ejemplo, tanto la 2-tupla (_, _) como Vec implementan PrimaryKey:

 impl<'a, T: PrimaryKey<'a> + Prefixer<'a>, U: PrimaryKey<'a>> PrimaryKey<'a> for (T, U) {
  type Prefix = T;
  type SubPrefix = ();
  fn key(&self) -> Vec<&[u8]> {
    let mut keys = self.0.key();
    keys.extend(&self.1.key());
    keys
  }
}
 pub fn tokens<'a>() -> IndexedMap<'a, &'a str, TokenInfo, TokenIndexes<'a>> {
  let indexes = TokenIndexes {
    owner: MultiIndex::new(
      |d, k| (Vec::from(d.owner.as_ref()), k),
      "tokens",
      "tokens__owner",
    ),
  };
  IndexedMap::new("tokens", indexes)
}

Durante la creación del índice, debemos suministrar una función de índice por índice.

owner: MultiIndex::new(|d, k| (Vec::from(d.owner.as_ref()), k),

La función de índice se encarga de tomar el valor y la clave primaria (siempre en forma Vec) del mapa original y crear la clave de índice a partir de ellos. Por supuesto, esto requiere que los elementos necesarios para la clave de índice estén presentes en el valor (lo cual es lógico).

Aparte de la función de índice, también tenemos que proporcionar el espacio de nombres de la clave primaria y el espacio de nombres para el nuevo índice.

IndexedMap::new("tokens", indexes)

Aquí, el espacio de nombres de la clave primaria debe coincidir con el utilizado durante la creación del índice. Pasamos nuestros TokenIndexes (como un parámetro de tipo IndexList) como segundo argumento, conectando así el Map subyacente con la clave primaria y los índices definidos.

IndexedMap (y los otros tipos Indexed*) es simplemente una envoltura/extensión de Map que proporciona varias funciones de índice y espacios de nombres para crear índices sobre los datos originales de Map. También implementa la llamada a estas funciones de índice durante el almacenamiento/modificación/eliminación de valores, por lo que puede utilizar simplemente los datos indexados sin preocuparse de los detalles de implementación.

He aquí un ejemplo de cómo utilizar los índices en el código:

#[test]
fn test_tokens() {
  let mut store = MockStorage::new();
  let owner1 = Addr::unchecked("addr1");
  let ticker1 = "TOKEN1".to_string();
  let token1 = Token {
    owner: owner1.clone(),
    ticker: ticker1,
  };
  let ticker2 = "TOKEN2".to_string();
  let token2 = Token {
    owner: owner1.clone(),
    ticker: ticker2,
  };
  let token_id = increment_tokens(store.borrow_mut()).unwrap();
  tokens().save(store.borrow_mut(), &U64Key::from(token_id).joined_key(), &token1).unwrap();
  let token_id = increment_tokens(store.borrow_mut()).unwrap();
  tokens().save(store.borrow_mut(), &U64Key::from(token_id).joined_key(), &token1).unwrap();
  // want to load token using owner1 and ticker1
  let list: Vec<_> = tokens()
    .idx.owner
    .prefix(index_string(owner1.as_str()))
    .range(&store, None, None, Order::Ascending)
    .collect::<StdResult<_>>().unwrap();
  let (_, t) = &list[0];
  assert_eq!(t, &token1);
  assert_eq!(2, list.len());
}

Indexación múltiple compuesta

Consideremos la siguiente situación: tenemos varios lotes almacenados por su ID de lote (numérico), que puede cambiar y debe promocionarse automáticamente tras ser modificado. Queremos procesar todos los lotes pendientes con cualquier estado, desde Pendiente a Promocionado, en función de las interacciones con ellos. Además, cada lote tiene un tiempo de caducidad asociado. Sólo nos interesan los lotes pendientes que ya han caducado, para poder promocionarlos. Para ello, podemos crear un índice sobre los lotes, con una clave compuesta que incluya tanto el estado del lote como su fecha de caducidad. Utilizando esta clave compuesta, podemos excluir tanto los lotes ya promocionados como los pendientes que aún no han caducado.

Para ello, podemos crear el índice, generar la clave compuesta e iterar sobre todos los lotes pendientes que tengan una fecha de caducidad inferior a la actual.

He aquí un ejemplo de cómo hacerlo utilizando la estructura Batch:

/// A Batch is a group of members who got voted in together. We need this to
/// calculate moving from *Paid, Pending Voter* to *Voter*
#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)]
pub struct Batch {
  /// Timestamp (seconds) when all members are no longer pending
  pub grace_ends_at: u64,
  /// How many must still pay in their escrow before the batch is early authorized
  pub waiting_escrow: u32,
  /// All paid members promoted. We do this once when grace ends or waiting escrow hits 0.
  /// Store this one done so we don't loop through that anymore.
  pub batch_promoted: bool,
  /// List of all members that are part of this batch (look up ESCROWS with these keys)
  pub members: Vec<Addr>,
}

Definiciones de IndexedMap:

// We need a secondary index for batches, such that we can look up batches that have
// not been promoted, ordered by expiration (ascending) from now.
// Index: (U8Key/bool: batch_promoted, U64Key: grace_ends_at) -> U64Key: pk
pub struct BatchIndexes<'a> {
  pub promotion_time: MultiIndex<'a, (U8Key, U64Key, U64Key), Batch>,
}
impl<'a> IndexList<Batch> for BatchIndexes<'a> {
  fn get_indexes(&'_ self) -> Box<dyn Iterator<Item=&'_ dyn Index<Batch>> + '_> {
    let v: Vec<&dyn Index<Batch>> = vec![&self.promotion_time];
    Box::new(v.into_iter())
  }
}
pub fn batches<'a>() -> IndexedMap<'a, U64Key, Batch, BatchIndexes<'a>> {
  let indexes = BatchIndexes {
    promotion_time: MultiIndex::new(
      |b: &Batch, pk: Vec<u8>| {
        let promoted = if b.batch_promoted { 1u8 } else { 0u8 };
        (promoted.into(), b.grace_ends_at.into(), pk.into())
      },
      "batch",
      "batch__promotion",
    ),
  };
  IndexedMap::new("batch", indexes)
}

Este ejemplo es similar al anterior. Las únicas diferencias son:

La clave compuesta tiene ahora tres elementos: el estado del lote, la fecha de caducidad y el ID del lote (que es la clave principal de los datos del lote). Estamos utilizando un U64Key para el id de lote / pk. Es sólo por comodidad. También podríamos haber utilizado un Vec.

Ahora, aquí es cómo utilizar los datos indexados:

let batch_map = batches();
// Limit to batches that have not yet been promoted (0), using sub_prefix.
// Iterate which have expired at or less than the current time (now), using a bound.
// These are all eligible for timeout-based promotion
let now = block.time.nanos() / 1_000_000_000;
// as we want to keep the last item (pk) unbounded, we increment time by 1 and use exclusive (below the next tick)
let max_key = (U64Key::from(now + 1), U64Key::from(0)).joined_key();
let bound = Bound::Exclusive(max_key);
let ready = batch_map
              .idx
              .promotion_time
              .sub_prefix(0u8.into())
              .range(storage, None, Some(bound), Order::Ascending)
              .collect::<StdResult<Vec<_ >>>() ?;

Un par de comentarios:

  • joined_key() se utiliza para crear la clave de rango. Esta función de ayuda transforma la clave compuesta (parcial) compuesta por la fecha de caducidad del lote y el ID del lote en un Vec con el formato adecuado. A continuación, se utiliza para crear un límite de rango.

  • sub_prefix() se utiliza para fijar el primer elemento de la clave compuesta (el estado del lote). Esto es necesario, porque prefix() en este caso (una 3-tupla), implica fijar los dos primeros elementos de la clave, y no queremos / necesitamos eso aquí.

  • La iteración procede desde Ninguno hasta la clave vinculada creada a partir de la marca de tiempo actual. De este modo, sólo se enumeran los lotes pendientes pero ya caducados.

Eso es todo. Después de eso, podemos iterar sobre los resultados y cambiar su estado de Pendiente a Promovido, o lo que necesitemos hacer.

Last updated