use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::{format_ident, quote};
use syn::{parse_macro_input, Data, DeriveInput, Fields, Lit, Meta};
#[proc_macro_derive(RustioAdmin, attributes(rustio))]
pub fn derive_rustio_admin(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
expand(input)
.unwrap_or_else(|e| e.to_compile_error())
.into()
}
fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
let struct_name = &input.ident;
let fields = struct_fields(&input)?;
let admin_name = plural_snake(&struct_name.to_string());
let display_name = humanise(&plural_snake(&struct_name.to_string()));
let singular = struct_name.to_string();
let mut field_metas = Vec::new();
let mut display_value_arms = Vec::new();
let mut from_form_parses = Vec::new();
let mut from_form_fields = Vec::new();
let mut update_tuples = Vec::new();
for f in fields {
let fname = f.ident.as_ref().unwrap();
let fname_str = fname.to_string();
let kind = classify_type(&f.ty)?;
let kind = if matches!(kind, FieldKind::DateTime) && is_auto_timestamp_name(&fname_str) {
FieldKind::DateTimeAuto
} else {
kind
};
let editable = fname_str != "id" && kind != FieldKind::DateTimeAuto;
let type_variant = kind.field_type_ident();
let relation = parse_relation_attr(&f.attrs, &fname_str)?;
let relation_tokens = match &relation {
Some((target, display)) => {
let display_tok = match display {
Some(d) => quote! { ::std::option::Option::Some(#d) },
None => quote! { ::std::option::Option::None },
};
quote! {
::std::option::Option::Some(::rustio_core::admin::AdminRelation {
target_model: #target,
display_field: #display_tok,
multi: false,
})
}
}
None => quote! { ::std::option::Option::None },
};
field_metas.push(quote! {
::rustio_core::admin::AdminField {
name: #fname_str,
label: #fname_str,
field_type: ::rustio_core::admin::FieldType::#type_variant,
editable: #editable,
relation: #relation_tokens,
choices: ::std::option::Option::None,
}
});
let display_arm = match kind {
FieldKind::String => quote! {
out.push((#fname_str.to_string(), self.#fname.clone()));
},
FieldKind::OptionalString => quote! {
out.push((#fname_str.to_string(), match &self.#fname {
Some(v) => v.clone(),
None => String::new(),
}));
},
FieldKind::I32 | FieldKind::I64 => quote! {
out.push((#fname_str.to_string(), self.#fname.to_string()));
},
FieldKind::OptionalI64 => quote! {
out.push((#fname_str.to_string(), match &self.#fname {
Some(v) => v.to_string(),
None => String::new(),
}));
},
FieldKind::Bool => quote! {
out.push((#fname_str.to_string(), if self.#fname { "true".to_string() } else { "false".to_string() }));
},
FieldKind::DateTime | FieldKind::DateTimeAuto => quote! {
out.push((#fname_str.to_string(), self.#fname.format("%Y-%m-%dT%H:%M").to_string()));
},
};
display_value_arms.push(display_arm);
if fname_str == "id" {
from_form_fields.push(quote! { #fname: 0 });
continue;
}
let humanised_label = humanise_field(&fname_str);
let required_msg = format!("{humanised_label} is required.");
let number_msg = format!("{humanised_label} must be a number.");
let date_invalid_msg = format!("{humanised_label} is not a valid date.");
match kind {
FieldKind::String => {
from_form_parses.push(quote! {
let #fname = match form.get(#fname_str).map(str::trim) {
Some(v) if !v.is_empty() => v.to_string(),
_ => { errors.push(#required_msg.to_string()); String::new() }
};
});
from_form_fields.push(quote! { #fname });
}
FieldKind::OptionalString => {
from_form_parses.push(quote! {
let #fname: Option<String> = form
.get(#fname_str)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
});
from_form_fields.push(quote! { #fname });
}
FieldKind::I32 => {
from_form_parses.push(quote! {
let #fname: i32 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
Some(v) => v,
None => { errors.push(#number_msg.to_string()); 0 }
};
});
from_form_fields.push(quote! { #fname });
}
FieldKind::I64 => {
from_form_parses.push(quote! {
let #fname: i64 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
Some(v) => v,
None => { errors.push(#number_msg.to_string()); 0 }
};
});
from_form_fields.push(quote! { #fname });
}
FieldKind::OptionalI64 => {
from_form_parses.push(quote! {
let #fname: Option<i64> = match form.get(#fname_str).map(str::trim) {
None | Some("") => None,
Some(raw) => match raw.parse::<i64>() {
Ok(n) => Some(n),
Err(_) => {
errors.push(#number_msg.to_string());
None
}
},
};
});
from_form_fields.push(quote! { #fname });
}
FieldKind::Bool => {
from_form_parses.push(quote! {
let #fname: bool = form.bool_flag(#fname_str);
});
from_form_fields.push(quote! { #fname });
}
FieldKind::DateTime => {
from_form_parses.push(quote! {
let #fname = match form.get(#fname_str) {
Some(raw) if !raw.is_empty() => {
match ::chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M") {
Ok(dt) => ::chrono::DateTime::<::chrono::Utc>::from_naive_utc_and_offset(dt, ::chrono::Utc),
Err(_) => { errors.push(#date_invalid_msg.to_string()); ::chrono::Utc::now() }
}
}
_ => { errors.push(#required_msg.to_string()); ::chrono::Utc::now() }
};
});
from_form_fields.push(quote! { #fname });
}
FieldKind::DateTimeAuto => {
from_form_parses.push(quote! {
let #fname = ::chrono::Utc::now();
});
from_form_fields.push(quote! { #fname });
}
}
update_tuples.push(quote! {
(#fname_str, self.#fname.clone().into())
});
}
let object_label_expr = find_label_field(fields)
.map(|n| {
let id = format_ident!("{n}");
quote! { self.#id.clone().to_string() }
})
.unwrap_or_else(|| quote! { format!("#{}", self.id) });
Ok(quote! {
impl ::rustio_core::admin::AdminModel for #struct_name {
const ADMIN_NAME: &'static str = #admin_name;
const DISPLAY_NAME: &'static str = #display_name;
const SINGULAR_NAME: &'static str = #singular;
const FIELDS: &'static [::rustio_core::admin::AdminField] = &[
#(#field_metas),*
];
fn display_values(&self) -> ::std::vec::Vec<(::std::string::String, ::std::string::String)> {
let mut out = ::std::vec::Vec::new();
#(#display_value_arms)*
out
}
fn from_form(form: &::rustio_core::http::FormData) -> ::std::result::Result<Self, ::std::vec::Vec<::std::string::String>>
where
Self: Sized,
{
let mut errors: ::std::vec::Vec<::std::string::String> = ::std::vec::Vec::new();
#(#from_form_parses)*
if !errors.is_empty() {
return Err(errors);
}
Ok(Self { #(#from_form_fields),* })
}
fn object_label(&self) -> ::std::string::String {
#object_label_expr
}
fn id(&self) -> i64 {
self.id
}
fn values_to_update(&self) -> ::std::vec::Vec<(&'static str, ::rustio_core::orm::Value)> {
::std::vec![#(#update_tuples),*]
}
}
})
}
fn struct_fields(input: &DeriveInput) -> syn::Result<&syn::punctuated::Punctuated<syn::Field, syn::Token![,]>> {
let data = match &input.data {
Data::Struct(s) => s,
_ => {
return Err(syn::Error::new_spanned(
&input.ident,
"RustioAdmin can only derive on structs",
))
}
};
match &data.fields {
Fields::Named(named) => Ok(&named.named),
_ => Err(syn::Error::new_spanned(
&input.ident,
"RustioAdmin requires a struct with named fields",
)),
}
}
#[derive(Debug, PartialEq, Clone, Copy)]
enum FieldKind {
I32,
I64,
Bool,
String,
DateTime,
DateTimeAuto,
OptionalString,
OptionalI64,
}
impl FieldKind {
fn field_type_ident(&self) -> proc_macro2::Ident {
match self {
FieldKind::I32 => format_ident!("I32"),
FieldKind::I64 => format_ident!("I64"),
FieldKind::Bool => format_ident!("Bool"),
FieldKind::String => format_ident!("String"),
FieldKind::DateTime | FieldKind::DateTimeAuto => format_ident!("DateTime"),
FieldKind::OptionalString => format_ident!("OptionalString"),
FieldKind::OptionalI64 => format_ident!("OptionalI64"),
}
}
}
fn is_auto_timestamp_name(name: &str) -> bool {
matches!(name, "created_at" | "updated_at")
}
fn humanise_field(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut next_upper = true;
for ch in s.chars() {
if ch == '_' {
out.push(' ');
next_upper = true;
} else if next_upper {
out.push(ch.to_ascii_uppercase());
next_upper = false;
} else {
out.push(ch);
}
}
out
}
fn classify_type(ty: &syn::Type) -> syn::Result<FieldKind> {
let as_string = quote! { #ty }.to_string().replace(' ', "");
let kind = match as_string.as_str() {
"i32" => FieldKind::I32,
"i64" => FieldKind::I64,
"bool" => FieldKind::Bool,
"String" => FieldKind::String,
"DateTime<Utc>" | "chrono::DateTime<chrono::Utc>" => FieldKind::DateTime,
"Option<String>" => FieldKind::OptionalString,
"Option<i64>" => FieldKind::OptionalI64,
other => {
return Err(syn::Error::new_spanned(
ty,
format!("unsupported field type for RustioAdmin: {other}"),
))
}
};
Ok(kind)
}
fn parse_relation_attr(
attrs: &[syn::Attribute],
field_name: &str,
) -> syn::Result<Option<(String, Option<String>)>> {
for attr in attrs {
if !attr.path().is_ident("rustio") {
continue;
}
let mut target: Option<String> = None;
let mut display: Option<String> = None;
attr.parse_nested_meta(|m| {
if m.path.is_ident("belongs_to") {
let value = m.value()?;
let lit: Lit = value.parse()?;
if let Lit::Str(s) = lit {
target = Some(s.value());
}
Ok(())
} else if m.path.is_ident("display") {
let value = m.value()?;
let lit: Lit = value.parse()?;
if let Lit::Str(s) = lit {
display = Some(s.value());
}
Ok(())
} else {
Err(m.error(format!("unknown rustio attribute for field `{field_name}`")))
}
})?;
if let Some(t) = target {
return Ok(Some((t, display)));
}
if display.is_some() {
return Err(syn::Error::new_spanned(
attr,
"`display` requires `belongs_to` alongside it",
));
}
}
let _ = std::marker::PhantomData::<Meta>;
Ok(None)
}
fn plural_snake(camel: &str) -> String {
let snake = camel_to_snake(camel);
if snake.ends_with('s') {
snake
} else {
format!("{snake}s")
}
}
fn camel_to_snake(s: &str) -> String {
let mut out = String::new();
for (i, c) in s.chars().enumerate() {
if c.is_ascii_uppercase() && i > 0 {
out.push('_');
}
out.push(c.to_ascii_lowercase());
}
out
}
fn humanise(snake: &str) -> String {
let mut chars = snake.chars();
let mut out = String::new();
if let Some(first) = chars.next() {
out.push(first.to_ascii_uppercase());
}
for c in chars {
if c == '_' {
out.push(' ');
} else {
out.push(c);
}
}
out
}
fn find_label_field(fields: &syn::punctuated::Punctuated<syn::Field, syn::Token![,]>) -> Option<String> {
let names = ["name", "title", "full_name", "label", "email"];
for candidate in names {
if fields
.iter()
.any(|f| f.ident.as_ref().map(|i| i == candidate).unwrap_or(false))
{
return Some(candidate.to_string());
}
}
None
}
#[proc_macro_derive(RustioModel, attributes(rustio))]
pub fn derive_rustio_model(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
rustio_model::expand(input)
.unwrap_or_else(|e| e.to_compile_error())
.into()
}
mod rustio_model {
use super::*;
use syn::{
parse::{Parse, ParseStream},
Attribute, GenericArgument, LitStr, PathArguments, Token, Type,
};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum RustTypeKind {
I32,
I64,
F64,
Bool,
String,
DateTimeUtc,
JsonValue,
Decimal,
Uuid,
}
impl RustTypeKind {
fn to_token(self) -> TokenStream2 {
match self {
RustTypeKind::I32 => quote! { ::rustio_core::contract::RustType::I32 },
RustTypeKind::I64 => quote! { ::rustio_core::contract::RustType::I64 },
RustTypeKind::F64 => quote! { ::rustio_core::contract::RustType::F64 },
RustTypeKind::Bool => quote! { ::rustio_core::contract::RustType::Bool },
RustTypeKind::String => quote! { ::rustio_core::contract::RustType::String },
RustTypeKind::DateTimeUtc => {
quote! { ::rustio_core::contract::RustType::DateTimeUtc }
}
RustTypeKind::JsonValue => {
quote! { ::rustio_core::contract::RustType::JsonValue }
}
RustTypeKind::Decimal => quote! { ::rustio_core::contract::RustType::Decimal },
RustTypeKind::Uuid => quote! { ::rustio_core::contract::RustType::Uuid },
}
}
}
#[derive(Default)]
struct FieldAttr {
sql: String,
searchable: bool,
filterable: bool,
sortable: bool,
readonly: bool,
widget: Option<String>,
label: Option<String>,
references: Option<String>,
}
pub(super) fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
if !input.generics.params.is_empty() {
return Err(syn::Error::new_spanned(
&input.ident,
"RustioModel does not support generic structs (yet)",
));
}
let struct_name = &input.ident;
let fields = match &input.data {
Data::Struct(ds) => match &ds.fields {
Fields::Named(f) => &f.named,
_ => {
return Err(syn::Error::new_spanned(
struct_name,
"RustioModel requires a named-field struct (no tuple structs)",
));
}
},
_ => {
return Err(syn::Error::new_spanned(
struct_name,
"RustioModel can only be derived on structs",
));
}
};
let table = parse_table_attr(&input.attrs)?;
let mut column_exprs = Vec::new();
let mut primary_key: Option<String> = None;
let mut has_searchable = false;
for field in fields {
let field_name = field
.ident
.as_ref()
.expect("named struct fields have idents")
.to_string();
let field_attr = parse_field_attr(&field.attrs)?;
if field_attr.sql.is_empty() {
return Err(syn::Error::new_spanned(
field,
format!("field `{field_name}` is missing the required `#[rustio(sql = \"...\")]` attribute"),
));
}
let (kind, nullable) = classify(&field.ty)?;
validate_field_rules(&field_name, &field_attr.sql, kind, &field.ty)?;
let sql_upper = field_attr.sql.to_uppercase();
let is_pk = sql_upper.contains("PRIMARY KEY");
if is_pk {
if let Some(prev) = &primary_key {
return Err(syn::Error::new_spanned(
field,
format!(
"more than one field declares PRIMARY KEY: `{prev}` and `{field_name}`"
),
));
}
primary_key = Some(field_name.clone());
}
if field_attr.searchable {
has_searchable = true;
}
column_exprs.push(build_column_expr(&field_name, &field_attr, kind, nullable, is_pk));
}
let pk = primary_key.ok_or_else(|| {
syn::Error::new_spanned(
struct_name,
"RustioModel requires at least one field whose `sql = \"...\"` declares PRIMARY KEY",
)
})?;
let schema_init = if has_searchable {
quote! {
::rustio_core::contract::ModelSchema::new(#table, __COLS, #pk)
.with_search_index(#table)
}
} else {
quote! {
::rustio_core::contract::ModelSchema::new(#table, __COLS, #pk)
}
};
Ok(quote! {
impl ::rustio_core::contract::HasSchema for #struct_name {
const SCHEMA: ::rustio_core::contract::ModelSchema = {
const __COLS: &[::rustio_core::contract::ModelColumn] = &[
#(#column_exprs),*
];
#schema_init
};
}
})
}
fn classify(ty: &Type) -> syn::Result<(RustTypeKind, bool)> {
if let Some(inner) = unwrap_option(ty) {
let (k, _) = classify(inner)?;
return Ok((k, true));
}
let path = match ty {
Type::Path(tp) => tp,
_ => {
return Err(syn::Error::new_spanned(
ty,
"RustioModel: unsupported type shape (need a simple path type)",
));
}
};
let last = path
.path
.segments
.last()
.ok_or_else(|| syn::Error::new_spanned(ty, "RustioModel: empty type path"))?;
let name = last.ident.to_string();
if name == "DateTime" {
if let PathArguments::AngleBracketed(args) = &last.arguments {
let mut got_utc = false;
for arg in &args.args {
if let GenericArgument::Type(Type::Path(tp)) = arg {
if tp
.path
.segments
.last()
.map(|s| s.ident == "Utc")
.unwrap_or(false)
{
got_utc = true;
}
}
}
if got_utc {
return Ok((RustTypeKind::DateTimeUtc, false));
}
}
return Err(syn::Error::new_spanned(
ty,
"RustioModel: only `DateTime<Utc>` is supported (Type Rule #2). Other timezone parameters are not accepted.",
));
}
if name == "NaiveDateTime" {
return Err(syn::Error::new_spanned(
ty,
"RustioModel: `NaiveDateTime` is forbidden (Type Rule #2) — use `chrono::DateTime<chrono::Utc>` for all timestamp columns",
));
}
let kind = match name.as_str() {
"i32" => RustTypeKind::I32,
"i64" => RustTypeKind::I64,
"f64" => RustTypeKind::F64,
"bool" => RustTypeKind::Bool,
"String" => RustTypeKind::String,
"Value" => RustTypeKind::JsonValue, "Decimal" => RustTypeKind::Decimal, "Uuid" => RustTypeKind::Uuid, other => {
return Err(syn::Error::new_spanned(
ty,
format!(
"RustioModel: unsupported field type `{other}`. \
Supported: i32, i64, f64, bool, String, \
DateTime<Utc>, serde_json::Value, \
rust_decimal::Decimal, uuid::Uuid \
(and Option<T> for any of the above)."
),
));
}
};
Ok((kind, false))
}
fn unwrap_option(ty: &Type) -> Option<&Type> {
let path = match ty {
Type::Path(tp) => &tp.path,
_ => return None,
};
let last = path.segments.last()?;
if last.ident != "Option" {
return None;
}
let args = match &last.arguments {
PathArguments::AngleBracketed(a) => a,
_ => return None,
};
for arg in &args.args {
if let GenericArgument::Type(t) = arg {
return Some(t);
}
}
None
}
fn validate_field_rules(
name: &str,
sql: &str,
kind: RustTypeKind,
ty: &Type,
) -> syn::Result<()> {
let sql_upper = sql.to_uppercase();
if name == "id" && kind != RustTypeKind::I64 {
return Err(syn::Error::new_spanned(
ty,
"Type Rule #1: field `id` must be `i64` (mapped to BIGINT/BIGSERIAL). \
Using a smaller integer type for IDs silently truncates at 2.1B rows.",
));
}
let has_numeric_token = sql_upper
.split(|c: char| !c.is_alphanumeric())
.any(|t| t == "NUMERIC" || t == "DECIMAL");
if has_numeric_token && kind != RustTypeKind::Decimal {
return Err(syn::Error::new_spanned(
ty,
"Type Rule #3: NUMERIC/DECIMAL columns must pair with \
`rust_decimal::Decimal`. Using `f64` (or any other type) \
for money loses precision under arithmetic.",
));
}
Ok(())
}
fn build_column_expr(
name: &str,
attr: &FieldAttr,
kind: RustTypeKind,
nullable: bool,
is_pk: bool,
) -> TokenStream2 {
let name_lit = LitStr::new(name, proc_macro2::Span::call_site());
let sql_lit = LitStr::new(&attr.sql, proc_macro2::Span::call_site());
let kind_token = kind.to_token();
let mut expr = quote! {
::rustio_core::contract::ModelColumn::new(#name_lit, #sql_lit, #kind_token)
};
if nullable {
expr = quote! { #expr.nullable() };
}
if is_pk {
expr = quote! { #expr.primary_key() };
}
let s = attr.searchable;
let f = attr.filterable;
let so = attr.sortable;
let r = attr.readonly;
let flags_expr = if !s && !f && !so && !r {
quote! { ::rustio_core::contract::SchemaFlags::empty() }
} else {
let mut mutations = Vec::new();
if s {
mutations.push(quote! { __f.searchable = true; });
}
if f {
mutations.push(quote! { __f.filterable = true; });
}
if so {
mutations.push(quote! { __f.sortable = true; });
}
if r {
mutations.push(quote! { __f.readonly = true; });
}
quote! {
{
let mut __f = ::rustio_core::contract::SchemaFlags::empty();
#(#mutations)*
__f
}
}
};
expr = quote! { #expr.with_flags(#flags_expr) };
if let Some(label) = &attr.label {
let l = LitStr::new(label, proc_macro2::Span::call_site());
expr = quote! { #expr.with_label(#l) };
}
if let Some(widget) = &attr.widget {
let w = LitStr::new(widget, proc_macro2::Span::call_site());
expr = quote! { #expr.with_widget(#w) };
}
let _ = &attr.references;
expr
}
fn parse_table_attr(attrs: &[Attribute]) -> syn::Result<String> {
for attr in attrs {
if !attr.path().is_ident("rustio") {
continue;
}
let parsed: TableAttr = attr.parse_args()?;
return Ok(parsed.table);
}
Err(syn::Error::new(
proc_macro2::Span::call_site(),
"RustioModel requires a `#[rustio(table = \"...\")]` attribute on the struct",
))
}
fn parse_field_attr(attrs: &[Attribute]) -> syn::Result<FieldAttr> {
let mut out = FieldAttr::default();
for attr in attrs {
if !attr.path().is_ident("rustio") {
continue;
}
let parsed: FieldAttrTokens = attr.parse_args()?;
for entry in parsed.entries {
match entry {
AttrEntry::Sql(s) => out.sql = s,
AttrEntry::Searchable => out.searchable = true,
AttrEntry::Filterable => out.filterable = true,
AttrEntry::Sortable => out.sortable = true,
AttrEntry::Readonly => out.readonly = true,
AttrEntry::Widget(s) => out.widget = Some(s),
AttrEntry::Label(s) => out.label = Some(s),
AttrEntry::References(s) => out.references = Some(s),
}
}
}
Ok(out)
}
struct TableAttr {
table: String,
}
impl Parse for TableAttr {
fn parse(input: ParseStream) -> syn::Result<Self> {
let key: syn::Ident = input.parse()?;
if key != "table" {
return Err(syn::Error::new(
key.span(),
"expected `table = \"...\"` on the struct",
));
}
input.parse::<Token![=]>()?;
let value: LitStr = input.parse()?;
Ok(Self { table: value.value() })
}
}
enum AttrEntry {
Sql(String),
Searchable,
Filterable,
Sortable,
Readonly,
Widget(String),
Label(String),
References(String),
}
struct FieldAttrTokens {
entries: Vec<AttrEntry>,
}
impl Parse for FieldAttrTokens {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut entries = Vec::new();
loop {
if input.is_empty() {
break;
}
let key: syn::Ident = input.parse()?;
let key_str = key.to_string();
let entry = match key_str.as_str() {
"sql" => {
input.parse::<Token![=]>()?;
AttrEntry::Sql(input.parse::<LitStr>()?.value())
}
"searchable" => AttrEntry::Searchable,
"filterable" => AttrEntry::Filterable,
"sortable" => AttrEntry::Sortable,
"readonly" => AttrEntry::Readonly,
"widget" => {
input.parse::<Token![=]>()?;
AttrEntry::Widget(input.parse::<LitStr>()?.value())
}
"label" => {
input.parse::<Token![=]>()?;
AttrEntry::Label(input.parse::<LitStr>()?.value())
}
"references" => {
input.parse::<Token![=]>()?;
AttrEntry::References(input.parse::<LitStr>()?.value())
}
other => {
return Err(syn::Error::new(
key.span(),
format!(
"unknown rustio attribute `{other}`. \
Known: sql, searchable, filterable, sortable, \
readonly, widget, label, references."
),
));
}
};
entries.push(entry);
if input.is_empty() {
break;
}
input.parse::<Token![,]>()?;
}
Ok(Self { entries })
}
}
}