From cdf14a9a1485248a4fd60c6f648cfb2714fccdf5 Mon Sep 17 00:00:00 2001 From: Kyle Davis Date: Tue, 19 Mar 2024 10:10:39 -0700 Subject: [PATCH] feat: improve tests, docs, as_1 fns (#4) * feat: update tests and docs, add as_1 fns * docs: add metadata to cargo package --- Cargo.toml | 5 +- README.md | 85 ++++++++++++++++++++++++ src/model.rs | 53 ++++++++++++++- tests/main.rs | 71 ++++++++++++++++---- tests/strings.rs | 167 +++++++++++++++++++++++++++++++++++++++++++++++ tests/unions.rs | 64 ++++++++++++++++++ 6 files changed, 429 insertions(+), 16 deletions(-) create mode 100644 tests/strings.rs create mode 100644 tests/unions.rs diff --git a/Cargo.toml b/Cargo.toml index 0060855..27ce93d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,9 +2,12 @@ name = "ragkit_convex_macros" description = "Macros to help make Convex in Rust nice" authors = ["Ragkit "] -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"] } diff --git a/README.md b/README.md index 68fe6d5..171518e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/model.rs b/src/model.rs index 406fe75..eaa4ba7 100644 --- a/src/model.rs +++ b/src/model.rs @@ -104,6 +104,7 @@ impl Parse for ConvexField { impl ConvexField { pub fn print(&self) -> Vec { 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 = Vec::new(); let mut serialize_arms: Vec = Vec::new(); + let mut as_fns: Vec = 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(); diff --git a/tests/main.rs b/tests/main.rs index 2be18fb..c9aaeaf 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -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), + ); +} diff --git a/tests/strings.rs b/tests/strings.rs new file mode 100644 index 0000000..aa09f94 --- /dev/null +++ b/tests/strings.rs @@ -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)); +} diff --git a/tests/unions.rs b/tests/unions.rs new file mode 100644 index 0000000..71927e7 --- /dev/null +++ b/tests/unions.rs @@ -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)); +}