From 0e02211bfc0a9272dfdfa7d07a6c57c3c969e82e Mon Sep 17 00:00:00 2001 From: Miles Granger Date: Wed, 18 Mar 2026 08:37:47 +0100 Subject: [PATCH 1/3] Fix raw identifiers in collection schema --- typesense/tests/derive/collection.rs | 61 +++++++++++++++++++++++- typesense_derive/src/field_attributes.rs | 6 +-- typesense_derive/src/helpers.rs | 6 +++ typesense_derive/src/lib.rs | 2 +- 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/typesense/tests/derive/collection.rs b/typesense/tests/derive/collection.rs index ca8c2c33..ad986d33 100644 --- a/typesense/tests/derive/collection.rs +++ b/typesense/tests/derive/collection.rs @@ -260,7 +260,66 @@ fn derived_document_handles_nested_and_flattened_fields() { assert_eq!(serde_json::to_value(schema).unwrap(), expected); } -// Test 4: All Boolean Shorthand Attributes +// Test 4: Raw Identifiers +// +// Rust raw identifiers (r#type, r#abstract, etc.) must have the r# prefix +// stripped in all generated output: regular field names, flattened prefixes, +// and default_sorting_field validation. + +#[allow(dead_code)] +#[derive(Typesense, Serialize, Deserialize)] +#[typesense(collection_name = "raw_ident_docs", default_sorting_field = "type")] +struct RawIdentDoc { + id: String, + #[typesense(type = "int32")] + r#type: String, + r#abstract: Option, + normal_field: i32, +} + +#[derive(Typesense, Serialize, Deserialize)] +struct RawIdentNested { + value: String, +} + +#[allow(dead_code)] +#[derive(Typesense, Serialize, Deserialize)] +#[typesense(collection_name = "raw_ident_flat")] +struct RawIdentFlat { + id: String, + #[typesense(flatten, skip)] + r#match: RawIdentNested, +} + +#[test] +fn derived_document_strips_raw_identifier_prefix() { + // Regular fields: r#type -> "type", r#abstract -> "abstract" + let schema = RawIdentDoc::collection_schema(); + let expected = json!({ + "name": "raw_ident_docs", + "default_sorting_field": "type", + "fields": [ + { "name": "id", "type": "string" }, + { "name": "type", "type": "int32" }, + { "name": "abstract", "type": "string", "optional": true }, + { "name": "normal_field", "type": "int32" } + ] + }); + assert_eq!(serde_json::to_value(schema).unwrap(), expected); + + // Flattened prefix: r#match -> "match.value" + let schema = RawIdentFlat::collection_schema(); + let expected = json!({ + "name": "raw_ident_flat", + "fields": [ + { "name": "id", "type": "string" }, + { "name": "match.value", "type": "string" } + ] + }); + assert_eq!(serde_json::to_value(schema).unwrap(), expected); +} + +// Test 5: All Boolean Shorthand Attributes #[allow(dead_code)] #[derive(Typesense, Serialize, Deserialize)] diff --git a/typesense_derive/src/field_attributes.rs b/typesense_derive/src/field_attributes.rs index b1f455e5..e7c06e23 100644 --- a/typesense_derive/src/field_attributes.rs +++ b/typesense_derive/src/field_attributes.rs @@ -1,4 +1,4 @@ -use crate::{bool_literal, get_inner_type, i32_literal, skip_eq, string_literal, ty_inner_type}; +use crate::{bool_literal, get_inner_type, i32_literal, skip_eq, string_literal, strip_raw_prefix, ty_inner_type}; use proc_macro2::TokenTree; use quote::quote; use syn::{Attribute, Field}; @@ -271,7 +271,7 @@ fn build_regular_field(field: &Field, field_attrs: &FieldAttributes) -> proc_mac let field_name = if let Some(rename) = &field_attrs.rename { quote! { #rename } } else { - let name_ident = field.ident.as_ref().unwrap().to_string(); + let name_ident = strip_raw_prefix(&field.ident.as_ref().unwrap().to_string()); quote! { #name_ident } }; @@ -322,7 +322,7 @@ pub(crate) fn process_field( let prefix = if let Some(rename_prefix) = &field_attrs.rename { quote! { #rename_prefix } } else { - let name_ident = field.ident.as_ref().unwrap().to_string(); + let name_ident = strip_raw_prefix(&field.ident.as_ref().unwrap().to_string()); quote! { #name_ident } }; diff --git a/typesense_derive/src/helpers.rs b/typesense_derive/src/helpers.rs index 337fc11f..d99199f7 100644 --- a/typesense_derive/src/helpers.rs +++ b/typesense_derive/src/helpers.rs @@ -133,6 +133,12 @@ pub(crate) fn ty_inner_type<'a>(ty: &'a syn::Type, wrapper: &'static str) -> Opt None } +/// Strip the `r#` prefix from raw identifiers. +/// ie, 'r#' in `r#type`should not appear in generated output like schema field names. +pub(crate) fn strip_raw_prefix(s: &str) -> String { + s.strip_prefix("r#").unwrap_or(s).to_string() +} + /// Helper to get the inner-most type from nested Option/Vec wrappers. pub(crate) fn get_inner_type(mut ty: &syn::Type) -> &syn::Type { while let Some(inner) = ty_inner_type(ty, "Option").or_else(|| ty_inner_type(ty, "Vec")) { diff --git a/typesense_derive/src/lib.rs b/typesense_derive/src/lib.rs index 0e1b658c..f1727034 100644 --- a/typesense_derive/src/lib.rs +++ b/typesense_derive/src/lib.rs @@ -58,7 +58,7 @@ fn impl_typesense_collection(item: ItemStruct) -> syn::Result { extract_field_attrs(field).map(|attrs| { attrs .rename - .unwrap_or_else(|| field.ident.as_ref().unwrap().to_string()) + .unwrap_or_else(|| strip_raw_prefix(&field.ident.as_ref().unwrap().to_string())) }) }) .collect::>>()?; From 87bf46f4b1c3502a3424936380cd79a51c61608c Mon Sep 17 00:00:00 2001 From: Miles Granger Date: Wed, 18 Mar 2026 12:51:35 +0100 Subject: [PATCH 2/3] Format --- typesense_derive/src/field_attributes.rs | 5 ++++- typesense_derive/src/lib.rs | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/typesense_derive/src/field_attributes.rs b/typesense_derive/src/field_attributes.rs index e7c06e23..2956142a 100644 --- a/typesense_derive/src/field_attributes.rs +++ b/typesense_derive/src/field_attributes.rs @@ -1,4 +1,7 @@ -use crate::{bool_literal, get_inner_type, i32_literal, skip_eq, string_literal, strip_raw_prefix, ty_inner_type}; +use crate::{ + bool_literal, get_inner_type, i32_literal, skip_eq, string_literal, strip_raw_prefix, + ty_inner_type, +}; use proc_macro2::TokenTree; use quote::quote; use syn::{Attribute, Field}; diff --git a/typesense_derive/src/lib.rs b/typesense_derive/src/lib.rs index f1727034..d31834bd 100644 --- a/typesense_derive/src/lib.rs +++ b/typesense_derive/src/lib.rs @@ -56,9 +56,9 @@ fn impl_typesense_collection(item: ItemStruct) -> syn::Result { .iter() .map(|field| { extract_field_attrs(field).map(|attrs| { - attrs - .rename - .unwrap_or_else(|| strip_raw_prefix(&field.ident.as_ref().unwrap().to_string())) + attrs.rename.unwrap_or_else(|| { + strip_raw_prefix(&field.ident.as_ref().unwrap().to_string()) + }) }) }) .collect::>>()?; From 42e47bb966cd3702ee5c46e4715dd9afe9441516 Mon Sep 17 00:00:00 2001 From: Miles Granger Date: Wed, 18 Mar 2026 12:51:50 +0100 Subject: [PATCH 3/3] to_string -> to_owned --- typesense_derive/src/helpers.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typesense_derive/src/helpers.rs b/typesense_derive/src/helpers.rs index d99199f7..a258584c 100644 --- a/typesense_derive/src/helpers.rs +++ b/typesense_derive/src/helpers.rs @@ -136,7 +136,7 @@ pub(crate) fn ty_inner_type<'a>(ty: &'a syn::Type, wrapper: &'static str) -> Opt /// Strip the `r#` prefix from raw identifiers. /// ie, 'r#' in `r#type`should not appear in generated output like schema field names. pub(crate) fn strip_raw_prefix(s: &str) -> String { - s.strip_prefix("r#").unwrap_or(s).to_string() + s.strip_prefix("r#").unwrap_or(s).to_owned() } /// Helper to get the inner-most type from nested Option/Vec wrappers.