Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement rename_all in derive(ValueDeserialize) #1094

Merged
merged 1 commit into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion cynic-parser-deser-macros/src/attributes.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
use syn::{Attribute, Expr, Field, LitStr, Path};

#[derive(Default)]
use crate::renames::RenameAll;

#[derive(Default, Debug)]
pub struct StructAttribute {
pub default: Option<()>,
pub rename_all: Option<RenameAll>,
}

impl StructAttribute {
Expand All @@ -13,9 +16,21 @@ impl StructAttribute {
.map(|attr| {
let mut output = StructAttribute::default();
attr.parse_nested_meta(|meta| {
// Note: If adding an attribute in here don't forget to add it to
// the merge function below
if meta.path.is_ident("default") {
output.default = Some(());
Ok(())
} else if meta.path.is_ident("rename_all") {
let value = meta.value()?;
let rename = value.parse::<LitStr>()?;
output.rename_all = Some(
rename
.value()
.parse()
.map_err(|e| syn::Error::new(rename.span(), e))?,
);
Ok(())
} else {
Err(meta.error("unsupported attribute"))
}
Expand All @@ -28,6 +43,7 @@ impl StructAttribute {

fn merge(mut self, other: Self) -> Self {
self.default = self.default.or(other.default);
self.rename_all = self.rename_all.or(other.rename_all);
self
}

Expand Down Expand Up @@ -59,6 +75,8 @@ impl FieldAttributes {
.iter()
.filter(|attr| attr.path().is_ident("deser"))
.map(|attr| {
// Note: If adding an attribute in here don't forget to add it to
// the merge function below
let mut output = FieldAttributes::default();
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("rename") {
Expand Down
8 changes: 5 additions & 3 deletions cynic-parser-deser-macros/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod attributes;
mod renames;

use attributes::{FieldAttributes, FieldDefault, StructAttribute};
use proc_macro2::TokenStream;
Expand Down Expand Up @@ -75,9 +76,10 @@ fn value_deser_impl(ast: syn::DeriveInput) -> Result<TokenStream, ()> {
let field_name_strings = fields
.iter()
.map(|(field, attrs)| {
proc_macro2::Literal::string(&match &attrs.rename {
Some(rename) => rename.to_string(),
None => field.ident.as_ref().unwrap().to_string(),
proc_macro2::Literal::string(&match (&attrs.rename, struct_attrs.rename_all) {
(Some(rename), _) => rename.to_string(),
(None, Some(rule)) => rule.apply(field.ident.as_ref().unwrap().to_string()),
_ => field.ident.as_ref().unwrap().to_string(),
})
})
.collect::<Vec<_>>();
Expand Down
201 changes: 201 additions & 0 deletions cynic-parser-deser-macros/src/renames.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
use std::str::FromStr;

#[derive(Debug, Clone, Copy)]
/// Rules to rename all fields in an InputObject or variants in an Enum
/// as GraphQL naming conventions usually don't match rust
pub enum RenameAll {
None,
/// For names that are entirely lowercase in GraphQL: `myfield`
Lowercase,
/// For names that are entirely uppercase in GraphQL: `MYFIELD`
Uppercase,
/// For names that are entirely pascal case in GraphQL: `MyField`
PascalCase,
/// For names that are entirely camel case in GraphQL: `myField`
CamelCase,
/// For names that are entirely snake case in GraphQL: `my_field`
SnakeCase,
/// For names that are entirely snake case in GraphQL: `MY_FIELD`
ScreamingSnakeCase,
}

impl RenameAll {
pub(super) fn apply(&self, string: impl AsRef<str>) -> String {
match self {
RenameAll::Lowercase => string.as_ref().to_lowercase(),
RenameAll::Uppercase => string.as_ref().to_uppercase(),
RenameAll::PascalCase => to_pascal_case(string.as_ref()),
RenameAll::CamelCase => to_camel_case(string.as_ref()),
RenameAll::SnakeCase => to_snake_case(string.as_ref()),
RenameAll::ScreamingSnakeCase => to_snake_case(string.as_ref()).to_uppercase(),
RenameAll::None => string.as_ref().to_string(),
}
}
}

impl FromStr for RenameAll {
type Err = String;

fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_ref() {
"none" => Ok(RenameAll::None),
"lowercase" => Ok(RenameAll::Lowercase),
"uppercase" => Ok(RenameAll::Uppercase),
"pascalcase" => Ok(RenameAll::PascalCase),
"camelcase" => Ok(RenameAll::CamelCase),
"snake_case" => Ok(RenameAll::SnakeCase),
"screaming_snake_case" => Ok(RenameAll::ScreamingSnakeCase),
case => {
Err(format!("unknown case: {case}. expected one of lowercase, UPPERCASE, PascalCAse, camelCase, snake_case, SCREAMING_SNAKE_CASE"))
}
}
}
}

pub fn to_snake_case(s: &str) -> String {
let mut buf = String::with_capacity(s.len());
// Setting this to true to avoid adding underscores at the beginning
let mut prev_is_upper = true;
for c in s.chars() {
if c.is_uppercase() && !prev_is_upper {
buf.push('_');
buf.extend(c.to_lowercase());
prev_is_upper = true;
} else if c.is_uppercase() {
buf.extend(c.to_lowercase());
} else {
prev_is_upper = false;
buf.push(c);
}
}
buf
}

pub fn to_pascal_case(s: &str) -> String {
let mut buf = String::with_capacity(s.len());
let mut first_char = true;
let mut prev_is_upper = false;
let mut prev_is_underscore = false;
let mut chars = s.chars().peekable();
loop {
let c = chars.next();
if c.is_none() {
break;
}
let c = c.unwrap();
if first_char {
if c == '_' {
// keep leading underscores
buf.push('_');
while let Some('_') = chars.peek() {
buf.push(chars.next().unwrap());
}
} else if c.is_uppercase() {
prev_is_upper = true;
buf.push(c);
} else {
buf.extend(c.to_uppercase());
}
first_char = false;
continue;
}

if c.is_uppercase() {
if prev_is_upper {
buf.extend(c.to_lowercase());
} else {
buf.push(c);
}
prev_is_upper = true;
} else if c == '_' {
prev_is_underscore = true;
prev_is_upper = false;
} else {
if prev_is_upper {
buf.extend(c.to_lowercase())
} else if prev_is_underscore {
buf.extend(c.to_uppercase());
} else {
buf.push(c);
}
prev_is_upper = false;
prev_is_underscore = false;
}
}

buf
}

pub(super) fn to_camel_case(s: &str) -> String {
let s = to_pascal_case(s);

let mut buf = String::with_capacity(s.len());
let mut chars = s.chars();

if let Some(first_char) = chars.next() {
buf.extend(first_char.to_lowercase());
}

buf.extend(chars);

buf
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_underscore() {
assert_eq!(to_snake_case("_hello"), "_hello");
assert_eq!(to_snake_case("_"), "_");
}

#[test]
fn test_to_snake_case() {
assert_eq!(to_snake_case("aString"), "a_string");
assert_eq!(to_snake_case("MyString"), "my_string");
assert_eq!(to_snake_case("my_string"), "my_string");
assert_eq!(to_snake_case("_another_one"), "_another_one");
assert_eq!(to_snake_case("RepeatedUPPERCASE"), "repeated_uppercase");
assert_eq!(to_snake_case("UUID"), "uuid");
}

#[test]
fn test_to_camel_case() {
assert_eq!(to_camel_case("aString"), "aString");
assert_eq!(to_camel_case("MyString"), "myString");
assert_eq!(to_camel_case("my_string"), "myString");
assert_eq!(to_camel_case("_another_one"), "_anotherOne");
assert_eq!(to_camel_case("RepeatedUPPERCASE"), "repeatedUppercase");
assert_eq!(to_camel_case("UUID"), "uuid");
assert_eq!(to_camel_case("__typename"), "__typename");
}

#[test]
fn test_to_pascal_case() {
assert_eq!(to_pascal_case("aString"), "AString");
assert_eq!(to_pascal_case("MyString"), "MyString");
assert_eq!(to_pascal_case("my_string"), "MyString");
assert_eq!(to_pascal_case("_another_one"), "_anotherOne");
assert_eq!(to_pascal_case("RepeatedUPPERCASE"), "RepeatedUppercase");
assert_eq!(to_pascal_case("UUID"), "Uuid");
assert_eq!(to_pascal_case("CREATED_AT"), "CreatedAt");
assert_eq!(to_pascal_case("__typename"), "__typename");
}

#[test]
fn casings_are_not_lossy_where_possible() {
for s in ["snake_case_thing", "snake"] {
assert_eq!(to_snake_case(&to_pascal_case(s)), s);
}

for s in ["PascalCase", "Pascal"] {
assert_eq!(to_pascal_case(&to_snake_case(s)), s);
}

for s in ["camelCase", "camel"] {
assert_eq!(to_camel_case(&to_snake_case(s)), s);
}
}
}
22 changes: 22 additions & 0 deletions cynic-parser-deser/tests/deser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,28 @@ fn test_option_defaults() {
);
}

#[derive(ValueDeserialize)]
struct RenameField {
#[deser(rename = "fooBar")]
foo_bar: usize,
}

#[test]
fn test_field_rename() {
assert_eq!(deser::<RenameField>("@id(fooBar: 1)").unwrap().foo_bar, 1);
}

#[derive(ValueDeserialize)]
#[deser(rename_all = "camelCase")]
struct RenameRule {
foo_bar: usize,
}

#[test]
fn test_rename_rule() {
assert_eq!(deser::<RenameRule>("@id(fooBar: 1)").unwrap().foo_bar, 1);
}

fn deser<T>(input: &str) -> Result<T, cynic_parser_deser::Error>
where
T: ValueDeserializeOwned,
Expand Down
Loading