feat: improve tests, docs, as_1 fns (#4)

* feat: update tests and docs, add as_1 fns

* docs: add metadata to cargo package
This commit is contained in:
Kyle Davis 2024-03-19 10:10:39 -07:00 committed by GitHub
parent 326a37b83c
commit cdf14a9a14
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 429 additions and 16 deletions

View file

@ -2,9 +2,12 @@
name = "ragkit_convex_macros"
description = "Macros to help make Convex in Rust nice"
authors = ["Ragkit <support@ragkit.com>"]
version = "0.0.1"
version = "0.0.2"
edition = "2021"
license = "MIT"
documentation = "https://github.com/ragkit/convex-macros"
repository = "https://github.com/ragkit/convex-macros"
homepage = "https://github.com/ragkit/convex-macros"
[dependencies]
syn = { version = "2.0.53", features = ["full"] }

View file

@ -3,3 +3,88 @@
[![CI Badge](https://github.com/ragkit/convex-macros/actions/workflows/ci.yml/badge.svg)](https://github.com/ragkit/convex-macros/actions/workflows/ci.yml)
Macros to help make Convex in Rust nice
## Installation
```toml
[dependencies]
ragkit_convex_macros = "0.0.2"
# Required by code this macro generates.
anyhow = "1.0.80"
convex = "0.6.0"
serde = "1.0.185"
serde_json = "1.0"
```
## Usage
Create models using the same [Convex validator](https://docs.convex.dev/functions/args-validation#convex-values) syntax as your schema definition.
```rust
convex_model!(User {
_id: v.id("users"),
name: v.string(),
age: v.optional(v.int64()),
platform: v.union(
v.object({
platform: v.literal("google"),
verified: v.boolean(),
}),
v.object({
platform: v.literal("github"),
username: v.string(),
}),
),
});
```
This generates `pub struct User {}` with various methods to convert from [`convex::Value`](https://docs.rs/convex/0.6.0/convex/enum.Value.html) and to [`serde_json::Value`](https://docs.rs/serde_json/latest/serde_json/enum.Value.html).
```rust
let user = User::from_convex_value(&Value::Object(btreemap! {
"_id".into() => Value::String("1234".into()),
"name".into() => Value::String("Alice".into()),
"age".into() => Value::Int64(42),
"platform".into() => Value::Object(btreemap! {
"platform".into() => Value::String("github".into()),
"username".into() => Value::String("alicecodes".into()),
}),
}))
.expect("it should parse");
assert_eq!("1234", user._id);
assert_eq!("alicecodes", user.platform.as_2().unwrap().username);
assert_eq!(
json!({
"_id": "1234",
"name": "Alice",
"age": 42,
"platform": {
"platform": "github",
"username": "alicecodes",
},
}),
json!(user),
);
```
## Features
- `let user = User::from_convex_value(value)?;` to parse a value from Convex client
- `json!(user)` to serialize as json
- Discriminated unions are automatically handled
- Helper functions for each branch are also exposed: `user.platform.as_2()?.username`
## Limitations
- This is experimental and may not be "production quality", use with caution.
- `v.bytes()`, `v.array()`, `v.any()` are not yet supported.
- Field names must be valid Rust identifiers, so keywords like `type` cannot be a field name. Map it to `_type`, `kind`, `t`, etc.
- Union variant names are always named like: `Variant1`, `Variant2`, etc.
- The first acceptable union branch will be used if there are multiples that could validly parse data.
- This package generates code that expects `anyhow`, `convex`, `serde`, and `serde_json` to be available.
# License
MIT

View file

@ -104,6 +104,7 @@ impl Parse for ConvexField {
impl ConvexField {
pub fn print(&self) -> Vec<TokenStream> {
let struct_name = self.name.to_struct_name();
let struct_name_str = struct_name.to_string();
let mut structs = Vec::new();
let mut impls = Vec::new();
let ignore_attributes = quote! {
@ -111,7 +112,7 @@ impl ConvexField {
};
// Note: We need a custom serialize to avoid unions printing as objects.
let enum_struct_attributes = quote! {
#[derive(::serde::Deserialize, Clone, Debug)]
#[derive(::serde::Deserialize, Clone, Debug, PartialEq)]
};
match &self.t {
@ -127,11 +128,15 @@ impl ConvexField {
let mut extract_arms = Vec::new();
let mut json_arms: Vec<TokenStream> = Vec::new();
let mut serialize_arms: Vec<TokenStream> = Vec::new();
let mut as_fns: Vec<TokenStream> = Vec::new();
let mut i = 0;
for t in types {
i += 1;
let branch_name =
Ident::new(format!("Variant{}", i).as_str(), Span::call_site());
let branch_name_str = branch_name.to_string();
let as_name =
Ident::new(format!("as_{}", i).as_str(), Span::call_site());
let full_branch_name = Ident::new(
format!("{}Variant{}", struct_name, i).as_str(),
Span::call_site(),
@ -150,6 +155,19 @@ impl ConvexField {
serialize_arms.push(quote! {
| #struct_name::#branch_name => ().serialize(serializer),
});
as_fns.push(quote! {
pub fn #as_name(&self) -> ::core::result::Result<(), ::anyhow::Error> {
if let #struct_name::#branch_name = self {
::core::result::Result::Ok(())
} else {
::core::result::Result::Err(::anyhow::anyhow!(
"Expected variant {}::{}",
#struct_name_str,
#branch_name_str,
))
}
}
});
} else {
enum_kinds.push(quote! {
#branch_name(#branch_type),
@ -160,6 +178,19 @@ impl ConvexField {
serialize_arms.push(quote! {
| #struct_name::#branch_name(ref value) => value.serialize(serializer),
});
as_fns.push(quote! {
pub fn #as_name(&self) -> ::core::result::Result<#branch_type, ::anyhow::Error> {
if let #struct_name::#branch_name(value) = self {
::core::result::Result::Ok(value.clone())
} else {
::core::result::Result::Err(::anyhow::anyhow!(
"Expected variant {}::{}",
#struct_name_str,
#branch_name_str,
))
}
}
});
}
},
| None => {
@ -172,6 +203,20 @@ impl ConvexField {
serialize_arms.push(quote! {
| #struct_name::#branch_name(ref value) => value.serialize(serializer),
});
// TODO: Probably doing too much cloning.
as_fns.push(quote! {
pub fn #as_name(&self) -> ::core::result::Result<#full_branch_name, ::anyhow::Error> {
if let #struct_name::#branch_name(value) = self {
::core::result::Result::Ok(value.clone())
} else {
::core::result::Result::Err(::anyhow::anyhow!(
"Expected variant {}::{}",
#struct_name_str,
#branch_name_str,
))
}
}
});
},
};
match t {
@ -282,6 +327,10 @@ impl ConvexField {
},
}
}
#(
#as_fns
)*
}
});
@ -334,7 +383,7 @@ impl ConvexField {
#[allow(non_snake_case)]
};
let struct_attributes = quote! {
#[derive(::serde::Serialize, ::serde::Deserialize, Clone, Debug)]
#[derive(::serde::Serialize, ::serde::Deserialize, Clone, Debug, PartialEq)]
};
let mut structs = Vec::new();
let mut rendered_fields = Vec::new();

View file

@ -3,21 +3,21 @@ use maplit::btreemap;
use ragkit_convex_macros::convex_model;
use serde_json::json;
convex_model!(Example {
one: v.id("table"),
two: v.null(),
three: v.int64(),
four: v.number(),
five: v.boolean(),
six: v.string(),
seven: v.literal("seven"),
eight: v.literal(false),
nine: v.literal(9),
ten: v.union(v.string(), v.number()),
});
#[test]
fn example() {
convex_model!(Example {
one: v.id("table"),
two: v.null(),
three: v.int64(),
four: v.number(),
five: v.boolean(),
six: v.string(),
seven: v.literal("seven"),
eight: v.literal(false),
nine: v.literal(9),
ten: v.union(v.string(), v.number()),
});
let convex_data = Value::Object(btreemap! {
"one".into() => Value::String("123fakeid".into()),
"two".into() => Value::Null,
@ -114,3 +114,48 @@ fn example3() {
let actual_json_data = json!(model);
assert_eq!(expected_json_data, actual_json_data);
}
#[test]
fn readme() {
convex_model!(User {
_id: v.id("users"),
name: v.string(),
age: v.optional(v.int64()),
platform: v.union(
v.object({
platform: v.literal("google"),
verified: v.boolean(),
}),
v.object({
platform: v.literal("github"),
username: v.string(),
}),
),
});
let user = User::from_convex_value(&Value::Object(btreemap! {
"_id".into() => Value::String("1234".into()),
"name".into() => Value::String("Alice".into()),
"age".into() => Value::Int64(42),
"platform".into() => Value::Object(btreemap! {
"platform".into() => Value::String("github".into()),
"username".into() => Value::String("alicecodes".into()),
}),
}))
.expect("it should parse");
assert_eq!("1234", user._id);
assert_eq!("alicecodes", user.platform.as_2().unwrap().username);
assert_eq!(
json!({
"_id": "1234",
"name": "Alice",
"age": 42,
"platform": {
"platform": "github",
"username": "alicecodes",
},
}),
json!(user),
);
}

167
tests/strings.rs Normal file
View file

@ -0,0 +1,167 @@
use convex::Value;
use maplit::btreemap;
use ragkit_convex_macros::convex_model;
use serde_json::json;
#[test]
fn basic_string() {
convex_model!(Model { a: v.string() });
let convex_data = Value::Object(btreemap! {
"a".into() => Value::String("apple".into()),
});
let json_data = json!({
"a": "apple",
});
let model = Model::from_convex_value(&convex_data);
assert!(model.is_ok());
let model = model.unwrap();
assert_eq!("apple", model.a);
assert_eq!(json_data, json!(model));
}
#[test]
fn basic_string_negative() {
convex_model!(Model { a: v.string() });
let model = Model::from_convex_value(&Value::Object(btreemap! {
"a".into() => Value::Int64(42),
}));
assert!(model.is_err());
let model = Model::from_convex_value(&Value::Object(btreemap! {
"b".into() => Value::String("apple".into()),
}));
assert!(model.is_err());
}
#[test]
fn basic_id() {
convex_model!(Model { a: v.id("apples") });
let convex_data = Value::Object(btreemap! {
"a".into() => Value::String("1234".into()),
});
let json_data = json!({
"a": "1234",
});
let model = Model::from_convex_value(&convex_data);
assert!(model.is_ok());
let model = model.unwrap();
assert_eq!("1234", model.a);
assert_eq!(json_data, json!(model));
}
#[test]
fn basic_id_negative() {
convex_model!(Model { a: v.id("apples") });
let model = Model::from_convex_value(&Value::Object(btreemap! {
"a".into() => Value::Int64(42),
}));
assert!(model.is_err());
let model = Model::from_convex_value(&Value::Object(btreemap! {
"b".into() => Value::String("apple".into()),
}));
assert!(model.is_err());
}
#[test]
fn basic_string_literal() {
convex_model!(Model { a: v.literal("apples") });
let convex_data = Value::Object(btreemap! {
"a".into() => Value::String("apples".into()),
});
let json_data = json!({
"a": "apples",
});
let model = Model::from_convex_value(&convex_data);
assert!(model.is_ok());
let model = model.unwrap();
assert_eq!("apples", model.a);
assert_eq!(json_data, json!(model));
}
#[test]
fn basic_string_literal_negative() {
convex_model!(Model { a: v.literal("apples") });
let model = Model::from_convex_value(&Value::Object(btreemap! {
"a".into() => Value::String("not-apple".into()),
}));
assert!(model.is_err());
let model = Model::from_convex_value(&Value::Object(btreemap! {
"a".into() => Value::Int64(42),
}));
assert!(model.is_err());
let model = Model::from_convex_value(&Value::Object(btreemap! {
"b".into() => Value::String("apple".into()),
}));
assert!(model.is_err());
}
#[test]
fn basic_optional_string() {
convex_model!(Model { a: v.optional(v.string()) });
let convex_data = Value::Object(btreemap! {
"a".into() => Value::String("apples".into()),
});
let json_data = json!({
"a": "apples",
});
let model = Model::from_convex_value(&convex_data);
assert!(model.is_ok());
let model = model.unwrap();
assert_eq!(Some("apples".into()), model.a);
assert_eq!(json_data, json!(model));
let convex_data = Value::Object(btreemap! {
"a".into() => Value::Null,
});
let json_data = json!({
"a": null,
});
let model = Model::from_convex_value(&convex_data);
assert!(model.is_ok());
let model = model.unwrap();
assert_eq!(None, model.a);
assert_eq!(json_data, json!(model));
}
#[test]
fn basic_string_union() {
convex_model!(Model { a: v.union(v.literal("apples"), v.literal("banana")) });
let convex_data = Value::Object(btreemap! {
"a".into() => Value::String("apples".into()),
});
let json_data = json!({
"a": "apples",
});
let model = Model::from_convex_value(&convex_data);
assert!(model.is_ok());
let model = model.unwrap();
assert_eq!(ModelA::Variant1("apples".into()), model.a);
assert_eq!(json_data, json!(model));
let convex_data = Value::Object(btreemap! {
"a".into() => Value::String("banana".into()),
});
let json_data = json!({
"a": "banana",
});
let model = Model::from_convex_value(&convex_data);
assert!(model.is_ok());
let model = model.unwrap();
assert_eq!(ModelA::Variant2("banana".into()), model.a);
assert_eq!(json_data, json!(model));
}

64
tests/unions.rs Normal file
View file

@ -0,0 +1,64 @@
use convex::Value;
use maplit::btreemap;
use ragkit_convex_macros::convex_model;
use serde_json::json;
#[test]
fn basic_discriminated() {
convex_model!(Model {
a: v.union(
v.object({
t: v.literal("one"),
value: v.int64(),
}),
v.object({
t: v.literal("two"),
value: v.string(),
}),
),
});
// Try variant 1
let convex_data = Value::Object(btreemap! {
"a".into() => Value::Object(btreemap! {
"t".into() => Value::String("one".into()),
"value".into() => Value::Int64(42),
}),
});
let json_data = json!({
"a": {
"t": "one",
"value": 42,
},
});
let model = Model::from_convex_value(&convex_data);
assert!(model.is_ok());
let model = model.unwrap();
let a = model.a.as_1().unwrap();
assert_eq!("one", a.t);
assert_eq!(42, a.value);
assert_eq!(json_data, json!(model));
// Try Variant 2
let convex_data = Value::Object(btreemap! {
"a".into() => Value::Object(btreemap! {
"t".into() => Value::String("two".into()),
"value".into() => Value::String("something".into()),
}),
});
let json_data = json!({
"a": {
"t": "two",
"value": "something",
},
});
let model = Model::from_convex_value(&convex_data);
assert!(model.is_ok());
let model = model.unwrap();
let a = model.a.as_2().unwrap();
assert_eq!("two", a.t);
assert_eq!("something", a.value);
assert_eq!(json_data, json!(model));
}