51 个版本 (5 个稳定版)

2.0.0 2024年3月14日
2.0.0-beta.02024年1月23日
1.2.0 2023年11月14日
1.1.0 2023年6月13日
0.3.2 2020年10月28日

#1462 in 魔法豆

Download history 13435/week @ 2024-04-22 14743/week @ 2024-04-29 13063/week @ 2024-05-06 14083/week @ 2024-05-13 15037/week @ 2024-05-20 14392/week @ 2024-05-27 15806/week @ 2024-06-03 15515/week @ 2024-06-10 14967/week @ 2024-06-17 14398/week @ 2024-06-24 10938/week @ 2024-07-01 11771/week @ 2024-07-08 15719/week @ 2024-07-15 13911/week @ 2024-07-22 13597/week @ 2024-07-29 11877/week @ 2024-08-05

55,784 次每月下载
用于 518 个crate (357直接使用)

Apache-2.0

365KB
8K SLoC

cw-storage-plus: 为CosmWasm提供的存储抽象

该库已在许多生产质量合约中得到广泛使用。代码已证明其稳定性与强大功能。尚未经过审计,Confio不承担任何责任,但我们认为它足够成熟,可以成为您合约的 标准存储层

使用概述

我们引入了两个主要类来在 cosmwasm_std::Storage 上提供高效抽象。它们是 Item,它是对一个数据库键的封装,提供了一些辅助函数,以与它交互而不需要处理原始字节。还有 Map,允许你在前缀下存储多个唯一的类型对象,通过简单的或复合(例如,(&[u8], &[u8]))主键索引。

Item

使用一个 Item 的方法非常简单。您只需提供正确的类型以及一个未被任何其他项目使用的数据库键。然后它将为您提供与这类数据交互的便捷接口。

如果您之前使用过 Singleton,最大的变化是我们不再在内部存储 Storage,这意味着我们不需要对象的读写变体,只需要一种类型。此外,我们使用 const fn 来创建 Item,允许其作为一个全局编译时常量来定义,而不是每次都必须构造的函数,这既节省了气体又节省了输入。

示例用法

#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct Config {
    pub owner: String,
    pub max_tokens: i32,
}

// note const constructor rather than 2 functions with Singleton
const CONFIG: Item<Config> = Item::new("config");

fn demo() -> StdResult<()> {
    let mut store = MockStorage::new();

    // may_load returns Option<T>, so None if data is missing
    // load returns T and Err(StdError::NotFound{}) if data is missing
    let empty = CONFIG.may_load(&store)?;
    assert_eq!(None, empty);
    let cfg = Config {
        owner: "admin".to_string(),
        max_tokens: 1234,
    };
    CONFIG.save(&mut store, &cfg)?;
    let loaded = CONFIG.load(&store)?;
    assert_eq!(cfg, loaded);

    // update an item with a closure (includes read and write)
    // returns the newly saved value
    let output = CONFIG.update(&mut store, |mut c| -> StdResult<_> {
        c.max_tokens *= 2;
        Ok(c)
    })?;
    assert_eq!(2468, output.max_tokens);

    // you can error in an update and nothing is saved
    let failed = CONFIG.update(&mut store, |_| -> StdResult<_> {
        Err(StdError::generic_err("failure mode"))
    });
    assert!(failed.is_err());

    // loading data will show the first update was saved
    let loaded = CONFIG.load(&store)?;
    let expected = Config {
        owner: "admin".to_string(),
        max_tokens: 2468,
    };
    assert_eq!(expected, loaded);

    // we can remove data as well
    CONFIG.remove(&mut store);
    let empty = CONFIG.may_load(&store)?;
    assert_eq!(None, empty);

    Ok(())
}

映射

Map 的使用稍微复杂一些,但仍然非常简单。您可以将其想象为一个基于存储的 BTreeMap,允许具有类型值的键值查找。此外,我们不仅支持简单的二进制键(如 &[u8]),还支持组合元组。这允许我们通过示例将津贴作为复合键存储,即 (owner, spender) 来查找余额。

除了直接查找之外,我们还有一种在以太坊中找不到的超能力——迭代。没错,您可以在一个 Map 中列出所有项,或者只列出其中的一部分。我们还可以高效地允许对这些项进行分页,从上次查询结束的点开始,以低气体成本。这需要在 cw-storage-plus 中启用 iterator 功能(它在 cosmwasm-std 中也自动启用,并且默认启用)。

如果您之前使用过 Bucket,最大的变化是我们不再在内部存储 Storage,这意味着我们不需要对象的读写变体,只需要一种类型。此外,我们使用 const fn 来创建 Bucket,允许其作为一个全局编译时常量来定义,而不是每次都必须构造的函数,这既节省了气体又节省了输入。此外,复合索引(元组)更符合人体工程学且更能表达意图,范围接口也得到了改进。

以下是一个使用正常(简单)键的示例

#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
struct Data {
    pub name: String,
    pub age: i32,
}

const PEOPLE: Map<&str, Data> = Map::new("people");

fn demo() -> StdResult<()> {
    let mut store = MockStorage::new();
    let data = Data {
        name: "John".to_string(),
        age: 32,
    };

    // load and save with extra key argument
    let empty = PEOPLE.may_load(&store, "john")?;
    assert_eq!(None, empty);
    PEOPLE.save(&mut store, "john", &data)?;
    let loaded = PEOPLE.load(&store, "john")?;
    assert_eq!(data, loaded);

    // nothing on another key
    let missing = PEOPLE.may_load(&store, "jack")?;
    assert_eq!(None, missing);

    // update function for new or existing keys
    let birthday = |d: Option<Data>| -> StdResult<Data> {
        match d {
            Some(one) => Ok(Data {
                name: one.name,
                age: one.age + 1,
            }),
            None => Ok(Data {
                name: "Newborn".to_string(),
                age: 0,
            }),
        }
    };

    let old_john = PEOPLE.update(&mut store, "john", birthday)?;
    assert_eq!(33, old_john.age);
    assert_eq!("John", old_john.name.as_str());

    let new_jack = PEOPLE.update(&mut store, "jack", birthday)?;
    assert_eq!(0, new_jack.age);
    assert_eq!("Newborn", new_jack.name.as_str());

    // update also changes the store
    assert_eq!(old_john, PEOPLE.load(&store, "john")?);
    assert_eq!(new_jack, PEOPLE.load(&store, "jack")?);

    // removing leaves us empty
    PEOPLE.remove(&mut store, "john");
    let empty = PEOPLE.may_load(&store, "john")?;
    assert_eq!(None, empty);

    Ok(())
}

键类型

Map 的键可以是实现了 PrimaryKey 特性的任何内容。已经提供了一系列 PrimaryKey 的实现(请参阅 keys.rs

  • impl<'a> PrimaryKey<'a> for &'a [u8]
  • impl<'a> PrimaryKey<'a> for &'a str
  • impl<'a> PrimaryKey<'a> for Vec<u8>
  • impl<'a> PrimaryKey<'a> for String
  • impl<'a> PrimaryKey<'a> for Addr
  • impl<'a, constN: usize> PrimaryKey<'a> for [u8; N]
  • impl<'a, T:Prefixer<'a>> Prefixer<'a> for &'a T
  • impl<'a, T:PrimaryKey<'a> +Prefixer<'a>, U:PrimaryKey<'a>> PrimaryKey<'a> for (T, U)
  • impl<'a, T:PrimaryKey<'a> +Prefixer<'a>, U:PrimaryKey<'a> +Prefixer<'a>, V:PrimaryKey<'a>> PrimaryKey<'a> for (T, U, V)
  • PrimaryKey 为无符号整数实现了,上限为 u128
  • PrimaryKey 为有符号整数实现了,上限为 i128

这意味着字节和字符串切片、字节向量和字符串都可以方便地用作键。此外,还可以使用一些其他类型,例如地址和地址引用、成对和三重组合,以及整数类型。

如果键代表一个地址,我们建议在存储中使用 &Addr 作为键,而不是 String 或字符串切片。这意味着通过 addr_validate 在通过消息传入的任何地址上进行地址验证,以确保它是有效的地址,而不是后来会失败的随机文本。在 deps.api 中的 pub fn addr_validate(&self, &str) -> Addr 可以用于地址验证,然后返回的 Addr 可以方便地用作 Map 或类似结构中的键。

使用引用(即借用值)而不是值作为键(即 &Addr 而不是 Addr),通常可以节省一些在键的读取/写入过程中的克隆操作。

复合键

有时我们希望使用多个项作为键。例如,当根据账户所有者和支出者存储津贴时。我们可以在调用之前尝试手动连接它们,但这可能导致冲突,而且对我们来说有点底层。另外,通过明确分离键,我们可以轻松提供辅助工具来进行前缀范围查询,例如“显示某个所有者的所有津贴”(复合键的第一部分)。就像你从你最喜欢的数据库中期望的那样。

以下是使用复合键的方法。只需定义一个元组作为键,并在使用单个键的任何地方使用它。

// Note the tuple for primary key. We support one slice, or a 2 or 3-tuple.
// Adding longer tuples is possible, but unlikely to be needed.
const ALLOWANCE: Map<(&str, &str), u64> = Map::new("allow");

fn demo() -> StdResult<()> {
    let mut store = MockStorage::new();

    // save and load on a composite key
    let empty = ALLOWANCE.may_load(&store, ("owner", "spender"))?;
    assert_eq!(None, empty);
    ALLOWANCE.save(&mut store, ("owner", "spender"), &777)?;
    let loaded = ALLOWANCE.load(&store, ("owner", "spender"))?;
    assert_eq!(777, loaded);

    // doesn't appear under other key (even if a concat would be the same)
    let different = ALLOWANCE.may_load(&store, ("owners", "pender")).unwrap();
    assert_eq!(None, different);

    // simple update
    ALLOWANCE.update(&mut store, ("owner", "spender"), |v| {
        Ok(v.unwrap_or_default() + 222)
    })?;
    let loaded = ALLOWANCE.load(&store, ("owner", "spender"))?;
    assert_eq!(999, loaded);

    Ok(())
}

路径

在幕后,当访问键时,我们会从 Map 创建一个 Path。例如,PEOPLE.load(&store, "jack") == PEOPLE.key("jack").load()Map.key() 返回一个 Path,它具有与 Item 相同的接口,重新使用计算出的路径到这个键。

对于简单键,这只是一个更少的输入和更少的气体,如果你为许多调用使用相同的键。然而,对于复合键,例如 ("owner", "spender"),这将大大减少输入。强烈建议在任何需要使用复合键的地方,即使只使用两次。

#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
struct Data {
    pub name: String,
    pub age: i32,
}

const PEOPLE: Map<&str, Data> = Map::new("people");
const ALLOWANCE: Map<(&str, &str), u64> = Map::new("allow");

fn demo() -> StdResult<()> {
    let mut store = MockStorage::new();
    let data = Data {
        name: "John".to_string(),
        age: 32,
    };

    // create a Path one time to use below
    let john = PEOPLE.key("john");

    // Use this just like an Item above
    let empty = john.may_load(&store)?;
    assert_eq!(None, empty);
    john.save(&mut store, &data)?;
    let loaded = john.load(&store)?;
    assert_eq!(data, loaded);
    john.remove(&mut store);
    let empty = john.may_load(&store)?;
    assert_eq!(None, empty);

    // Same for composite keys, just use both parts in `key()`.
    // Notice how much less verbose than the above example.
    let allow = ALLOWANCE.key(("owner", "spender"));
    allow.save(&mut store, &1234)?;
    let loaded = allow.load(&store)?;
    assert_eq!(1234, loaded);
    allow.update(&mut store, |x| Ok(x.unwrap_or_default() * 2))?;
    let loaded = allow.load(&store)?;
    assert_eq!(2468, loaded);

    Ok(())
}

前缀

除了从映射中获取特定项外,我们还可以遍历映射(或映射的子集)。这使我们能够回答诸如“显示所有令牌”等问题,并提供了一些便捷的Bound辅助函数,以便轻松实现分页或自定义范围。

通用格式是调用map.prefix(k)来获取Prefix,其中k比正常键少一个项(如果map.key()获取了[u8], &[u8]),那么map.prefix()将获取&[u8]。如果map.key()获取了&[u8],那么map.prefix()将获取())。一旦我们有了前缀空间,我们就可以通过range(store, min, max, order)来遍历所有项。它支持Order::Ascending(升序)或Order::Descending(降序)。min是下界,而max是上界。

如果minmax界限是None,则range将返回前缀下的所有项。您可以使用.take(n)来限制结果为n个项并开始分页。您还可以将min界限设置为例如Bound::exclusive(last_value)以从最后一个值开始迭代所有项。与take结合使用,我们很容易实现分页支持。您还可以在想要包含任何完美匹配时使用Bound::inclusive(x)

Bound

Bound是构建类型安全的键或子键迭代界限的辅助工具。它还支持原始(Vec<u8>)界限规范,用于您不希望或无法使用类型界限的情况。

#[derive(Clone, Debug)]
pub enum Bound<'a, K: PrimaryKey<'a>> {
  Inclusive((K, PhantomData<&'a bool>)),
  Exclusive((K, PhantomData<&'a bool>)),
  InclusiveRaw(Vec<u8>),
  ExclusiveRaw(Vec<u8>),
}

为了更好地理解API,请查看以下示例

#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
struct Data {
    pub name: String,
    pub age: i32,
}

const PEOPLE: Map<&str, Data> = Map::new("people");
const ALLOWANCE: Map<(&str, &str), u64> = Map::new("allow");

fn demo() -> StdResult<()> {
    let mut store = MockStorage::new();

    // save and load on two keys
    let data = Data { name: "John".to_string(), age: 32 };
    PEOPLE.save(&mut store, "john", &data)?;
    let data2 = Data { name: "Jim".to_string(), age: 44 };
    PEOPLE.save(&mut store, "jim", &data2)?;

    // iterate over them all
    let all: StdResult<Vec<_>> = PEOPLE
        .range(&store, None, None, Order::Ascending)
        .collect();
    assert_eq!(
        all?,
        vec![("jim".to_vec(), data2), ("john".to_vec(), data.clone())]
    );

    // or just show what is after jim
    let all: StdResult<Vec<_>> = PEOPLE
        .range(
            &store,
            Some(Bound::exclusive("jim")),
            None,
            Order::Ascending,
        )
        .collect();
    assert_eq!(all?, vec![("john".to_vec(), data)]);

    // save and load on three keys, one under different owner
    ALLOWANCE.save(&mut store, ("owner", "spender"), &1000)?;
    ALLOWANCE.save(&mut store, ("owner", "spender2"), &3000)?;
    ALLOWANCE.save(&mut store, ("owner2", "spender"), &5000)?;

    // get all under one key
    let all: StdResult<Vec<_>> = ALLOWANCE
        .prefix("owner")
        .range(&store, None, None, Order::Ascending)
        .collect();
    assert_eq!(
        all?,
        vec![("spender".to_vec(), 1000), ("spender2".to_vec(), 3000)]
    );

    // Or ranges between two items (even reverse)
    let all: StdResult<Vec<_>> = ALLOWANCE
        .prefix("owner")
        .range(
            &store,
            Some(Bound::exclusive("spender")),
            Some(Bound::inclusive("spender2")),
            Order::Descending,
        )
        .collect();
    assert_eq!(all?, vec![("spender2".to_vec(), 3000)]);

    Ok(())
}

注意:关于在MultiIndex上定义和使用类型安全的界限,请参阅下文中的类型安全的MultiIndex界限

IndexedMap

让我们通过一个例子来查看IndexedMap的定义和使用,这个例子最初来自cw721-base合约。

定义

pub struct TokenIndexes<'a> {
  pub owner: MultiIndex<'a, Addr, TokenInfo, String>,
}

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

pub fn tokens<'a>() -> IndexedMap<'a, &'a str, TokenInfo, TokenIndexes<'a>> {
  let indexes = TokenIndexes {
    owner: MultiIndex::new(
      |d: &TokenInfo| d.owner.clone(),
      "tokens",
      "tokens__owner",
    ),
  };
  IndexedMap::new("tokens", indexes)
}

让我们逐一讨论

pub struct TokenIndexes<'a> {
  pub owner: MultiIndex<'a, Addr, TokenInfo, String>,
}

这些是索引定义。这里只有一个索引,称为owner。可能会有更多,作为TokenIndexes结构体的公共成员。我们看到owner索引是一个MultiIndex。多索引可以作为键值重复。主键在内部作为多索引键的最后一个元素使用,以区分重复的索引值。正如其名所示,这是一个按所有者对代币进行索引的索引。鉴于一个所有者可以有多个代币,我们需要一个MultiIndex来列出/遍历他拥有的所有代币。

TokenInfo数据最初将按token_id(这是一个字符串值)存储。您可以在创建代币的代码中看到这一点

    tokens().update(deps.storage, &msg.token_id, |old| match old {
        Some(_) => Err(ContractError::Claimed {}),
        None => Ok(token),
    })?;

(顺便提一下,这里使用update而不是save,以避免覆盖已存在的代币)。

由于token_id是一个字符串值,我们指定String作为MultiIndex定义的最后一个参数。这样,主键的反序列化将完成到正确的类型(一个拥有字符串)。

注意:在特定情况下,对于MultiIndex和类型安全界限的最新实现,最后一个类型参数的定义对于正确使用类型安全界限至关重要。请参阅下文中的类型安全的MultiIndex界限

然后,此TokenInfo数据将通过代币所有者owner(这是一个Addr)进行索引。这样我们就可以列出所有者拥有的所有代币。这就是为什么owner索引键是Addr的原因。

这里另一个重要的事情是,键(及其组件,在复合键的情况下)必须实现PrimaryKey特质。您可以看到Addr实现了PrimaryKey

impl<'a> PrimaryKey<'a> for Addr {
  type Prefix = ();
  type SubPrefix = ();
  type Suffix = Self;
  type SuperSuffix = Self;

  fn key(&self) -> Vec<Key> {
    // this is simple, we don't add more prefixes
    vec![Key::Ref(self.as_bytes())]
  }
}

现在我们可以通过查看剩余的代码来了解它是如何工作的

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

这为TokenIndexes实现了IndexList特质。

注意:此代码几乎是模板化的,并且对于内部需要。不要尝试自定义此代码;只需返回所有索引的列表。实现此特质有两个目的(实际上是一个目的):它允许通过get_indexes查询索引,并允许TokenIndexes被视为一个IndexList。这样,它就可以在IndexedMap构造函数下作为参数传递

pub fn tokens<'a>() -> IndexedMap<'a, &'a str, TokenInfo, TokenIndexes<'a>> {
    let indexes = TokenIndexes {
        owner: MultiIndex::new(
            |d: &TokenInfo| d.owner.clone(),
            "tokens",
            "tokens__owner",
        ),
    };
    IndexedMap::new("tokens", indexes)
}

在这里tokens()只是一个辅助函数,它简化了我们的IndexedMap构造。首先创建索引(es),然后创建并返回IndexedMap

在创建索引的过程中,我们必须为每个索引提供一个索引函数

        owner: MultiIndex::new(|d: &TokenInfo| d.owner.clone(),

这是将原始映射的值取出并从中创建索引键的那个。当然,这要求索引键所需元素必须存在于值中。除了索引函数外,我们还必须提供pk的命名空间和新的索引的命名空间。


之后,我们只需创建并返回 IndexedMap

    IndexedMap::new("tokens", indexes)

当然,这里的pk命名空间必须与创建索引时使用的命名空间相匹配。此外,我们将我们的 TokenIndexes(作为 IndexList-类型参数)作为第二个参数传递。以这种方式连接底层 Map 的pk,与定义的索引。

因此,IndexedMap(以及其他 Indexed* 类型)只是 Map 的包装/扩展,提供了一些索引函数和命名空间来在原始 Map 数据上创建索引。它还实现了在值存储/更新/删除期间调用这些索引函数,这样您就可以忘记它,只需使用索引数据。

用法

使用示例,其中 owner 是作为参数传递的 String 值,而 start_afterlimit 可选地定义了分页范围

注意这里使用了 prefix(),如上文所述的 Map 部分。

    let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize;
    let start = start_after.map(Bound::exclusive);
    let owner_addr = deps.api.addr_validate(&owner)?;

    let res: Result<Vec<_>, _> = tokens()
        .idx
        .owner
        .prefix(owner_addr)
        .range(deps.storage, start, None, Order::Ascending)
        .take(limit)
        .collect();
    let tokens = res?;

现在 tokens 包含了给定 owner(token_id, TokenInfo) 对。在 range_raw() 的情况下,pk值是 Vec<u8>,但将使用 range() 反序列化为正确的类型;前提是pk反序列化类型(在本例中是 String)已在 MultiIndex 定义中正确指定(见下文 索引键反序列化)。

另一个类似的示例,但只返回(原始)token_id,使用 keys_raw() 方法

    let pks: Vec<_> = tokens()
        .idx
        .owner
        .prefix(owner_addr)
        .keys_raw(
            deps.storage,
            start,
            None,
            Order::Ascending,
        )
        .take(limit)
        .collect();

现在 pks 包含了给定 ownertoken_id 值(作为原始 Vec<u8>)。通过使用 keys,可以获取反序列化的键,如下一节所述。

索引键反序列化

对于 UniqueIndexMultiIndex,需要指定主键(PK)类型,以便将主键反序列化到它。此 PK 类型指定对于 MultiIndex 类型安全的范围也很重要,因为主键是多索引键的一部分。参见下一节,多索引的类型安全范围

注意:此规范仍然是一个手动(因此容易出错)的过程/设置,将来(如果可能)将自动化(https://github.com/CosmWasm/cw-plus/issues/531)。

多索引的类型安全范围

MultiIndex的特定情况下,主键(PK)类型参数也定义了索引键(即与主键对应的部分)的(部分)边界类型。因此,为了正确使用多索引范围的类型安全边界,确保这个PK类型被正确定义是至关重要的,使其与主键类型或其(通常是拥有的)反序列化变体相匹配。

Deque

Deque的使用相当直接。从概念上讲,它类似于Rust std的存储后端版本的Deque,可以用作队列或栈。它允许你在两端推送和弹出元素,也可以读取第一个或最后一个元素而不修改deque。你还可以直接读取特定索引。

示例用法

#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
struct Data {
    pub name: String,
    pub age: i32,
}

const DATA: Deque<Data> = Deque::new("data");

fn demo() -> StdResult<()> {
    let mut store = MockStorage::new();

    // read methods return a wrapped Option<T>, so None if the deque is empty
    let empty = DATA.front(&store)?;
    assert_eq!(None, empty);

    // some example entries
    let p1 = Data {
        name: "admin".to_string(),
        age: 1234,
    };
    let p2 = Data {
        name: "user".to_string(),
        age: 123,
    };

    // use it like a queue by pushing and popping at opposite ends
    DATA.push_back(&mut store, &p1)?;
    DATA.push_back(&mut store, &p2)?;

    let admin = DATA.pop_front(&mut store)?;
    assert_eq!(admin.as_ref(), Some(&p1));
    let user = DATA.pop_front(&mut store)?;
    assert_eq!(user.as_ref(), Some(&p2));

    // or push and pop at the same end to use it as a stack
    DATA.push_back(&mut store, &p1)?;
    DATA.push_back(&mut store, &p2)?;

    let user = DATA.pop_back(&mut store)?;
    assert_eq!(user.as_ref(), Some(&p2));
    let admin = DATA.pop_back(&mut store)?;
    assert_eq!(admin.as_ref(), Some(&p1));

    // you can also iterate over it
    DATA.push_front(&mut store, &p1)?;
    DATA.push_front(&mut store, &p2)?;

    let all: StdResult<Vec<_>> = DATA.iter(&store)?.collect();
    assert_eq!(all?, [p2, p1]);

    // or access an index directly
    assert_eq!(DATA.get(&store, 0)?, Some(p2));
    assert_eq!(DATA.get(&store, 1)?, Some(p1));
    assert_eq!(DATA.get(&store, 3)?, None);

    Ok(())
}

依赖项

~3.5–7MB
~142K SLoC