1use proc_macro::TokenStream;
25use quote::{format_ident, quote};
26use syn::{Data, DeriveInput, Fields, GenericArgument, PathArguments, Type, parse_macro_input};
27
28#[proc_macro_derive(TableRow, attributes(yt))]
86pub fn derive_table_row(input: TokenStream) -> TokenStream {
87 let input = parse_macro_input!(input as DeriveInput);
88 expand(&input)
89 .unwrap_or_else(syn::Error::into_compile_error)
90 .into()
91}
92
93struct StructOptions {
95 strict: bool,
96 unique_keys: bool,
97 crate_path: syn::Path,
98}
99
100#[derive(Default)]
102struct FieldOptions {
103 key: bool,
104 skip: bool,
105 name: Option<String>,
106 column_type: Option<(String, proc_macro2::Span)>,
107}
108
109fn expand(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
110 let options = struct_options(input)?;
111 let fields = named_fields(input)?;
112
113 let mut columns = Vec::new();
114 let mut names: Vec<(String, proc_macro2::Span)> = Vec::new();
115 let mut keys_ended = false;
116 let mut any_key = false;
117
118 for field in fields {
119 let ident = field.ident.as_ref().expect("named fields only");
120 let span = ident.span();
121 let field_options = field_options(field)?;
122
123 if field_options.skip {
124 continue;
125 }
126
127 let name = field_options.name.unwrap_or_else(|| ident.to_string());
128 if let Some((first, _)) = names.iter().find(|(seen, _)| *seen == name) {
129 return Err(syn::Error::new(
130 span,
131 format!(
132 "two columns would be named {first:?}; \
133 a table cannot have duplicate column names"
134 ),
135 ));
136 }
137 names.push((name.clone(), span));
138
139 if field_options.key {
142 if keys_ended {
143 return Err(syn::Error::new(
144 span,
145 "key columns must be the first fields of the struct; \
146 move this field up, or drop #[yt(key)]",
147 ));
148 }
149 any_key = true;
150 } else {
151 keys_ended = true;
152 }
153
154 let (column_type, optional) = match &field_options.column_type {
155 Some((name, span)) => (
158 named_column_type(name, *span)?,
159 option_inner(&field.ty).is_some(),
160 ),
161 None => {
162 column_type_of(&field.ty).ok_or_else(|| unsupported_type(&field.ty, span, &name))?
163 }
164 };
165
166 let krate = &options.crate_path;
167 let variant = format_ident!("{}", column_type);
168 let required = if optional || NEVER_REQUIRED.contains(&column_type) {
172 quote!()
173 } else {
174 quote!(.required())
175 };
176 let key = if field_options.key {
177 quote!(.key())
178 } else {
179 quote!()
180 };
181
182 columns.push(quote! {
183 #krate::Column::new(#name, #krate::ColumnType::#variant) #required #key
184 });
185 }
186
187 if options.unique_keys && !any_key {
188 return Err(syn::Error::new_spanned(
189 &input.ident,
190 "unique_keys promises no two rows share a key, but no field is #[yt(key)]",
191 ));
192 }
193
194 let krate = &options.crate_path;
195 let name = &input.ident;
196 let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
197
198 let non_strict = if options.strict {
199 quote!()
200 } else {
201 quote!(.non_strict())
202 };
203 let unique_keys = if options.unique_keys {
204 quote!(.with_unique_keys(true))
205 } else {
206 quote!()
207 };
208
209 Ok(quote! {
210 #[automatically_derived]
211 impl #impl_generics #krate::TableRow for #name #ty_generics #where_clause {
212 fn table_schema() -> #krate::TableSchema {
213 #krate::TableSchema::new([ #(#columns),* ]) #non_strict #unique_keys
214 }
215 }
216 })
217}
218
219fn named_fields(input: &DeriveInput) -> syn::Result<impl Iterator<Item = &syn::Field>> {
221 let Data::Struct(data) = &input.data else {
222 return Err(syn::Error::new_spanned(
223 &input.ident,
224 "TableRow describes a table's columns, so it can only be derived for a struct",
225 ));
226 };
227
228 let Fields::Named(named) = &data.fields else {
229 return Err(syn::Error::new_spanned(
230 &input.ident,
231 "a table's columns have names, so TableRow needs a struct with named fields",
232 ));
233 };
234
235 Ok(named.named.iter())
236}
237
238fn struct_options(input: &DeriveInput) -> syn::Result<StructOptions> {
239 let mut options = StructOptions {
240 strict: true,
241 unique_keys: false,
242 crate_path: syn::parse_quote!(::ytsaurus_client),
243 };
244
245 for attr in input.attrs.iter().filter(|a| a.path().is_ident("yt")) {
246 attr.parse_nested_meta(|meta| {
247 if meta.path.is_ident("non_strict") {
248 options.strict = false;
249 } else if meta.path.is_ident("unique_keys") {
250 options.unique_keys = true;
251 } else if meta.path.is_ident("crate_path") {
252 let value: syn::LitStr = meta.value()?.parse()?;
253 options.crate_path = value.parse()?;
254 } else {
255 return Err(meta.error(
256 "unknown option; the struct takes non_strict, unique_keys and crate_path",
257 ));
258 }
259 Ok(())
260 })?;
261 }
262
263 Ok(options)
264}
265
266fn field_options(field: &syn::Field) -> syn::Result<FieldOptions> {
267 let mut options = FieldOptions::default();
268
269 for attr in field.attrs.iter().filter(|a| a.path().is_ident("yt")) {
270 attr.parse_nested_meta(|meta| {
271 if meta.path.is_ident("key") {
272 options.key = true;
273 } else if meta.path.is_ident("skip") {
274 options.skip = true;
275 } else if meta.path.is_ident("name") {
276 let value: syn::LitStr = meta.value()?.parse()?;
277 options.name = Some(value.value());
278 } else if meta.path.is_ident("column_type") {
279 let value: syn::LitStr = meta.value()?.parse()?;
280 options.column_type = Some((value.value(), value.span()));
281 } else {
282 return Err(
283 meta.error("unknown option; a field takes key, skip, name and column_type")
284 );
285 }
286 Ok(())
287 })?;
288 }
289
290 Ok(options)
291}
292
293const NEVER_REQUIRED: &[&str] = &["Any", "Null", "Void"];
299
300const TYPE_NAMES: &[(&str, &str)] = &[
301 ("int8", "Int8"),
302 ("int16", "Int16"),
303 ("int32", "Int32"),
304 ("int64", "Int64"),
305 ("uint8", "Uint8"),
306 ("uint16", "Uint16"),
307 ("uint32", "Uint32"),
308 ("uint64", "Uint64"),
309 ("float", "Float"),
310 ("double", "Double"),
311 ("boolean", "Boolean"),
312 ("bool", "Boolean"),
313 ("string", "String"),
314 ("utf8", "Utf8"),
315 ("any", "Any"),
316 ("yson", "Any"),
317 ("date", "Date"),
318 ("datetime", "Datetime"),
319 ("timestamp", "Timestamp"),
320 ("interval", "Interval"),
321 ("date32", "Date32"),
322 ("datetime64", "Datetime64"),
323 ("timestamp64", "Timestamp64"),
324 ("interval64", "Interval64"),
325 ("json", "Json"),
326 ("uuid", "Uuid"),
327 ("void", "Void"),
328 ("null", "Null"),
329];
330
331fn named_column_type(name: &str, span: proc_macro2::Span) -> syn::Result<&'static str> {
332 TYPE_NAMES
333 .iter()
334 .find(|(wire, _)| *wire == name)
335 .map(|(_, variant)| *variant)
336 .ok_or_else(|| {
337 let known: Vec<&str> = TYPE_NAMES.iter().map(|(wire, _)| *wire).collect();
338 syn::Error::new(
339 span,
340 format!(
341 "{name:?} is not a column type; try one of {}",
342 known.join(", ")
343 ),
344 )
345 })
346}
347
348fn column_type_of(ty: &Type) -> Option<(&'static str, bool)> {
354 if let Some(inner) = option_inner(ty) {
355 if option_inner(inner).is_some() {
358 return None;
359 }
360 return Some((simple_column_type(inner)?, true));
361 }
362 Some((simple_column_type(ty)?, false))
363}
364
365fn simple_column_type(ty: &Type) -> Option<&'static str> {
366 match ty {
367 Type::Reference(reference) => simple_column_type(&reference.elem),
370 Type::Slice(slice) => is_u8(&slice.elem).then_some("String"),
372 Type::Array(array) => is_u8(&array.elem).then_some("String"),
373 Type::Path(path) => {
374 let segment = path.path.segments.last()?;
375 let name = segment.ident.to_string();
376
377 match name.as_str() {
378 "i8" => Some("Int8"),
379 "i16" => Some("Int16"),
380 "i32" => Some("Int32"),
381 "i64" => Some("Int64"),
382 "u8" => Some("Uint8"),
383 "u16" => Some("Uint16"),
384 "u32" => Some("Uint32"),
385 "u64" => Some("Uint64"),
386 "f32" => Some("Float"),
387 "f64" => Some("Double"),
388 "bool" => Some("Boolean"),
389 "String" | "str" => Some("Utf8"),
393 "YsonValue" => Some("Any"),
394 "Vec" => generic_argument(segment)
395 .filter(|arg| is_u8(arg))
396 .map(|_| "String"),
397 "Cow" => {
398 let PathArguments::AngleBracketed(args) = &segment.arguments else {
400 return None;
401 };
402 args.args
403 .iter()
404 .find_map(|arg| match arg {
405 GenericArgument::Type(ty) => Some(ty),
406 _ => None,
407 })
408 .and_then(simple_column_type)
409 }
410 _ => None,
411 }
412 }
413 _ => None,
414 }
415}
416
417fn option_inner(ty: &Type) -> Option<&Type> {
418 let Type::Path(path) = ty else { return None };
419 let segment = path.path.segments.last()?;
420 (segment.ident == "Option").then(|| generic_argument(segment))?
421}
422
423fn generic_argument(segment: &syn::PathSegment) -> Option<&Type> {
424 let PathArguments::AngleBracketed(args) = &segment.arguments else {
425 return None;
426 };
427 args.args.iter().find_map(|arg| match arg {
428 GenericArgument::Type(ty) => Some(ty),
429 _ => None,
430 })
431}
432
433fn is_u8(ty: &Type) -> bool {
434 matches!(ty, Type::Path(path) if path.path.is_ident("u8"))
435}
436
437fn unsupported_type(ty: &Type, span: proc_macro2::Span, column: &str) -> syn::Error {
438 let rendered = quote!(#ty).to_string();
439 let hint = if option_inner(ty).and_then(option_inner).is_some() {
440 "Option<Option<T>> has no meaning in a schema: a column is either there or it is not"
441 } else {
442 "supported types are the integers, f32/f64, bool, String/&str, Vec<u8>/&[u8] and \
443 YsonValue, each optionally wrapped in Option; \
444 for anything else say what you mean with #[yt(column_type = \"…\")], \
445 or drop the field with #[yt(skip)]"
446 };
447
448 syn::Error::new(
449 span,
450 format!("cannot infer a column type for {column:?} from `{rendered}`: {hint}"),
451 )
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use syn::parse_str;
458
459 fn infer(rust: &str) -> Option<(&'static str, bool)> {
460 column_type_of(&parse_str::<Type>(rust).expect("a type"))
461 }
462
463 #[test]
464 fn integers_map_to_their_own_width() {
465 assert_eq!(infer("i8"), Some(("Int8", false)));
466 assert_eq!(infer("i16"), Some(("Int16", false)));
467 assert_eq!(infer("i32"), Some(("Int32", false)));
468 assert_eq!(infer("i64"), Some(("Int64", false)));
469 assert_eq!(infer("u8"), Some(("Uint8", false)));
470 assert_eq!(infer("u64"), Some(("Uint64", false)));
471 }
472
473 #[test]
474 fn floats_keep_their_precision() {
475 assert_eq!(infer("f32"), Some(("Float", false)));
476 assert_eq!(infer("f64"), Some(("Double", false)));
477 }
478
479 #[test]
483 fn text_and_bytes_do_not_collapse_into_one_type() {
484 assert_eq!(infer("String"), Some(("Utf8", false)));
485 assert_eq!(infer("&str"), Some(("Utf8", false)));
486 assert_eq!(infer("&'a str"), Some(("Utf8", false)));
487 assert_eq!(infer("Cow<'a, str>"), Some(("Utf8", false)));
488
489 assert_eq!(infer("Vec<u8>"), Some(("String", false)));
490 assert_eq!(infer("&[u8]"), Some(("String", false)));
491 assert_eq!(infer("&'a [u8]"), Some(("String", false)));
492 assert_eq!(infer("[u8; 16]"), Some(("String", false)));
493 assert_eq!(infer("Cow<'a, [u8]>"), Some(("String", false)));
494 }
495
496 #[test]
497 fn option_is_the_one_source_of_optionality() {
498 assert_eq!(infer("Option<i64>"), Some(("Int64", true)));
499 assert_eq!(infer("Option<&'a str>"), Some(("Utf8", true)));
500 assert_eq!(infer("Option<Vec<u8>>"), Some(("String", true)));
501 assert_eq!(infer("bool"), Some(("Boolean", false)));
502 }
503
504 #[test]
505 fn a_doubly_optional_column_is_refused() {
506 assert_eq!(infer("Option<Option<i64>>"), None);
509 }
510
511 #[test]
512 fn a_qualified_path_is_still_recognised() {
513 assert_eq!(infer("std::string::String"), Some(("Utf8", false)));
514 assert_eq!(infer("ytsaurus_yson::YsonValue"), Some(("Any", false)));
515 assert_eq!(infer("core::option::Option<i64>"), Some(("Int64", true)));
516 }
517
518 #[test]
519 fn a_type_with_no_column_shape_is_refused() {
520 assert_eq!(infer("Vec<i64>"), None);
523 assert_eq!(infer("HashMap<String, i64>"), None);
524 assert_eq!(infer("MyStruct"), None);
525 assert_eq!(infer("(i64, i64)"), None);
526 assert_eq!(infer("Vec<Vec<u8>>"), None);
527 }
528
529 #[test]
530 fn every_wire_name_names_a_variant() {
531 for (wire, variant) in TYPE_NAMES {
532 assert_eq!(
533 named_column_type(wire, proc_macro2::Span::call_site()).unwrap(),
534 *variant
535 );
536 }
537 }
538
539 #[test]
540 fn an_unknown_wire_name_lists_the_known_ones() {
541 let err =
542 named_column_type("int128", proc_macro2::Span::call_site()).expect_err("must refuse");
543 let message = err.to_string();
544 assert!(message.contains("int128"), "{message}");
545 assert!(message.contains("int64"), "{message}");
546 }
547}