Skip to main content

radixdb_plugin_macros/
lib.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use proc_macro::TokenStream;
4use proc_macro2::{Ident, Span, TokenStream as TokenStream2};
5use quote::{format_ident, quote};
6use sha2::{Digest, Sha256};
7use syn::{
8    parse::Parser, parse_macro_input, punctuated::Punctuated, spanned::Spanned, Attribute, Data,
9    DeriveInput, Expr, ExprLit, Fields, FnArg, GenericArgument, Item, ItemFn, ItemMod, Lit, Meta,
10    PathArguments, ReturnType, Token, Type,
11};
12
13#[proc_macro_derive(RadixType, attributes(radix_type, radix_field))]
14pub fn derive_radix_type(input: TokenStream) -> TokenStream {
15    match expand_radix_type(parse_macro_input!(input as DeriveInput)) {
16        Ok(tokens) => tokens.into(),
17        Err(error) => error.into_compile_error().into(),
18    }
19}
20
21#[proc_macro_attribute]
22pub fn radixdb_plugin(attribute: TokenStream, item: TokenStream) -> TokenStream {
23    let args = match parse_options(attribute.into()) {
24        Ok(args) => args,
25        Err(error) => return error.into_compile_error().into(),
26    };
27    match expand_plugin(args, parse_macro_input!(item as ItemMod)) {
28        Ok(tokens) => tokens.into(),
29        Err(error) => error.into_compile_error().into(),
30    }
31}
32
33macro_rules! passthrough_attribute {
34    ($name:ident) => {
35        #[proc_macro_attribute]
36        pub fn $name(_attribute: TokenStream, item: TokenStream) -> TokenStream {
37            item
38        }
39    };
40}
41
42passthrough_attribute!(radixdb_scalar);
43passthrough_attribute!(radixdb_batch);
44passthrough_attribute!(radixdb_operator);
45passthrough_attribute!(radixdb_operator_class);
46passthrough_attribute!(radixdb_planner_support);
47
48macro_rules! rejected_attribute {
49    ($name:ident, $kind:literal) => {
50        #[proc_macro_attribute]
51        pub fn $name(_attribute: TokenStream, _item: TokenStream) -> TokenStream {
52            syn::Error::new(
53                Span::call_site(),
54                concat!(
55                    $kind,
56                    " plugin functions are outside the RadixDB 1.2 authoring scope"
57                ),
58            )
59            .into_compile_error()
60            .into()
61        }
62    };
63}
64
65rejected_attribute!(radixdb_aggregate, "aggregate");
66rejected_attribute!(radixdb_window, "window");
67rejected_attribute!(radixdb_tvf, "table-valued");
68
69#[derive(Default, Clone)]
70struct Options {
71    values: BTreeMap<String, Expr>,
72    flags: BTreeSet<String>,
73}
74
75impl Options {
76    fn required_string(&self, name: &str, span: Span) -> syn::Result<String> {
77        self.string(name)?
78            .ok_or_else(|| syn::Error::new(span, format!("missing required `{name}`")))
79    }
80
81    fn string(&self, name: &str) -> syn::Result<Option<String>> {
82        self.values
83            .get(name)
84            .map(|value| match value {
85                Expr::Lit(ExprLit {
86                    lit: Lit::Str(value),
87                    ..
88                }) => Ok(value.value()),
89                _ => Err(syn::Error::new(value.span(), "expected string literal")),
90            })
91            .transpose()
92    }
93
94    fn required_u32(&self, name: &str, span: Span) -> syn::Result<u32> {
95        self.u32(name)?
96            .ok_or_else(|| syn::Error::new(span, format!("missing required `{name}`")))
97    }
98
99    fn u32(&self, name: &str) -> syn::Result<Option<u32>> {
100        self.values
101            .get(name)
102            .map(|value| match value {
103                Expr::Lit(ExprLit {
104                    lit: Lit::Int(value),
105                    ..
106                }) => value.base10_parse(),
107                _ => Err(syn::Error::new(value.span(), "expected integer literal")),
108            })
109            .transpose()
110    }
111
112    fn path(&self, name: &str) -> syn::Result<Option<syn::Path>> {
113        self.values
114            .get(name)
115            .map(|value| match value {
116                Expr::Path(value) => Ok(value.path.clone()),
117                _ => Err(syn::Error::new(value.span(), "expected callback path")),
118            })
119            .transpose()
120    }
121
122    fn reject_unknown(&self, allowed_values: &[&str], allowed_flags: &[&str]) -> syn::Result<()> {
123        for name in self.values.keys() {
124            if !allowed_values.contains(&name.as_str()) {
125                return Err(syn::Error::new(
126                    Span::call_site(),
127                    format!("unknown RadixDB attribute option `{name}`"),
128                ));
129            }
130        }
131        for name in &self.flags {
132            if !allowed_flags.contains(&name.as_str()) {
133                return Err(syn::Error::new(
134                    Span::call_site(),
135                    format!("unknown RadixDB attribute flag `{name}`"),
136                ));
137            }
138        }
139        Ok(())
140    }
141}
142
143fn parse_options(tokens: TokenStream2) -> syn::Result<Options> {
144    let metas = Punctuated::<Meta, Token![,]>::parse_terminated.parse2(tokens)?;
145    let mut result = Options::default();
146    for meta in metas {
147        match meta {
148            Meta::Path(path) => {
149                let name = single_ident(&path)?;
150                if !result.flags.insert(name.clone()) {
151                    return Err(syn::Error::new(path.span(), format!("duplicate `{name}`")));
152                }
153            }
154            Meta::NameValue(value) => {
155                let name = single_ident(&value.path)?;
156                if result.values.insert(name.clone(), value.value).is_some() {
157                    return Err(syn::Error::new(
158                        value.path.span(),
159                        format!("duplicate `{name}`"),
160                    ));
161                }
162            }
163            Meta::List(list) => {
164                return Err(syn::Error::new(
165                    list.span(),
166                    "nested RadixDB attribute options are not supported",
167                ));
168            }
169        }
170    }
171    Ok(result)
172}
173
174fn options_from_attribute(attribute: &Attribute) -> syn::Result<Options> {
175    let Meta::List(list) = &attribute.meta else {
176        return Err(syn::Error::new(
177            attribute.span(),
178            "expected attribute arguments",
179        ));
180    };
181    parse_options(list.tokens.clone())
182}
183
184fn single_ident(path: &syn::Path) -> syn::Result<String> {
185    if path.segments.len() != 1 {
186        return Err(syn::Error::new(
187            path.span(),
188            "expected a single option name",
189        ));
190    }
191    Ok(path.segments[0].ident.to_string())
192}
193
194fn attribute<'a>(attributes: &'a [Attribute], name: &str) -> Option<&'a Attribute> {
195    attributes
196        .iter()
197        .find(|attribute| attribute.path().is_ident(name))
198}
199
200fn expand_radix_type(input: DeriveInput) -> syn::Result<TokenStream2> {
201    let ident = &input.ident;
202    if !input.generics.params.is_empty() {
203        return Err(syn::Error::new(
204            input.generics.span(),
205            "RadixType does not support generic external types",
206        ));
207    }
208    let type_attribute = attribute(&input.attrs, "radix_type")
209        .ok_or_else(|| syn::Error::new(input.span(), "missing #[radix_type(...)]"))?;
210    let options = options_from_attribute(type_attribute)?;
211    options.reject_unknown(
212        &[
213            "id",
214            "name",
215            "codec",
216            "semantic_revision",
217            "storage",
218            "max_bytes",
219            "equality",
220            "hash",
221            "ordering",
222            "manual",
223        ],
224        &[],
225    )?;
226    let local_id = options.required_string("id", input.span())?;
227    let display_name = options.required_string("name", input.span())?;
228    validate_local_id(&local_id, input.span())?;
229    validate_sql_name(&display_name, input.span())?;
230    let codec_version = options.required_u32("codec", input.span())?;
231    let semantic_revision = options.required_u32("semantic_revision", input.span())?;
232    if codec_version == 0 || semantic_revision == 0 {
233        return Err(syn::Error::new(
234            input.span(),
235            "codec and semantic_revision must be non-zero",
236        ));
237    }
238    let storage = options.required_string("storage", input.span())?;
239    let max_bytes = options.required_u32("max_bytes", input.span())?;
240    if max_bytes == 0 || max_bytes > 16 * 1024 * 1024 {
241        return Err(syn::Error::new(
242            input.span(),
243            "max_bytes must be in 1..=16777216",
244        ));
245    }
246    let storage_kind = match storage.as_str() {
247        "fixed" => quote!(::radixdb_plugin::__private::abi::RADIX_EXTERNAL_STORAGE_FIXED),
248        "variable" => quote!(::radixdb_plugin::__private::abi::RADIX_EXTERNAL_STORAGE_VARIABLE),
249        _ => {
250            return Err(syn::Error::new(
251                input.span(),
252                "storage must be \"fixed\" or \"variable\"",
253            ));
254        }
255    };
256    let fixed_bytes = if storage == "fixed" { max_bytes } else { 0 };
257    let equality = options.path("equality")?;
258    let hash = options.path("hash")?;
259    let ordering = options.path("ordering")?;
260    if hash.is_some() && equality.is_none() {
261        return Err(syn::Error::new(
262            input.span(),
263            "hash callback requires an explicit equality callback",
264        ));
265    }
266    let manual = options.path("manual")?;
267
268    let Data::Struct(data) = &input.data else {
269        return Err(syn::Error::new(input.span(), "RadixType requires a struct"));
270    };
271    let Fields::Named(fields) = &data.fields else {
272        return Err(syn::Error::new(
273            data.fields.span(),
274            "RadixType requires named fields",
275        ));
276    };
277
278    let (encode_body, decode_body, corpus_body, schema, inferred_fixed_bytes) = if let Some(codec) =
279        manual
280    {
281        if fields
282            .named
283            .iter()
284            .any(|field| attribute(&field.attrs, "radix_field").is_some())
285        {
286            return Err(syn::Error::new(
287                fields.span(),
288                "manual codec types must not declare radix_field codecs",
289            ));
290        }
291        (
292            quote!(<#codec as ::radixdb_plugin::ManualCodec<Self>>::encode(self, output)),
293            quote!(<#codec as ::radixdb_plugin::ManualCodec<Self>>::decode(input)),
294            quote!(<#codec as ::radixdb_plugin::ManualCodec<Self>>::corpus()),
295            format!("manual:{}", quote!(#codec)),
296            None,
297        )
298    } else {
299        let mut encode = Vec::new();
300        let mut decode = Vec::new();
301        let mut names = Vec::new();
302        let mut edge_extensions = Vec::new();
303        let mut schema_parts = Vec::new();
304        let mut encoded_width = Some(0_u32);
305        for field in &fields.named {
306            let field_ident = field.ident.as_ref().expect("named field");
307            let field_type = &field.ty;
308            names.push(field_ident.clone());
309            let field_attribute = attribute(&field.attrs, "radix_field").ok_or_else(|| {
310                syn::Error::new(
311                    field.span(),
312                    "every derived field requires #[radix_field(...)]",
313                )
314            })?;
315            let field_options = options_from_attribute(field_attribute)?;
316            field_options.reject_unknown(&["codec", "max_items", "max_bytes"], &[])?;
317            let codec = field_options.required_string("codec", field.span())?;
318            let generated =
319                generate_field_codec(field_ident, &field.ty, &codec, &field_options, field.span())?;
320            encode.push(generated.encode);
321            decode.push(generated.decode);
322            edge_extensions.push(generated.edge);
323            encoded_width = match (encoded_width, generated.fixed_width) {
324                (Some(total), Some(width)) => Some(total.checked_add(width).ok_or_else(|| {
325                    syn::Error::new(field.span(), "derived fixed width exceeds u32")
326                })?),
327                _ => None,
328            };
329            schema_parts.push(format!(
330                "{}:{}:{}",
331                field_ident,
332                quote!(#field_type),
333                generated.schema
334            ));
335        }
336        let defaults = names.iter().zip(fields.named.iter()).map(|(name, field)| {
337            let ty = &field.ty;
338            quote!(#name: <#ty as ::std::default::Default>::default())
339        });
340        (
341            quote!({ #(#encode)* Ok(()) }),
342            quote!({ #(let #names = #decode;)* Ok(Self { #(#names),* }) }),
343            quote!({
344                let mut corpus = vec![Self { #(#defaults),* }];
345                #(#edge_extensions)*
346                corpus
347            }),
348            schema_parts.join(";"),
349            encoded_width,
350        )
351    };
352    if storage == "fixed" && inferred_fixed_bytes.is_some_and(|encoded| encoded != max_bytes) {
353        return Err(syn::Error::new(
354            input.span(),
355            format!(
356                "fixed type max_bytes must equal derived encoded width {}",
357                inferred_fixed_bytes.unwrap()
358            ),
359        ));
360    }
361
362    let mut capabilities = 0u64;
363    if equality.is_some() {
364        capabilities |= 1 << 0;
365    }
366    if hash.is_some() {
367        capabilities |= 1 << 1;
368    }
369    if ordering.is_some() {
370        capabilities |= 1 << 2;
371    }
372    // This fingerprint is persisted beside encoded values. It identifies only
373    // the physical codec: SQL names and semantic callbacks can evolve without
374    // forcing a codec migration. Their evolution is tracked independently by
375    // semantic revisions and the package descriptor fingerprint.
376    let fingerprint = digest32(format!(
377        "radixdb.type.codec.v1\0{id}\0{codec_version}\0{storage}\0{max_bytes}\0{schema}",
378        id = local_id,
379    ));
380    let fingerprint_tokens = bytes_tokens(&fingerprint);
381    let encode_wrapper =
382        format_ident!("__radixdb_type_{}_encode", ident.to_string().to_lowercase());
383    let decode_wrapper =
384        format_ident!("__radixdb_type_{}_decode", ident.to_string().to_lowercase());
385    let equal_wrapper = format_ident!("__radixdb_type_{}_equal", ident.to_string().to_lowercase());
386    let hash_wrapper = format_ident!("__radixdb_type_{}_hash", ident.to_string().to_lowercase());
387    let compare_wrapper = format_ident!(
388        "__radixdb_type_{}_compare",
389        ident.to_string().to_lowercase()
390    );
391
392    let equality_impl = if let Some(callback) = &equality {
393        quote! {
394            fn semantic_equal(left: &Self, right: &Self) -> Option<bool> {
395                let callback: fn(&Self, &Self) -> bool = #callback;
396                Some(callback(left, right))
397            }
398        }
399    } else {
400        quote!()
401    };
402    let hash_impl = if let Some(callback) = &hash {
403        quote! {
404            fn semantic_hash(
405                value: &Self,
406                sink: &mut ::radixdb_plugin::HashSink<'_>,
407            ) -> Option<::radixdb_plugin::PluginResult<()>> {
408                let callback: fn(&Self, &mut ::radixdb_plugin::HashSink<'_>)
409                    -> ::radixdb_plugin::PluginResult<()> = #callback;
410                Some(callback(value, sink))
411            }
412        }
413    } else {
414        quote!()
415    };
416    let ordering_impl = if let Some(callback) = &ordering {
417        quote! {
418            fn semantic_compare(left: &Self, right: &Self) -> Option<::std::cmp::Ordering> {
419                let callback: fn(&Self, &Self) -> ::std::cmp::Ordering = #callback;
420                Some(callback(left, right))
421            }
422        }
423    } else {
424        quote!()
425    };
426    let equal_items = if equality.is_some() {
427        quote! {
428            unsafe extern "C" fn #equal_wrapper(
429                context: *const ::radixdb_plugin::__private::abi::RadixAbiCallContextV1,
430                left: *const ::radixdb_plugin::__private::abi::RadixAbiValueV1,
431                right: *const ::radixdb_plugin::__private::abi::RadixAbiValueV1,
432                output: *mut u8,
433            ) -> u32 {
434                unsafe { ::radixdb_plugin::__private::run_equal::<#ident>(context, left, right, output) }
435            }
436        }
437    } else {
438        quote!()
439    };
440    let hash_items = if hash.is_some() {
441        quote! {
442            unsafe extern "C" fn #hash_wrapper(
443                context: *const ::radixdb_plugin::__private::abi::RadixAbiCallContextV1,
444                value: *const ::radixdb_plugin::__private::abi::RadixAbiValueV1,
445                sink: *const ::radixdb_plugin::__private::abi::RadixAbiHashSinkV1,
446            ) -> u32 {
447                unsafe { ::radixdb_plugin::__private::run_hash::<#ident>(context, value, sink) }
448            }
449        }
450    } else {
451        quote!()
452    };
453    let ordering_items = if ordering.is_some() {
454        quote! {
455            unsafe extern "C" fn #compare_wrapper(
456                context: *const ::radixdb_plugin::__private::abi::RadixAbiCallContextV1,
457                left: *const ::radixdb_plugin::__private::abi::RadixAbiValueV1,
458                right: *const ::radixdb_plugin::__private::abi::RadixAbiValueV1,
459                output: *mut i8,
460            ) -> u32 {
461                unsafe { ::radixdb_plugin::__private::run_compare::<#ident>(context, left, right, output) }
462            }
463        }
464    } else {
465        quote!()
466    };
467    let equal_const = equality
468        .as_ref()
469        .map(|_| quote!(Some(#equal_wrapper)))
470        .unwrap_or(quote!(None));
471    let hash_const = hash
472        .as_ref()
473        .map(|_| quote!(Some(#hash_wrapper)))
474        .unwrap_or(quote!(None));
475    let ordering_const = ordering
476        .as_ref()
477        .map(|_| quote!(Some(#compare_wrapper)))
478        .unwrap_or(quote!(None));
479
480    Ok(quote! {
481        unsafe extern "C" fn #encode_wrapper(
482            context: *const ::radixdb_plugin::__private::abi::RadixAbiCallContextV1,
483            input: *const ::radixdb_plugin::__private::abi::RadixAbiValueV1,
484            output: *const ::radixdb_plugin::__private::abi::RadixAbiResultBuilderV1,
485        ) -> u32 {
486            unsafe { ::radixdb_plugin::__private::run_codec_encode::<#ident>(context, input, output) }
487        }
488
489        unsafe extern "C" fn #decode_wrapper(
490            context: *const ::radixdb_plugin::__private::abi::RadixAbiCallContextV1,
491            input: ::radixdb_plugin::__private::abi::RadixAbiSliceV1,
492            output: *const ::radixdb_plugin::__private::abi::RadixAbiResultBuilderV1,
493        ) -> u32 {
494            unsafe { ::radixdb_plugin::__private::run_codec_decode::<#ident>(context, input, output) }
495        }
496
497        #equal_items
498        #hash_items
499        #ordering_items
500
501        impl ::radixdb_plugin::RadixType for #ident {
502            const LOCAL_ID: &'static str = #local_id;
503            const DISPLAY_NAME: &'static str = #display_name;
504            const CODEC_VERSION: u32 = #codec_version;
505            const SEMANTIC_REVISION: u32 = #semantic_revision;
506            const STORAGE_KIND: u16 = #storage_kind;
507            const FIXED_BYTES: u32 = #fixed_bytes;
508            const MAX_BYTES: u32 = #max_bytes;
509            const CAPABILITIES: u64 = #capabilities;
510            const CODEC_FINGERPRINT: [u8; 32] = [#(#fingerprint_tokens),*];
511            const ABI_ENCODE: Option<::radixdb_plugin::__private::abi::RadixAbiCodecFnV1> =
512                Some(#encode_wrapper);
513            const ABI_DECODE: Option<::radixdb_plugin::__private::abi::RadixAbiParseFnV1> =
514                Some(#decode_wrapper);
515            const ABI_EQUALITY: Option<::radixdb_plugin::__private::abi::RadixAbiEqualFnV1> =
516                #equal_const;
517            const ABI_HASH: Option<::radixdb_plugin::__private::abi::RadixAbiHashFnV1> =
518                #hash_const;
519            const ABI_ORDERING: Option<::radixdb_plugin::__private::abi::RadixAbiCompareFnV1> =
520                #ordering_const;
521
522            fn encode(&self, output: &mut ::radixdb_plugin::CodecWriter)
523                -> ::radixdb_plugin::PluginResult<()>
524            {
525                #encode_body
526            }
527
528            fn decode(input: &mut ::radixdb_plugin::CodecReader<'_>)
529                -> ::radixdb_plugin::PluginResult<Self>
530            {
531                #decode_body
532            }
533
534            #[allow(clippy::needless_update)]
535            fn test_corpus() -> Vec<Self> {
536                #corpus_body
537            }
538
539            #equality_impl
540            #hash_impl
541            #ordering_impl
542        }
543    })
544}
545
546struct GeneratedField {
547    encode: TokenStream2,
548    decode: TokenStream2,
549    edge: TokenStream2,
550    schema: String,
551    fixed_width: Option<u32>,
552}
553
554fn generate_field_codec(
555    field: &Ident,
556    ty: &Type,
557    codec: &str,
558    options: &Options,
559    span: Span,
560) -> syn::Result<GeneratedField> {
561    if let Some(inner) = vec_inner(ty) {
562        let max_items = options
563            .u32("max_items")?
564            .ok_or_else(|| syn::Error::new(span, "bounded sequence requires max_items"))?;
565        let max_bytes = options
566            .u32("max_bytes")?
567            .ok_or_else(|| syn::Error::new(span, "bounded sequence requires max_bytes"))?;
568        validate_primitive_codec(inner, codec, span)?;
569        return Ok(GeneratedField {
570            encode: quote! {
571                ::radixdb_plugin::__private::encode_sequence(
572                    &self.#field,
573                    #max_items as usize,
574                    #max_bytes as usize,
575                    output,
576                )?;
577            },
578            decode: quote!(::radixdb_plugin::__private::decode_sequence::<#inner>(
579                input,
580                #max_items as usize,
581                #max_bytes as usize,
582            )?),
583            edge: quote! {
584                for value in <#inner as ::radixdb_plugin::__private::CanonicalField>::edge_values() {
585                    corpus.push(Self {
586                        #field: vec![value.clone()],
587                        ..Self::default()
588                    });
589                    corpus.push(Self {
590                        #field: vec![value; #max_items as usize],
591                        ..Self::default()
592                    });
593                }
594            },
595            schema: format!("{codec}[max_items={max_items},max_bytes={max_bytes}]"),
596            fixed_width: None,
597        });
598    }
599    if options.values.contains_key("max_items") || options.values.contains_key("max_bytes") {
600        return Err(syn::Error::new(
601            span,
602            "max_items/max_bytes are valid only for bounded sequences",
603        ));
604    }
605    if let Type::Array(array) = ty {
606        validate_primitive_codec(&array.elem, codec, span)?;
607        let length = match &array.len {
608            Expr::Lit(ExprLit {
609                lit: Lit::Int(length),
610                ..
611            }) => length.base10_parse::<u32>()?,
612            _ => {
613                return Err(syn::Error::new(
614                    array.len.span(),
615                    "fixed array length must be an integer literal",
616                ));
617            }
618        };
619        let element_width = primitive_codec_width(&array.elem).ok_or_else(|| {
620            syn::Error::new(array.elem.span(), "unsupported fixed-array element type")
621        })?;
622        return Ok(GeneratedField {
623            encode: quote!(::radixdb_plugin::__private::CanonicalField::encode_field(
624                &self.#field,
625                output,
626            )?;),
627            decode: quote!(<#ty as ::radixdb_plugin::__private::CanonicalField>::decode_field(input)?),
628            edge: quote! {
629                for value in <#ty as ::radixdb_plugin::__private::CanonicalField>::edge_values() {
630                    corpus.push(Self {
631                        #field: value,
632                        ..Self::default()
633                    });
634                }
635            },
636            schema: format!("{codec}[{}]", quote!(#array.len)),
637            fixed_width: Some(length.checked_mul(element_width).ok_or_else(|| {
638                syn::Error::new(array.len.span(), "fixed array width exceeds u32")
639            })?),
640        });
641    }
642    validate_primitive_codec(ty, codec, span)?;
643    Ok(GeneratedField {
644        encode: quote!(::radixdb_plugin::__private::CanonicalField::encode_field(
645            &self.#field,
646            output,
647        )?;),
648        decode: quote!(<#ty as ::radixdb_plugin::__private::CanonicalField>::decode_field(input)?),
649        edge: quote! {
650            for value in <#ty as ::radixdb_plugin::__private::CanonicalField>::edge_values() {
651                corpus.push(Self {
652                    #field: value,
653                    ..Self::default()
654                });
655            }
656        },
657        schema: codec.to_string(),
658        fixed_width: primitive_codec_width(ty),
659    })
660}
661
662fn primitive_codec_width(ty: &Type) -> Option<u32> {
663    match terminal_type_ident(ty)?.as_str() {
664        "i8" | "u8" | "bool" => Some(1),
665        "i16" | "u16" => Some(2),
666        "i32" | "u32" | "f32" => Some(4),
667        "i64" | "u64" | "f64" => Some(8),
668        _ => None,
669    }
670}
671
672fn validate_primitive_codec(ty: &Type, codec: &str, span: Span) -> syn::Result<()> {
673    let expected = match terminal_type_ident(ty).as_deref() {
674        Some("i8") => "i8-le",
675        Some("i16") => "i16-le",
676        Some("i32") => "i32-le",
677        Some("i64") => "i64-le",
678        Some("u8") => "u8-le",
679        Some("u16") => "u16-le",
680        Some("u32") => "u32-le",
681        Some("u64") => "u64-le",
682        Some("f32") => "f32-le",
683        Some("f64") => "f64-le",
684        Some("bool") => "bool-u8",
685        _ => {
686            return Err(syn::Error::new(
687                span,
688                "unsupported field type; use a primitive, fixed array, bounded Vec, or manual codec",
689            ));
690        }
691    };
692    if codec != expected {
693        return Err(syn::Error::new(
694            span,
695            format!("field type requires codec \"{expected}\""),
696        ));
697    }
698    Ok(())
699}
700
701fn expand_plugin(args: Options, mut module: ItemMod) -> syn::Result<TokenStream2> {
702    args.reject_unknown(&["id", "name", "version"], &[])?;
703    let package_id = args.required_string("id", module.span())?;
704    let package_name = args.required_string("name", module.span())?;
705    let package_version = args.required_string("version", module.span())?;
706    let uuid = uuid::Uuid::parse_str(&package_id)
707        .map_err(|_| syn::Error::new(module.span(), "plugin id must be a canonical UUID"))?;
708    if uuid.to_string() != package_id {
709        return Err(syn::Error::new(
710            module.span(),
711            "plugin id must use canonical lowercase UUID spelling",
712        ));
713    }
714    validate_package_name(&package_name, module.span())?;
715    let version = semver::Version::parse(&package_version)
716        .map_err(|_| syn::Error::new(module.span(), "plugin version must be SemVer"))?;
717    if version.to_string() != package_version || !version.build.is_empty() {
718        return Err(syn::Error::new(
719            module.span(),
720            "plugin version must be canonical SemVer without build metadata",
721        ));
722    }
723    let Some((_, items)) = &mut module.content else {
724        return Err(syn::Error::new(
725            module.span(),
726            "radixdb_plugin requires an inline module",
727        ));
728    };
729
730    let mut type_specs = Vec::new();
731    let mut scalar_specs = Vec::new();
732    let mut batch_specs = BTreeMap::new();
733    let mut operator_specs = Vec::new();
734    let mut opclass_specs = Vec::new();
735    let mut planner_specs = Vec::new();
736    let mut local_ids = BTreeSet::new();
737    for item in items.iter() {
738        match item {
739            Item::Struct(item) => {
740                if let Some(attribute) = attribute(&item.attrs, "radix_type") {
741                    let options = options_from_attribute(attribute)?;
742                    let local_id = options.required_string("id", item.span())?;
743                    admit_local_id(&mut local_ids, &local_id, item.span())?;
744                    type_specs.push((item.ident.clone(), local_id, options));
745                }
746            }
747            Item::Fn(function) => {
748                if let Some(attribute) = attribute(&function.attrs, "radixdb_scalar") {
749                    let options = options_from_attribute(attribute)?;
750                    let local_id = options.required_string("id", function.span())?;
751                    admit_local_id(&mut local_ids, &local_id, function.span())?;
752                    scalar_specs.push((function.clone(), local_id, options));
753                }
754                if let Some(attribute) = attribute(&function.attrs, "radixdb_batch") {
755                    let options = options_from_attribute(attribute)?;
756                    let scalar = options.required_string("for_scalar", function.span())?;
757                    if batch_specs
758                        .insert(scalar.clone(), (function.clone(), options))
759                        .is_some()
760                    {
761                        return Err(syn::Error::new(
762                            function.span(),
763                            format!("duplicate batch adapter for `{scalar}`"),
764                        ));
765                    }
766                }
767                if let Some(attribute) = attribute(&function.attrs, "radixdb_operator") {
768                    let options = options_from_attribute(attribute)?;
769                    let local_id = options.required_string("id", function.span())?;
770                    admit_local_id(&mut local_ids, &local_id, function.span())?;
771                    operator_specs.push((function.clone(), local_id, options));
772                }
773                if let Some(attribute) = attribute(&function.attrs, "radixdb_operator_class") {
774                    let options = options_from_attribute(attribute)?;
775                    let local_id = options.required_string("id", function.span())?;
776                    admit_local_id(&mut local_ids, &local_id, function.span())?;
777                    opclass_specs.push((function.clone(), local_id, options));
778                }
779                if let Some(attribute) = attribute(&function.attrs, "radixdb_planner_support") {
780                    let options = options_from_attribute(attribute)?;
781                    let local_id = options.required_string("id", function.span())?;
782                    admit_local_id(&mut local_ids, &local_id, function.span())?;
783                    planner_specs.push((function.clone(), local_id, options));
784                }
785            }
786            _ => {}
787        }
788    }
789
790    let package_uuid = *uuid.as_bytes();
791    let type_map: BTreeMap<String, ([u8; 16], u32)> = type_specs
792        .iter()
793        .map(|(ident, local_id, options)| {
794            Ok((
795                ident.to_string(),
796                (
797                    object_id(package_uuid, local_id),
798                    options.required_u32("codec", ident.span())?,
799                ),
800            ))
801        })
802        .collect::<syn::Result<_>>()?;
803    let mut type_names = BTreeSet::new();
804    for (ident, _, options) in &type_specs {
805        let name = options.required_string("name", ident.span())?;
806        if !type_names.insert(name.clone()) {
807            return Err(syn::Error::new(
808                ident.span(),
809                format!("duplicate external type SQL name `{name}`"),
810            ));
811        }
812    }
813    let mut overloads = BTreeSet::new();
814    for (function, _, options) in &scalar_specs {
815        let name = options.required_string("name", function.span())?;
816        let arguments = function_arguments(function)?;
817        let signature = format!(
818            "{}({})",
819            name,
820            arguments
821                .iter()
822                .map(|(_, ty)| quote!(#ty).to_string())
823                .collect::<Vec<_>>()
824                .join(",")
825        );
826        if !overloads.insert(signature.clone()) {
827            return Err(syn::Error::new(
828                function.span(),
829                format!("duplicate scalar overload `{signature}`"),
830            ));
831        }
832    }
833    let mut operator_overloads = BTreeSet::new();
834    for (function, _, options) in &operator_specs {
835        let symbol = options.required_string("symbol", function.span())?;
836        let left = parse_type_option(options, "left", function.span())?;
837        let right = parse_type_option(options, "right", function.span())?;
838        let signature = format!("{}({},{})", symbol, quote!(#left), quote!(#right));
839        if !operator_overloads.insert(signature.clone()) {
840            return Err(syn::Error::new(
841                function.span(),
842                format!("duplicate operator overload `{signature}`"),
843            ));
844        }
845    }
846    let function_ids: BTreeMap<String, [u8; 16]> = scalar_specs
847        .iter()
848        .map(|(_, local_id, _)| (local_id.clone(), object_id(package_uuid, local_id)))
849        .chain(scalar_specs.iter().map(|(function, local_id, _)| {
850            (
851                function.sig.ident.to_string(),
852                object_id(package_uuid, local_id),
853            )
854        }))
855        .collect();
856    let operator_ids: BTreeMap<(String, String, String), [u8; 16]> = operator_specs
857        .iter()
858        .map(|(function, local_id, options)| {
859            let symbol = options.required_string("symbol", function.span())?;
860            let left = parse_type_option(options, "left", function.span())?;
861            let right = parse_type_option(options, "right", function.span())?;
862            Ok((
863                (
864                    symbol,
865                    quote!(#left).to_string(),
866                    quote!(#right).to_string(),
867                ),
868                object_id(package_uuid, local_id),
869            ))
870        })
871        .collect::<syn::Result<_>>()?;
872    let opclass_ids: BTreeMap<String, [u8; 16]> = opclass_specs
873        .iter()
874        .map(|(function, local_id, _)| {
875            (
876                function.sig.ident.to_string(),
877                object_id(package_uuid, local_id),
878            )
879        })
880        .chain(
881            opclass_specs
882                .iter()
883                .map(|(_, local_id, _)| (local_id.clone(), object_id(package_uuid, local_id))),
884        )
885        .collect();
886
887    let type_descriptors = type_specs
888        .iter()
889        .map(|(ident, local_id, _)| generate_type_descriptor(ident, package_uuid, local_id));
890    let has_batch = !batch_specs.is_empty();
891    let mut generated_functions = Vec::new();
892    let mut function_descriptors = Vec::new();
893    for (function, local_id, options) in &scalar_specs {
894        let batch = batch_specs
895            .remove(local_id)
896            .or_else(|| batch_specs.remove(&function.sig.ident.to_string()));
897        let generated = generate_scalar_descriptor(
898            function,
899            local_id,
900            options,
901            batch.as_ref(),
902            package_uuid,
903            &type_map,
904        )?;
905        generated_functions.push(generated.items);
906        function_descriptors.push(generated.descriptor);
907    }
908    if let Some((name, (function, _))) = batch_specs.into_iter().next() {
909        return Err(syn::Error::new(
910            function.span(),
911            format!("batch adapter references unknown scalar `{name}`"),
912        ));
913    }
914    let operator_descriptors = operator_specs
915        .iter()
916        .map(|(function, local_id, options)| {
917            generate_operator_descriptor(
918                function,
919                local_id,
920                options,
921                package_uuid,
922                &type_map,
923                &function_ids,
924            )
925        })
926        .collect::<syn::Result<Vec<_>>>()?;
927    let mut opclass_items = Vec::new();
928    let mut opclass_descriptors = Vec::new();
929    for (function, local_id, options) in &opclass_specs {
930        let generated = generate_opclass_descriptor(
931            function,
932            local_id,
933            options,
934            package_uuid,
935            &type_map,
936            &operator_ids,
937        )?;
938        opclass_items.push(generated.items);
939        opclass_descriptors.push(generated.descriptor);
940    }
941    let mut planner_items = Vec::new();
942    let mut planner_descriptors = Vec::new();
943    for (function, local_id, options) in &planner_specs {
944        let generated = generate_planner_descriptor(
945            function,
946            local_id,
947            options,
948            package_uuid,
949            &function_ids,
950            &opclass_ids,
951        )?;
952        planner_items.push(generated.items);
953        planner_descriptors.push(generated.descriptor);
954    }
955
956    let package_caps: u64 = (if !type_specs.is_empty() { 1 } else { 0 })
957        | (if !scalar_specs.is_empty() { 1 << 1 } else { 0 })
958        | (if has_batch { 1 << 2 } else { 0 })
959        | (if !operator_specs.is_empty() {
960            1 << 3
961        } else {
962            0
963        })
964        | (if !opclass_specs.is_empty() { 1 << 4 } else { 0 })
965        | (if !planner_specs.is_empty() { 1 << 5 } else { 0 });
966
967    let source_contract = quote!(#(#items)*).to_string();
968    let descriptor_fingerprint = digest32(format!(
969        "radixdb.package.v1\0{}\0{}\0{}\0{}",
970        package_id,
971        package_name,
972        local_ids.iter().cloned().collect::<Vec<_>>().join("\0"),
973        source_contract,
974    ));
975    let fingerprint_tokens = bytes_tokens(&descriptor_fingerprint);
976    let package_id_tokens = bytes_tokens(&package_uuid);
977    let type_count = type_specs.len() as u32;
978    let function_count = scalar_specs.len() as u32;
979    let operator_count = operator_specs.len() as u32;
980    let opclass_count = opclass_specs.len() as u32;
981    let planner_count = planner_specs.len() as u32;
982
983    let generated = quote! {
984        #(#generated_functions)*
985        #(#opclass_items)*
986        #(#planner_items)*
987
988        static __RADIXDB_TYPES: [::radixdb_plugin::__private::abi::RadixAbiExternalTypeDescriptorV1; #type_count as usize] = [
989            #(#type_descriptors),*
990        ];
991        static __RADIXDB_FUNCTIONS: [::radixdb_plugin::__private::abi::RadixAbiScalarFunctionDescriptorV1; #function_count as usize] = [
992            #(#function_descriptors),*
993        ];
994        static __RADIXDB_OPERATORS: [::radixdb_plugin::__private::abi::RadixAbiOperatorDescriptorV1; #operator_count as usize] = [
995            #(#operator_descriptors),*
996        ];
997        static __RADIXDB_OPERATOR_CLASSES: [::radixdb_plugin::__private::abi::RadixAbiOperatorClassDescriptorV1; #opclass_count as usize] = [
998            #(#opclass_descriptors),*
999        ];
1000        static __RADIXDB_PLANNER_SUPPORT: [::radixdb_plugin::__private::abi::RadixAbiPlannerSupportDescriptorV1; #planner_count as usize] = [
1001            #(#planner_descriptors),*
1002        ];
1003        static __RADIXDB_PACKAGE: ::radixdb_plugin::__private::abi::RadixPluginDescriptorV1 =
1004            ::radixdb_plugin::__private::abi::RadixPluginDescriptorV1 {
1005                header: ::radixdb_plugin::__private::abi::RadixAbiHeaderV1::new::<
1006                    ::radixdb_plugin::__private::abi::RadixPluginDescriptorV1
1007                >(#package_caps),
1008                package_id: [#(#package_id_tokens),*],
1009                package_name: ::radixdb_plugin::__private::abi::RadixAbiSliceV1::from_static(#package_name.as_bytes()),
1010                package_version: ::radixdb_plugin::__private::abi::RadixAbiSliceV1::from_static(#package_version.as_bytes()),
1011                abi_min_minor: ::radixdb_plugin::__private::abi::RADIX_ABI_MINOR,
1012                abi_max_minor: ::radixdb_plugin::__private::abi::RADIX_ABI_MINOR,
1013                reserved: 0,
1014                descriptor_fingerprint: [#(#fingerprint_tokens),*],
1015                type_count: #type_count,
1016                reserved_types: 0,
1017                types: __RADIXDB_TYPES.as_ptr(),
1018                function_count: #function_count,
1019                reserved_functions: 0,
1020                functions: __RADIXDB_FUNCTIONS.as_ptr(),
1021                operator_count: #operator_count,
1022                reserved_operators: 0,
1023                operators: __RADIXDB_OPERATORS.as_ptr(),
1024                operator_class_count: #opclass_count,
1025                reserved_operator_classes: 0,
1026                operator_classes: __RADIXDB_OPERATOR_CLASSES.as_ptr(),
1027                planner_support_count: #planner_count,
1028                reserved_planner_support: 0,
1029                planner_support: __RADIXDB_PLANNER_SUPPORT.as_ptr(),
1030            };
1031
1032        #[doc(hidden)]
1033        pub fn __radixdb_descriptor() -> &'static ::radixdb_plugin::__private::abi::RadixPluginDescriptorV1 {
1034            &__RADIXDB_PACKAGE
1035        }
1036    };
1037    let original_items = items.clone();
1038    let attrs = &module.attrs;
1039    let visibility = &module.vis;
1040    let module_ident = &module.ident;
1041    Ok(quote! {
1042        #(#attrs)* #visibility mod #module_ident {
1043            #(#original_items)*
1044            #generated
1045        }
1046
1047        #[unsafe(no_mangle)]
1048        pub unsafe extern "C" fn radixdb_plugin_entry_v1(
1049            host: *const ::radixdb_plugin::__private::abi::RadixHostApiV1,
1050            status: *mut u32,
1051        ) -> *const ::radixdb_plugin::__private::abi::RadixPluginDescriptorV1 {
1052            let outcome = ::std::panic::catch_unwind(|| {
1053                let host = unsafe { host.as_ref() }.ok_or(())?;
1054                if host.header.abi_major != ::radixdb_plugin::__private::abi::RADIX_ABI_MAJOR
1055                    || host.header.abi_minor < ::radixdb_plugin::__private::abi::RADIX_ABI_MINOR
1056                {
1057                    return Err(());
1058                }
1059                Ok(#module_ident::__radixdb_descriptor() as *const _)
1060            });
1061            match outcome {
1062                Ok(Ok(descriptor)) => {
1063                    if let Some(status) = unsafe { status.as_mut() } {
1064                        *status = ::radixdb_plugin::__private::abi::RADIX_STATUS_OK;
1065                    }
1066                    descriptor
1067                }
1068                Ok(Err(())) => {
1069                    if let Some(status) = unsafe { status.as_mut() } {
1070                        *status = ::radixdb_plugin::__private::abi::RADIX_STATUS_UNSUPPORTED_ABI;
1071                    }
1072                    ::std::ptr::null()
1073                }
1074                Err(_) => {
1075                    if let Some(status) = unsafe { status.as_mut() } {
1076                        *status = ::radixdb_plugin::__private::abi::RADIX_STATUS_PANIC;
1077                    }
1078                    ::std::ptr::null()
1079                }
1080            }
1081        }
1082    })
1083}
1084
1085fn generate_type_descriptor(ident: &Ident, package_id: [u8; 16], local_id: &str) -> TokenStream2 {
1086    let object = bytes_tokens(&object_id(package_id, local_id));
1087    quote! {
1088        ::radixdb_plugin::__private::abi::RadixAbiExternalTypeDescriptorV1 {
1089            header: ::radixdb_plugin::__private::abi::RadixAbiHeaderV1::new::<
1090                ::radixdb_plugin::__private::abi::RadixAbiExternalTypeDescriptorV1
1091            >(0),
1092            object_id: [#(#object),*],
1093            local_id: ::radixdb_plugin::__private::abi::RadixAbiSliceV1::from_static(
1094                <#ident as ::radixdb_plugin::RadixType>::LOCAL_ID.as_bytes()
1095            ),
1096            display_name: ::radixdb_plugin::__private::abi::RadixAbiSliceV1::from_static(
1097                <#ident as ::radixdb_plugin::RadixType>::DISPLAY_NAME.as_bytes()
1098            ),
1099            codec_version: <#ident as ::radixdb_plugin::RadixType>::CODEC_VERSION,
1100            semantic_revision: <#ident as ::radixdb_plugin::RadixType>::SEMANTIC_REVISION,
1101            storage_kind: <#ident as ::radixdb_plugin::RadixType>::STORAGE_KIND,
1102            reserved_u16: 0,
1103            fixed_bytes: <#ident as ::radixdb_plugin::RadixType>::FIXED_BYTES,
1104            max_bytes: <#ident as ::radixdb_plugin::RadixType>::MAX_BYTES,
1105            reserved_u32: 0,
1106            capabilities: <#ident as ::radixdb_plugin::RadixType>::CAPABILITIES,
1107            codec_fingerprint: <#ident as ::radixdb_plugin::RadixType>::CODEC_FINGERPRINT,
1108            encode: <#ident as ::radixdb_plugin::RadixType>::ABI_ENCODE,
1109            decode: <#ident as ::radixdb_plugin::RadixType>::ABI_DECODE,
1110            equality: <#ident as ::radixdb_plugin::RadixType>::ABI_EQUALITY,
1111            hash: <#ident as ::radixdb_plugin::RadixType>::ABI_HASH,
1112            ordering: <#ident as ::radixdb_plugin::RadixType>::ABI_ORDERING,
1113            text_input: None,
1114            text_output: None,
1115            binary_input: None,
1116            binary_output: None,
1117        }
1118    }
1119}
1120
1121struct GeneratedDescriptor {
1122    items: TokenStream2,
1123    descriptor: TokenStream2,
1124}
1125
1126fn generate_scalar_descriptor(
1127    function: &ItemFn,
1128    local_id: &str,
1129    options: &Options,
1130    batch: Option<&(ItemFn, Options)>,
1131    package_id: [u8; 16],
1132    type_map: &BTreeMap<String, ([u8; 16], u32)>,
1133) -> syn::Result<GeneratedDescriptor> {
1134    options.reject_unknown(
1135        &[
1136            "id",
1137            "name",
1138            "semantic_revision",
1139            "cost",
1140            "cancellation",
1141            "max_output_bytes",
1142        ],
1143        &["immutable", "stable", "volatile", "strict", "parallel_safe"],
1144    )?;
1145    let name = options.required_string("name", function.span())?;
1146    validate_sql_name(&name, function.span())?;
1147    let semantic_revision = options.required_u32("semantic_revision", function.span())?;
1148    let cost = options.required_u32("cost", function.span())?;
1149    if semantic_revision == 0 || cost == 0 {
1150        return Err(syn::Error::new(
1151            function.span(),
1152            "semantic_revision and cost must be non-zero",
1153        ));
1154    }
1155    let volatility_flags = ["immutable", "stable", "volatile"]
1156        .into_iter()
1157        .filter(|flag| options.flags.contains(*flag))
1158        .collect::<Vec<_>>();
1159    if volatility_flags.len() != 1 {
1160        return Err(syn::Error::new(
1161            function.span(),
1162            "scalar requires exactly one of immutable, stable, volatile",
1163        ));
1164    }
1165    let volatility = match volatility_flags[0] {
1166        "immutable" => quote!(::radixdb_plugin::__private::abi::RADIX_VOLATILITY_IMMUTABLE),
1167        "stable" => quote!(::radixdb_plugin::__private::abi::RADIX_VOLATILITY_STABLE),
1168        _ => quote!(::radixdb_plugin::__private::abi::RADIX_VOLATILITY_VOLATILE),
1169    };
1170    let cancellation = options.required_string("cancellation", function.span())?;
1171    if cancellation != "bounded" {
1172        return Err(syn::Error::new(
1173            function.span(),
1174            "RadixDB 1.2 scalar cancellation must be \"bounded\"",
1175        ));
1176    }
1177    let arguments = function_arguments(function)?;
1178    let result = plugin_result_type(&function.sig.output)?;
1179    let argument_refs = arguments
1180        .iter()
1181        .map(|(_, ty)| type_ref_tokens(ty, type_map))
1182        .collect::<syn::Result<Vec<_>>>()?;
1183    let result_ref = type_ref_tokens(&result, type_map)?;
1184    let argument_count = arguments.len() as u32;
1185    let function_ident = &function.sig.ident;
1186    let wrapper = format_ident!("__radixdb_scalar_{}", function_ident);
1187    let argument_static = format_ident!(
1188        "__RADIXDB_ARGS_{}",
1189        function_ident.to_string().to_uppercase()
1190    );
1191    let decoded = arguments.iter().enumerate().map(|(index, (ident, ty))| {
1192        quote!(let #ident: #ty = <#ty as ::radixdb_plugin::ValueType>::decode_abi(&__arguments[#index])?;)
1193    });
1194    let call_arguments = arguments.iter().map(|(ident, _)| ident);
1195    let strict = options.flags.contains("strict");
1196    let parallel_safe = options.flags.contains("parallel_safe");
1197    let max_output = options
1198        .u32("max_output_bytes")?
1199        .map(|value| quote!(#value))
1200        .unwrap_or_else(|| quote!(<#result as ::radixdb_plugin::ValueType>::MAX_OUTPUT_BYTES));
1201
1202    let (batch_items, batch_pointer) = if let Some((batch, batch_options)) = batch {
1203        let generated = generate_batch_wrapper(batch, batch_options, argument_count, &result)?;
1204        let wrapper = generated.wrapper;
1205        (generated.items, quote!(Some(#wrapper)))
1206    } else {
1207        (quote!(), quote!(None))
1208    };
1209    let object = bytes_tokens(&object_id(package_id, local_id));
1210    Ok(GeneratedDescriptor {
1211        items: quote! {
1212            static #argument_static: [::radixdb_plugin::__private::abi::RadixAbiTypeRefV1; #argument_count as usize] = [
1213                #(#argument_refs),*
1214            ];
1215            unsafe extern "C" fn #wrapper(
1216                context: *const ::radixdb_plugin::__private::abi::RadixAbiCallContextV1,
1217                arguments: *const ::radixdb_plugin::__private::abi::RadixAbiValueV1,
1218                argument_count: u32,
1219                output: *const ::radixdb_plugin::__private::abi::RadixAbiResultBuilderV1,
1220            ) -> u32 {
1221                unsafe {
1222                    ::radixdb_plugin::__private::run_scalar(
1223                        context,
1224                        arguments,
1225                        argument_count,
1226                        output,
1227                        #argument_count,
1228                        #strict,
1229                        |_context, __arguments, __output| {
1230                            #(#decoded)*
1231                            let __result: #result = #function_ident(#(#call_arguments),*)?;
1232                            __output.push(__result)
1233                        },
1234                    )
1235                }
1236            }
1237            #batch_items
1238        },
1239        descriptor: quote! {
1240            ::radixdb_plugin::__private::abi::RadixAbiScalarFunctionDescriptorV1 {
1241                header: ::radixdb_plugin::__private::abi::RadixAbiHeaderV1::new::<
1242                    ::radixdb_plugin::__private::abi::RadixAbiScalarFunctionDescriptorV1
1243                >(0),
1244                object_id: [#(#object),*],
1245                local_id: ::radixdb_plugin::__private::abi::RadixAbiSliceV1::from_static(#local_id.as_bytes()),
1246                display_name: ::radixdb_plugin::__private::abi::RadixAbiSliceV1::from_static(#name.as_bytes()),
1247                semantic_revision: #semantic_revision,
1248                argument_count: #argument_count,
1249                arguments: #argument_static.as_ptr(),
1250                result: #result_ref,
1251                volatility: #volatility,
1252                cancellation: ::radixdb_plugin::__private::abi::RADIX_CANCELLATION_BOUNDED,
1253                strict: #strict as u8,
1254                parallel_safe: #parallel_safe as u8,
1255                reserved_u16: 0,
1256                cost: #cost,
1257                max_output_bytes: #max_output,
1258                scalar: Some(#wrapper),
1259                batch: #batch_pointer,
1260            }
1261        },
1262    })
1263}
1264
1265struct GeneratedBatch {
1266    items: TokenStream2,
1267    wrapper: Ident,
1268}
1269
1270fn generate_batch_wrapper(
1271    function: &ItemFn,
1272    options: &Options,
1273    expected_columns: u32,
1274    result: &Type,
1275) -> syn::Result<GeneratedBatch> {
1276    options.reject_unknown(&["for_scalar", "rows_per_cancel_check"], &[])?;
1277    let rows = options.required_u32("rows_per_cancel_check", function.span())?;
1278    if rows == 0 {
1279        return Err(syn::Error::new(
1280            function.span(),
1281            "rows_per_cancel_check must be non-zero",
1282        ));
1283    }
1284    let inputs = function
1285        .sig
1286        .inputs
1287        .iter()
1288        .take(expected_columns as usize)
1289        .enumerate()
1290        .map(|(index, argument)| {
1291            let FnArg::Typed(argument) = argument else {
1292                return Err(syn::Error::new(
1293                    argument.span(),
1294                    "methods are not supported",
1295                ));
1296            };
1297            let inner = generic_inner(&argument.ty, "ColumnView").ok_or_else(|| {
1298                syn::Error::new(argument.ty.span(), "batch inputs must be ColumnView<'_, T>")
1299            })?;
1300            let name = format_ident!("__column_{index}");
1301            Ok((name, inner))
1302        })
1303        .collect::<syn::Result<Vec<_>>>()?;
1304    if function.sig.inputs.len() != expected_columns as usize + 2 {
1305        return Err(syn::Error::new(
1306            function.sig.inputs.span(),
1307            "batch signature must contain scalar columns, output builder, and call context",
1308        ));
1309    }
1310    let function_ident = &function.sig.ident;
1311    let wrapper = format_ident!("__radixdb_batch_{}", function_ident);
1312    let columns = inputs
1313        .iter()
1314        .enumerate()
1315        .map(|(index, (name, ty))| quote!(let #name = __input.column::<#ty>(#index)?;));
1316    let names = inputs.iter().map(|(name, _)| name);
1317    Ok(GeneratedBatch {
1318        items: quote! {
1319            unsafe extern "C" fn #wrapper(
1320                context: *const ::radixdb_plugin::__private::abi::RadixAbiCallContextV1,
1321                input: *const ::radixdb_plugin::__private::abi::RadixAbiBatchViewV1,
1322                output: *const ::radixdb_plugin::__private::abi::RadixAbiResultBuilderV1,
1323            ) -> u32 {
1324                unsafe {
1325                    ::radixdb_plugin::__private::run_batch(
1326                        context,
1327                        input,
1328                        output,
1329                        #expected_columns,
1330                        |__context, __input, __raw_output| {
1331                            #(#columns)*
1332                            let mut __output = ::radixdb_plugin::__private::column_builder::<#result>(__raw_output);
1333                            #function_ident(#(#names),*, &mut __output, __context)
1334                        },
1335                    )
1336                }
1337            }
1338        },
1339        wrapper,
1340    })
1341}
1342
1343fn generate_operator_descriptor(
1344    function: &ItemFn,
1345    local_id: &str,
1346    options: &Options,
1347    package_id: [u8; 16],
1348    type_map: &BTreeMap<String, ([u8; 16], u32)>,
1349    function_ids: &BTreeMap<String, [u8; 16]>,
1350) -> syn::Result<TokenStream2> {
1351    options.reject_unknown(
1352        &[
1353            "id",
1354            "symbol",
1355            "semantic_revision",
1356            "function",
1357            "left",
1358            "right",
1359            "result",
1360        ],
1361        &[],
1362    )?;
1363    let symbol = options.required_string("symbol", function.span())?;
1364    let semantic_revision = options.required_u32("semantic_revision", function.span())?;
1365    if semantic_revision == 0 {
1366        return Err(syn::Error::new(
1367            function.span(),
1368            "semantic_revision must be non-zero",
1369        ));
1370    }
1371    let target = options.required_string("function", function.span())?;
1372    let function_id = function_ids.get(&target).ok_or_else(|| {
1373        syn::Error::new(
1374            function.span(),
1375            format!("unknown scalar function `{target}`"),
1376        )
1377    })?;
1378    let left = parse_type_option(options, "left", function.span())?;
1379    let right = parse_type_option(options, "right", function.span())?;
1380    let result = parse_type_option(options, "result", function.span())?;
1381    let left_ref = type_ref_tokens(&left, type_map)?;
1382    let right_ref = type_ref_tokens(&right, type_map)?;
1383    let result_ref = type_ref_tokens(&result, type_map)?;
1384    let object = bytes_tokens(&object_id(package_id, local_id));
1385    let function_object = bytes_tokens(function_id);
1386    Ok(quote! {
1387        ::radixdb_plugin::__private::abi::RadixAbiOperatorDescriptorV1 {
1388            header: ::radixdb_plugin::__private::abi::RadixAbiHeaderV1::new::<
1389                ::radixdb_plugin::__private::abi::RadixAbiOperatorDescriptorV1
1390            >(0),
1391            object_id: [#(#object),*],
1392            local_id: ::radixdb_plugin::__private::abi::RadixAbiSliceV1::from_static(#local_id.as_bytes()),
1393            symbol: ::radixdb_plugin::__private::abi::RadixAbiSliceV1::from_static(#symbol.as_bytes()),
1394            semantic_revision: #semantic_revision,
1395            reserved: 0,
1396            left: #left_ref,
1397            right: #right_ref,
1398            result: #result_ref,
1399            function_id: [#(#function_object),*],
1400        }
1401    })
1402}
1403
1404fn generate_opclass_descriptor(
1405    function: &ItemFn,
1406    local_id: &str,
1407    options: &Options,
1408    package_id: [u8; 16],
1409    type_map: &BTreeMap<String, ([u8; 16], u32)>,
1410    operator_ids: &BTreeMap<(String, String, String), [u8; 16]>,
1411) -> syn::Result<GeneratedDescriptor> {
1412    options.reject_unknown(
1413        &[
1414            "id",
1415            "semantic_revision",
1416            "access_method",
1417            "input",
1418            "key",
1419            "key_codec_revision",
1420        ],
1421        &[],
1422    )?;
1423    let semantic_revision = options.required_u32("semantic_revision", function.span())?;
1424    let key_codec_revision = options.required_u32("key_codec_revision", function.span())?;
1425    if semantic_revision == 0 || key_codec_revision == 0 {
1426        return Err(syn::Error::new(
1427            function.span(),
1428            "semantic_revision and key_codec_revision must be non-zero",
1429        ));
1430    }
1431    let method_name = options.required_string("access_method", function.span())?;
1432    let (method, required_strategies): (TokenStream2, &[(u16, &str)]) = match method_name.as_str() {
1433        "btree" => (
1434            quote!(::radixdb_plugin::__private::abi::RADIX_ACCESS_METHOD_BTREE),
1435            &[(1, "<"), (2, "<="), (3, "="), (4, ">="), (5, ">")],
1436        ),
1437        "hash" => (
1438            quote!(::radixdb_plugin::__private::abi::RADIX_ACCESS_METHOD_HASH),
1439            &[(1, "=")],
1440        ),
1441        "bitmap" => (
1442            quote!(::radixdb_plugin::__private::abi::RADIX_ACCESS_METHOD_BITMAP),
1443            &[(1, "=")],
1444        ),
1445        "hnsw" => {
1446            return Err(syn::Error::new(
1447                function.span(),
1448                "external HNSW operator classes require planner support outside v1.2",
1449            ));
1450        }
1451        _ => {
1452            return Err(syn::Error::new(
1453                function.span(),
1454                "operator class access_method must be core-owned btree/hash/bitmap/hnsw",
1455            ));
1456        }
1457    };
1458    let input = parse_type_option(options, "input", function.span())?;
1459    let key = parse_type_option(options, "key", function.span())?;
1460    let input_ref = type_ref_tokens(&input, type_map)?;
1461    let key_ref = type_ref_tokens(&key, type_map)?;
1462    let function_ident = &function.sig.ident;
1463    let wrapper = format_ident!("__radixdb_key_{}", function_ident);
1464    let law_test = format_ident!("__radixdb_operator_class_laws_{}", function_ident);
1465    let strategy_table = format_ident!(
1466        "__RADIXDB_STRATEGIES_{}",
1467        function_ident.to_string().to_uppercase()
1468    );
1469    let input_key = quote!(#input).to_string();
1470    let strategies = required_strategies
1471        .iter()
1472        .map(|(slot, symbol)| {
1473            let key = ((*symbol).to_owned(), input_key.clone(), input_key.clone());
1474            let object_id = operator_ids.get(&key).ok_or_else(|| {
1475                syn::Error::new(
1476                    function.span(),
1477                    format!("{method_name} operator class requires `{symbol}` over `{input_key}`"),
1478                )
1479            })?;
1480            let object_id = bytes_tokens(object_id);
1481            Ok(quote! {
1482                ::radixdb_plugin::__private::abi::RadixAbiBindingEntryV1 {
1483                    slot: #slot,
1484                    flags: 0,
1485                    object_id: [#(#object_id),*],
1486                }
1487            })
1488        })
1489        .collect::<syn::Result<Vec<_>>>()?;
1490    let strategy_count = strategies.len() as u32;
1491    let object = bytes_tokens(&object_id(package_id, local_id));
1492    let fingerprint = bytes_tokens(&digest32(format!(
1493        "radixdb.opclass.v1\0{local_id}\0{semantic_revision}\0{key_codec_revision}\0{}",
1494        quote!(#function)
1495    )));
1496    let law_check = match method_name.as_str() {
1497        "btree" => quote!(
1498            ::radixdb_plugin::testing::check_btree_operator_class::<#input, #key>(#function_ident)
1499        ),
1500        "hash" => quote!(
1501            ::radixdb_plugin::testing::check_hash_operator_class::<#input>()
1502        ),
1503        "bitmap" => quote!(
1504            ::radixdb_plugin::testing::check_bitmap_operator_class::<#input, #key>(#function_ident)
1505        ),
1506        "hnsw" => quote!(Ok(())),
1507        _ => unreachable!("access method was validated above"),
1508    };
1509    Ok(GeneratedDescriptor {
1510        items: quote! {
1511            static #strategy_table: [
1512                ::radixdb_plugin::__private::abi::RadixAbiBindingEntryV1;
1513                #strategy_count as usize
1514            ] = [#(#strategies),*];
1515
1516            unsafe extern "C" fn #wrapper(
1517                context: *const ::radixdb_plugin::__private::abi::RadixAbiCallContextV1,
1518                value: *const ::radixdb_plugin::__private::abi::RadixAbiValueV1,
1519                output: *const ::radixdb_plugin::__private::abi::RadixAbiResultBuilderV1,
1520            ) -> u32 {
1521                let callback: fn(#input) -> ::radixdb_plugin::PluginResult<#key> = #function_ident;
1522                unsafe {
1523                    ::radixdb_plugin::__private::run_key_encoder::<#input, #key>(
1524                        context, value, output, callback
1525                    )
1526                }
1527            }
1528
1529            #[cfg(test)]
1530            #[test]
1531            fn #law_test() {
1532                #law_check.expect("operator-class law check failed");
1533                ::radixdb_plugin::testing::check_operator_class_strategies::<#input>(
1534                    &__RADIXDB_PACKAGE,
1535                    #local_id,
1536                )
1537                .expect("operator-class strategy law check failed");
1538            }
1539        },
1540        descriptor: quote! {
1541            ::radixdb_plugin::__private::abi::RadixAbiOperatorClassDescriptorV1 {
1542                header: ::radixdb_plugin::__private::abi::RadixAbiHeaderV1::new::<
1543                    ::radixdb_plugin::__private::abi::RadixAbiOperatorClassDescriptorV1
1544                >(0),
1545                object_id: [#(#object),*],
1546                local_id: ::radixdb_plugin::__private::abi::RadixAbiSliceV1::from_static(#local_id.as_bytes()),
1547                semantic_revision: #semantic_revision,
1548                access_method: #method,
1549                reserved_u16: 0,
1550                input_type: #input_ref,
1551                key_type: #key_ref,
1552                key_codec_revision: #key_codec_revision,
1553                strategy_count: #strategy_count,
1554                strategies: #strategy_table.as_ptr(),
1555                support_count: 0,
1556                reserved_u32: 0,
1557                supports: ::std::ptr::null(),
1558                fingerprint: [#(#fingerprint),*],
1559                encode_key: Some(#wrapper),
1560            }
1561        },
1562    })
1563}
1564
1565fn generate_planner_descriptor(
1566    function: &ItemFn,
1567    local_id: &str,
1568    options: &Options,
1569    package_id: [u8; 16],
1570    function_ids: &BTreeMap<String, [u8; 16]>,
1571    opclass_ids: &BTreeMap<String, [u8; 16]>,
1572) -> syn::Result<GeneratedDescriptor> {
1573    options.reject_unknown(
1574        &[
1575            "id",
1576            "name",
1577            "semantic_revision",
1578            "for_function",
1579            "operator_class",
1580            "max_spans",
1581            "max_output_bytes",
1582        ],
1583        &["exact", "always_recheck"],
1584    )?;
1585    let _name = options.required_string("name", function.span())?;
1586    let semantic_revision = options.required_u32("semantic_revision", function.span())?;
1587    let max_spans = options.required_u32("max_spans", function.span())?;
1588    let max_output_bytes = options.required_u32("max_output_bytes", function.span())?;
1589    let policies = ["exact", "always_recheck"]
1590        .into_iter()
1591        .filter(|flag| options.flags.contains(*flag))
1592        .collect::<Vec<_>>();
1593    if policies.len() != 1 {
1594        return Err(syn::Error::new(
1595            function.span(),
1596            "planner support requires exactly one of exact or always_recheck",
1597        ));
1598    }
1599    if semantic_revision == 0 || max_spans == 0 || max_spans > 4096 || max_output_bytes == 0 {
1600        return Err(syn::Error::new(
1601            function.span(),
1602            "semantic_revision and planner bounds must be non-zero and max_spans <= 4096",
1603        ));
1604    }
1605    let target = options.required_string("for_function", function.span())?;
1606    let target_function = function_ids.get(&target).ok_or_else(|| {
1607        syn::Error::new(
1608            function.span(),
1609            format!("unknown target function `{target}`"),
1610        )
1611    })?;
1612    let opclass = options.required_string("operator_class", function.span())?;
1613    let target_opclass = opclass_ids.get(&opclass).ok_or_else(|| {
1614        syn::Error::new(
1615            function.span(),
1616            format!("unknown operator class `{opclass}`"),
1617        )
1618    })?;
1619    let recheck = if policies[0] == "exact" {
1620        quote!(::radixdb_plugin::__private::abi::RADIX_RECHECK_EXACT)
1621    } else {
1622        quote!(::radixdb_plugin::__private::abi::RADIX_RECHECK_ALWAYS)
1623    };
1624    let function_ident = &function.sig.ident;
1625    let wrapper = format_ident!("__radixdb_planner_{}", function_ident);
1626    let object = bytes_tokens(&object_id(package_id, local_id));
1627    let target_function = bytes_tokens(target_function);
1628    let target_opclass = bytes_tokens(target_opclass);
1629    let fingerprint = bytes_tokens(&digest32(format!(
1630        "radixdb.planner.v1\0{local_id}\0{semantic_revision}\0{max_spans}\0{max_output_bytes}\0{}",
1631        policies[0]
1632    )));
1633    Ok(GeneratedDescriptor {
1634        items: quote! {
1635            unsafe extern "C" fn #wrapper(
1636                context: *const ::radixdb_plugin::__private::abi::RadixAbiCallContextV1,
1637                predicate: ::radixdb_plugin::__private::abi::RadixAbiSliceV1,
1638                output: *const ::radixdb_plugin::__private::abi::RadixAbiResultBuilderV1,
1639            ) -> u32 {
1640                unsafe {
1641                    ::radixdb_plugin::__private::run_planner(
1642                        context,
1643                        predicate,
1644                        output,
1645                        |predicate, output| #function_ident(predicate, output),
1646                    )
1647                }
1648            }
1649        },
1650        descriptor: quote! {
1651            ::radixdb_plugin::__private::abi::RadixAbiPlannerSupportDescriptorV1 {
1652                header: ::radixdb_plugin::__private::abi::RadixAbiHeaderV1::new::<
1653                    ::radixdb_plugin::__private::abi::RadixAbiPlannerSupportDescriptorV1
1654                >(0),
1655                object_id: [#(#object),*],
1656                local_id: ::radixdb_plugin::__private::abi::RadixAbiSliceV1::from_static(#local_id.as_bytes()),
1657                semantic_revision: #semantic_revision,
1658                max_spans: #max_spans,
1659                max_output_bytes: #max_output_bytes,
1660                recheck_policy: #recheck,
1661                reserved_u16: 0,
1662                target_function_id: [#(#target_function),*],
1663                target_operator_class_id: [#(#target_opclass),*],
1664                fingerprint: [#(#fingerprint),*],
1665                callback: Some(#wrapper),
1666            }
1667        },
1668    })
1669}
1670
1671fn function_arguments(function: &ItemFn) -> syn::Result<Vec<(Ident, Type)>> {
1672    function
1673        .sig
1674        .inputs
1675        .iter()
1676        .map(|argument| {
1677            let FnArg::Typed(argument) = argument else {
1678                return Err(syn::Error::new(
1679                    argument.span(),
1680                    "plugin methods are not supported",
1681                ));
1682            };
1683            let syn::Pat::Ident(ident) = argument.pat.as_ref() else {
1684                return Err(syn::Error::new(
1685                    argument.pat.span(),
1686                    "plugin arguments require simple names",
1687                ));
1688            };
1689            if matches!(argument.ty.as_ref(), Type::Reference(_)) {
1690                return Err(syn::Error::new(
1691                    argument.ty.span(),
1692                    "scalar arguments must be owned typed values",
1693                ));
1694            }
1695            Ok((ident.ident.clone(), (*argument.ty).clone()))
1696        })
1697        .collect()
1698}
1699
1700fn plugin_result_type(output: &ReturnType) -> syn::Result<Type> {
1701    let ReturnType::Type(_, ty) = output else {
1702        return Err(syn::Error::new(
1703            output.span(),
1704            "plugin function must return PluginResult<T>",
1705        ));
1706    };
1707    generic_inner(ty, "PluginResult")
1708        .ok_or_else(|| syn::Error::new(ty.span(), "plugin function must return PluginResult<T>"))
1709}
1710
1711fn generic_inner(ty: &Type, expected: &str) -> Option<Type> {
1712    let Type::Path(path) = ty else {
1713        return None;
1714    };
1715    let segment = path.path.segments.last()?;
1716    if segment.ident != expected {
1717        return None;
1718    }
1719    let PathArguments::AngleBracketed(arguments) = &segment.arguments else {
1720        return None;
1721    };
1722    arguments.args.iter().find_map(|argument| match argument {
1723        GenericArgument::Type(ty) => Some(ty.clone()),
1724        _ => None,
1725    })
1726}
1727
1728fn vec_inner(ty: &Type) -> Option<&Type> {
1729    let Type::Path(path) = ty else {
1730        return None;
1731    };
1732    let segment = path.path.segments.last()?;
1733    if segment.ident != "Vec" {
1734        return None;
1735    }
1736    let PathArguments::AngleBracketed(arguments) = &segment.arguments else {
1737        return None;
1738    };
1739    arguments.args.iter().find_map(|argument| match argument {
1740        GenericArgument::Type(ty) => Some(ty),
1741        _ => None,
1742    })
1743}
1744
1745fn parse_type_option(options: &Options, name: &str, span: Span) -> syn::Result<Type> {
1746    let expression = options
1747        .values
1748        .get(name)
1749        .ok_or_else(|| syn::Error::new(span, format!("missing required `{name}`")))?;
1750    match expression {
1751        Expr::Path(path) => Ok(Type::Path(syn::TypePath {
1752            qself: None,
1753            path: path.path.clone(),
1754        })),
1755        _ => Err(syn::Error::new(
1756            expression.span(),
1757            "expected Rust type path",
1758        )),
1759    }
1760}
1761
1762fn type_ref_tokens(
1763    ty: &Type,
1764    external: &BTreeMap<String, ([u8; 16], u32)>,
1765) -> syn::Result<TokenStream2> {
1766    let ty = generic_inner(ty, "Option").unwrap_or_else(|| ty.clone());
1767    let ident = terminal_type_ident(&ty)
1768        .ok_or_else(|| syn::Error::new(ty.span(), "unsupported plugin signature type"))?;
1769    let builtin = match ident.as_str() {
1770        "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => Some(1_u16),
1771        "f32" | "f64" => Some(2_u16),
1772        "BoundedText" => Some(3_u16),
1773        "bool" => Some(4_u16),
1774        "BoundedBytes" => Some(11_u16),
1775        _ => None,
1776    };
1777    if let Some(tag) = builtin {
1778        return Ok(quote!(::radixdb_plugin::__private::abi::RadixAbiTypeRefV1::builtin(#tag)));
1779    }
1780    let (object_id, codec) = external.get(&ident).ok_or_else(|| {
1781        syn::Error::new(
1782            ty.span(),
1783            "signature type is neither a supported bounded built-in nor a local RadixType",
1784        )
1785    })?;
1786    let object = bytes_tokens(object_id);
1787    Ok(
1788        quote!(::radixdb_plugin::__private::abi::RadixAbiTypeRefV1::external(
1789            [#(#object),*], #codec
1790        )),
1791    )
1792}
1793
1794fn terminal_type_ident(ty: &Type) -> Option<String> {
1795    let Type::Path(path) = ty else {
1796        return None;
1797    };
1798    path.path
1799        .segments
1800        .last()
1801        .map(|segment| segment.ident.to_string())
1802}
1803
1804fn admit_local_id(ids: &mut BTreeSet<String>, value: &str, span: Span) -> syn::Result<()> {
1805    validate_local_id(value, span)?;
1806    if !ids.insert(value.to_string()) {
1807        return Err(syn::Error::new(
1808            span,
1809            format!("duplicate stable local id `{value}`"),
1810        ));
1811    }
1812    Ok(())
1813}
1814
1815fn validate_local_id(value: &str, span: Span) -> syn::Result<()> {
1816    if value.is_empty()
1817        || value.len() > 255
1818        || !value
1819            .bytes()
1820            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
1821    {
1822        return Err(syn::Error::new(
1823            span,
1824            "local id must match [a-z0-9_]{1,255}",
1825        ));
1826    }
1827    Ok(())
1828}
1829
1830fn validate_package_name(value: &str, span: Span) -> syn::Result<()> {
1831    if value.is_empty()
1832        || value.len() > 128
1833        || !value.bytes().all(|byte| {
1834            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-')
1835        })
1836    {
1837        return Err(syn::Error::new(
1838            span,
1839            "package name must match [a-z0-9_-]{1,128}",
1840        ));
1841    }
1842    Ok(())
1843}
1844
1845fn validate_sql_name(value: &str, span: Span) -> syn::Result<()> {
1846    if value.is_empty() || value.len() > 255 || value.contains('\0') {
1847        return Err(syn::Error::new(span, "SQL name is empty or too large"));
1848    }
1849    Ok(())
1850}
1851
1852fn object_id(package_id: [u8; 16], local_id: &str) -> [u8; 16] {
1853    let mut digest = Sha256::new();
1854    digest.update(b"radixdb.plugin.object.v1\0");
1855    digest.update(package_id);
1856    digest.update((local_id.len() as u32).to_le_bytes());
1857    digest.update(local_id.as_bytes());
1858    digest.finalize()[..16].try_into().unwrap()
1859}
1860
1861fn digest32(value: String) -> [u8; 32] {
1862    Sha256::digest(value.as_bytes()).into()
1863}
1864
1865fn bytes_tokens<const N: usize>(bytes: &[u8; N]) -> Vec<syn::LitInt> {
1866    bytes
1867        .iter()
1868        .map(|byte| syn::LitInt::new(&byte.to_string(), Span::call_site()))
1869        .collect()
1870}
1871
1872#[cfg(test)]
1873mod tests {
1874    use super::object_id;
1875
1876    #[test]
1877    fn sql_rename_cannot_change_object_identity() {
1878        let package = [0x5a; 16];
1879        let before_sql_rename = object_id(package, "distance");
1880        let after_sql_rename = object_id(package, "distance");
1881        assert_eq!(before_sql_rename, after_sql_rename);
1882        assert_ne!(before_sql_rename, object_id(package, "distance_v2"));
1883        assert_ne!(before_sql_rename, object_id([0xa5; 16], "distance"));
1884    }
1885}