Skip to main content

ytsaurus_helpers/
lib.rs

1//! Derive macros for [`ytsaurus-client`](https://docs.rs/ytsaurus-client).
2//!
3//! A job's rows are already described by a Rust struct. A table schema
4//! describes the same thing to the cluster, so writing it out again by hand is
5//! a chance to disagree with yourself. [`macro@TableRow`] reads the struct.
6//!
7//! ```ignore
8//! use ytsaurus_client::TableRow;
9//!
10//! #[derive(TableRow)]
11//! struct Visit<'a> {
12//!     #[yt(key)]
13//!     host: &'a str,               // utf8, required, sorted
14//!     size: i64,                   // int64, required
15//!     referrer: Option<&'a str>,   // utf8, optional — the Rust type says so
16//! }
17//!
18//! client.create_table("//tmp/visits", &Visit::table_schema())?;
19//! ```
20//!
21//! Nothing here runs on a cluster: a procedural-macro crate is loaded by the
22//! compiler and contributes nothing to the binary a worker ships.
23
24use proc_macro::TokenStream;
25use quote::{format_ident, quote};
26use syn::{Data, DeriveInput, Fields, GenericArgument, PathArguments, Type, parse_macro_input};
27
28/// Derives a YTsaurus table schema from a struct's fields.
29///
30/// One column per field, in declaration order, named after the field. The
31/// Rust type decides the column type, and `Option<T>` is the *only* thing that
32/// makes a column optional — a bare `T` is required.
33///
34/// | Rust | column type |
35/// | --- | --- |
36/// | `i8` `i16` `i32` `i64` | `int8` … `int64` |
37/// | `u8` `u16` `u32` `u64` | `uint8` … `uint64` |
38/// | `f32` / `f64` | `float` / `double` |
39/// | `bool` | `boolean` |
40/// | `String`, `&str`, `Cow<str>` | `utf8` |
41/// | `Vec<u8>`, `&[u8]` | `string` — YTsaurus strings are arbitrary bytes |
42/// | `YsonValue` | `any` |
43/// | `Option<T>` | `T`, not required |
44///
45/// Anything else is a compile error naming the field: guessing a column type
46/// from an unknown Rust type is how a schema comes to disagree with the data.
47/// Say what you mean with `#[yt(column_type = "…")]`.
48///
49/// # Attributes
50///
51/// On the struct:
52///
53/// - `#[yt(non_strict)]` — let rows carry columns the schema does not mention.
54/// - `#[yt(unique_keys)]` — promise no two rows share a key. Requires a key.
55/// - `#[yt(crate_path = "::path::to::ytsaurus_client")]` — for a renamed
56///   dependency.
57///
58/// On a field:
59///
60/// - `#[yt(key)]` — a key column, sorted ascending. Key fields must come first:
61///   the cluster refuses a schema whose keys are not a prefix, so the macro
62///   refuses it earlier and names the field.
63/// - `#[yt(name = "…")]` — the column name, when it differs from the field's.
64/// - `#[yt(column_type = "…")]` — the column type, by its wire name.
65/// - `#[yt(skip)]` — not a column at all.
66///
67/// # Example
68///
69/// ```ignore
70/// #[derive(TableRow)]
71/// #[yt(unique_keys)]
72/// struct Session {
73///     #[yt(key)]
74///     user_id: i64,
75///     #[yt(key)]
76///     started_at: i64,
77///     #[yt(name = "duration_s")]
78///     duration: i64,
79///     #[yt(column_type = "any")]
80///     details: ytsaurus_client::yson_build::YsonValue,
81///     #[yt(skip)]
82///     cached: usize,
83/// }
84/// ```
85#[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
93/// What a `#[yt(...)]` on the struct can say.
94struct StructOptions {
95    strict: bool,
96    unique_keys: bool,
97    crate_path: syn::Path,
98}
99
100/// What a `#[yt(...)]` on a field can say.
101#[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        // The cluster: "Key columns must form a prefix of schema". Catching it
140        // here names the field instead of returning error 314 from a create.
141        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            // An explicit column type still lets `Option<T>` say the column is
156            // optional: the attribute names the type, not the optionality.
157            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        // Three types can never be required — the cluster answers `Column of
169        // type "any" cannot be "required"` and `Null type cannot be required`.
170        // Each already means "there may be nothing here".
171        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
219/// The named fields of a struct, or a useful error.
220fn 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
293/// Every column type the derive knows, by wire name and `ColumnType` variant.
294///
295/// The wire names are the `type` spelling. `bool` and `yson` — the `type_v3`
296/// spellings of `boolean` and `any` — are accepted here so that a user who
297/// reaches for the other vocabulary is not stopped by it.
298const 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
348/// Maps a Rust type to a `ColumnType` variant, and says whether it is optional.
349///
350/// Syntactic by necessity — a derive macro sees tokens, not types — so
351/// `Vec<u8>` is recognised by its spelling. A type alias for it is not, which
352/// is what `#[yt(column_type = "…")]` is for.
353fn column_type_of(ty: &Type) -> Option<(&'static str, bool)> {
354    if let Some(inner) = option_inner(ty) {
355        // Option<Option<T>> has no meaning in a schema: a column is either
356        // there or it is not.
357        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        // `&str`, `&[u8]`, `&&str` — the reference is not part of the type as
368        // far as a column is concerned.
369        Type::Reference(reference) => simple_column_type(&reference.elem),
370        // `[u8]` and `[u8; N]`.
371        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                // A Rust `String` is UTF-8 by construction, so it maps to the
390                // column type that says so. Bytes that are not text are
391                // `Vec<u8>`, which maps to `string`.
392                "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                    // Cow<'a, str> and Cow<'a, [u8]>: the lifetime comes first.
399                    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    /// The distinction the whole codec is careful about: a Rust `String` is
480    /// text, a `Vec<u8>` is bytes, and YTsaurus has a different column type
481    /// for each.
482    #[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        // A column is present or it is not; there is no second layer for the
507        // schema to describe.
508        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        // Each of these would have to be guessed at, and a guess in a schema
521        // is a lie the cluster enforces on every write.
522        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}