use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::LitStr;
use crate::model::{infer_table_name, pascal_to_snake};
#[derive(Debug)]
pub struct CommentableSpec {
pub author_model: Option<syn::Ident>,
pub type_name: String,
pub table: String,
pub comment_pk: String,
pub type_column: String,
pub id_column: String,
pub parent_column: String,
pub author_column: String,
pub body_column: String,
pub created_at_column: String,
pub soft_delete: bool,
pub counter_column: Option<String>,
pub author_table: String,
pub author_pk: String,
pub author_name_column: Option<String>,
pub max_depth: u32,
pub max_body_bytes: usize,
pub span: proc_macro2::Span,
}
pub fn is_commentable_attr(attr: &syn::Attribute) -> bool {
attr.path().is_ident("commentable")
}
fn check_ident_value(key: &syn::Ident, value: &str, span: proc_macro2::Span) -> syn::Result<()> {
let plain = !value.is_empty()
&& !value.starts_with(|c: char| c.is_ascii_digit())
&& value.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if plain {
return Ok(());
}
Err(syn::Error::new(
span,
format!(
"`{value}` is not a valid identifier for `{key} = ...` in \
`#[commentable]`: the value is spliced verbatim into the generated \
SQL's table and column names, so it must be a plain identifier — \
ASCII letters, digits and underscores only, and no leading digit"
),
))
}
const MAX_DEPTH_CEILING: u32 = 1_000;
const KNOWN_KEYS: &[&str] = &[
"by",
"type_name",
"table",
"comment_pk",
"type_column",
"id_column",
"parent_column",
"author_column",
"body_column",
"created_at_column",
"soft_delete",
"counter_cache",
"author_table",
"author_pk",
"author_name",
"max_depth",
"max_body",
];
struct KeyValue {
value: String,
span: proc_macro2::Span,
boolean: Option<bool>,
integer: Option<u64>,
}
#[allow(clippy::too_many_lines)]
pub fn parse_commentable_attr(
attr: &syn::Attribute,
model_ident: &syn::Ident,
) -> syn::Result<CommentableSpec> {
use syn::parse::ParseStream;
let span = attr.path().span_of_attr();
let mut author_model: Option<syn::Ident> = None;
let mut pairs: std::collections::HashMap<String, KeyValue> = std::collections::HashMap::new();
if !matches!(attr.meta, syn::Meta::Path(_)) {
attr.parse_args_with(|input: ParseStream| {
while !input.is_empty() {
let key: syn::Ident = input.parse()?;
input.parse::<syn::Token![=]>()?;
let parsed = if input.peek(LitStr) {
let lit: LitStr = input.parse()?;
KeyValue {
value: lit.value(),
span: lit.span(),
boolean: None,
integer: None,
}
} else if input.peek(syn::LitInt) {
let lit: syn::LitInt = input.parse()?;
KeyValue {
value: lit.base10_digits().to_owned(),
span: lit.span(),
boolean: None,
integer: Some(lit.base10_parse::<u64>()?),
}
} else if input.peek(syn::LitBool) {
let lit: syn::LitBool = input.parse()?;
KeyValue {
value: lit.value.to_string(),
span: lit.span(),
boolean: Some(lit.value),
integer: None,
}
} else {
let ident: syn::Ident = input.parse()?;
if key == "by" {
author_model = Some(ident.clone());
}
KeyValue {
value: ident.to_string(),
span: ident.span(),
boolean: None,
integer: None,
}
};
if key == "by" && author_model.is_none() {
return Err(syn::Error::new(
parsed.span,
format!(
"`#[commentable(by = ...)]` takes a bare type name, \
found `{}`: write `by = User`, not `by = \"User\"` \
— a quoted or numeric value would be accepted and \
then ignored, leaving the model with no author",
parsed.value
),
));
}
if let Some(previous) = pairs.insert(key.to_string(), parsed) {
let now = &pairs[&key.to_string()].value;
return Err(syn::Error::new_spanned(
&key,
format!(
"duplicate `{key} = ...` in `#[commentable]` (was \
`{}`, now `{now}`): each key may be given at most \
once — the later value would silently win",
previous.value
),
));
}
if !input.is_empty() {
input.parse::<syn::Token![,]>()?;
}
}
Ok(())
})?;
}
for (key, parsed) in &pairs {
if !KNOWN_KEYS.contains(&key.as_str()) {
return Err(syn::Error::new(
parsed.span,
format!(
"unknown key `{key}` in `#[commentable]`; expected one of: {}",
KNOWN_KEYS.join(", ")
),
));
}
}
let ident_value = |key: &str, default: &str| -> syn::Result<String> {
match pairs.get(key) {
None => Ok(default.to_owned()),
Some(parsed) => {
let key_ident = syn::Ident::new(key, parsed.span);
check_ident_value(&key_ident, &parsed.value, parsed.span)?;
Ok(parsed.value.clone())
}
}
};
let author_table_default = author_model
.as_ref()
.map_or_else(String::new, infer_table_name);
if let Some(parsed) = pairs.get("author_name")
&& author_table_default.is_empty()
&& !pairs.contains_key("author_table")
{
return Err(syn::Error::new(
parsed.span,
"`author_name` in `#[commentable]` needs a table to read the name \
from: add `by = <AuthorModel>` (whose table is derived by \
convention) or `author_table = <table>`",
));
}
let counter_column = match pairs.get("counter_cache") {
Some(parsed) if parsed.boolean == Some(false) => None,
Some(parsed) if parsed.boolean == Some(true) => Some("comment_count".to_owned()),
Some(parsed) => {
let key_ident = syn::Ident::new("counter_cache", parsed.span);
check_ident_value(&key_ident, &parsed.value, parsed.span)?;
Some(parsed.value.clone())
}
None => Some("comment_count".to_owned()),
};
let author_name_column = match pairs.get("author_name") {
None => None,
Some(parsed) => {
let key_ident = syn::Ident::new("author_name", parsed.span);
check_ident_value(&key_ident, &parsed.value, parsed.span)?;
Some(parsed.value.clone())
}
};
let soft_delete = match pairs.get("soft_delete") {
None => true,
Some(parsed) => parsed.boolean.ok_or_else(|| {
syn::Error::new(
parsed.span,
"`soft_delete` in `#[commentable]` takes `true` or `false`",
)
})?,
};
let max_depth = match pairs.get("max_depth") {
None => 5_u32,
Some(parsed) => {
let raw = parsed.integer.ok_or_else(|| {
syn::Error::new(
parsed.span,
"`max_depth` in `#[commentable]` takes an integer",
)
})?;
let depth = u32::try_from(raw).map_err(|_| {
syn::Error::new(parsed.span, "`max_depth` in `#[commentable]` is too large")
})?;
if depth >= MAX_DEPTH_CEILING {
return Err(syn::Error::new(
parsed.span,
format!(
"`max_depth` in `#[commentable]` must be below {MAX_DEPTH_CEILING}: \
the runtime's depth probe stops recursing there, so a larger cap \
could not be enforced"
),
));
}
depth
}
};
let max_body_bytes = match pairs.get("max_body") {
None => 10_000_usize,
Some(parsed) => {
let raw = parsed.integer.ok_or_else(|| {
syn::Error::new(
parsed.span,
"`max_body` in `#[commentable]` takes an integer (bytes)",
)
})?;
if raw == 0 {
return Err(syn::Error::new(
parsed.span,
"`max_body` in `#[commentable]` must be at least 1: a cap of \
zero rejects every comment",
));
}
usize::try_from(raw).map_err(|_| {
syn::Error::new(parsed.span, "`max_body` in `#[commentable]` is too large")
})?
}
};
let type_name = match pairs.get("type_name") {
None => model_ident.to_string(),
Some(parsed) if parsed.value.trim().is_empty() => {
return Err(syn::Error::new(
parsed.span,
"`type_name` in `#[commentable]` must not be empty: it is the \
discriminator stored in `commentable_type`, and an empty one \
cannot tell two models' comments apart",
));
}
Some(parsed) => {
let bad = parsed
.value
.chars()
.find(|c| !c.is_ascii_alphanumeric() && !matches!(c, '_' | '-'));
if let Some(bad) = bad {
return Err(syn::Error::new(
parsed.span,
format!(
"`type_name` in `#[commentable]` may only contain ASCII \
letters, digits, `_` and `-`, but this one contains \
{bad:?}: the value is matched as a single URL path \
segment by the generic router and interpolated into an \
htmx id selector by the widget, and {bad:?} is valid in \
neither"
),
));
}
parsed.value.clone()
}
};
Ok(CommentableSpec {
author_model,
type_name,
table: ident_value("table", "comments")?,
comment_pk: ident_value("comment_pk", "id")?,
type_column: ident_value("type_column", "commentable_type")?,
id_column: ident_value("id_column", "commentable_id")?,
parent_column: ident_value("parent_column", "parent_id")?,
author_column: ident_value("author_column", "author_id")?,
body_column: ident_value("body_column", "body")?,
created_at_column: ident_value("created_at_column", "created_at")?,
soft_delete,
counter_column,
author_table: ident_value("author_table", &author_table_default)?,
author_pk: ident_value("author_pk", "id")?,
author_name_column,
max_depth,
max_body_bytes,
span,
})
}
pub fn resolve_commentable(
model_ident: &syn::Ident,
attrs: &[syn::Attribute],
) -> syn::Result<Option<CommentableSpec>> {
let mut found: Option<&syn::Attribute> = None;
for attr in attrs {
if !is_commentable_attr(attr) {
continue;
}
if found.is_some() {
return Err(syn::Error::new_spanned(
attr,
"at most one `#[commentable]` per model: the generated \
`{Model}Comments` trait's `add_comment`/`comment_thread`/\
`delete_comment` methods would otherwise collide (several \
comment kinds per model are not supported — see \
https://github.com/autumn-foundation/autumn/issues/1367)",
));
}
found = Some(attr);
}
found
.map(|attr| parse_commentable_attr(attr, model_ident))
.transpose()
}
pub struct ParentShape<'a> {
pub has_deleted_at: bool,
pub has_tenant_id: bool,
pub is_sharded: bool,
pub pk_ident: Option<&'a syn::Ident>,
}
#[allow(clippy::too_many_lines)]
pub fn emit_commentable_items(
model_ident: &syn::Ident,
vis: &syn::Visibility,
spec: &CommentableSpec,
parent_table: &str,
parent: &ParentShape<'_>,
) -> TokenStream {
let ParentShape {
has_deleted_at,
has_tenant_id,
is_sharded,
pk_ident,
} = *parent;
let model_snake = pascal_to_snake(&model_ident.to_string());
let spec_static = format_ident!("__AUTUMN_COMMENTABLE_SPEC_{}", model_snake.to_uppercase());
let trait_ident = format_ident!("{model_ident}Comments");
let type_name = &spec.type_name;
let comments_table = &spec.table;
let comment_pk = &spec.comment_pk;
let type_column = &spec.type_column;
let id_column = &spec.id_column;
let parent_column = &spec.parent_column;
let author_column = &spec.author_column;
let body_column = &spec.body_column;
let created_at_column = &spec.created_at_column;
let soft_delete = spec.soft_delete;
let parent_pk = pk_ident.map_or_else(|| "id".to_owned(), std::string::ToString::to_string);
let max_depth = spec.max_depth;
let max_body_bytes = spec.max_body_bytes;
let counter_column = spec.counter_column.as_ref().map_or_else(
|| quote! { ::core::option::Option::None },
|column| quote! { ::core::option::Option::Some(#column) },
);
let parent_tenant_column = if has_tenant_id {
quote! { ::core::option::Option::Some("tenant_id") }
} else {
quote! { ::core::option::Option::None }
};
let (author_table, author_name_column) = spec.author_name_column.as_ref().map_or_else(
|| {
(
quote! { ::core::option::Option::None },
quote! { ::core::option::Option::None },
)
},
|column| {
let table = &spec.author_table;
(
quote! { ::core::option::Option::Some(#table) },
quote! { ::core::option::Option::Some(#column) },
)
},
);
let author_pk = &spec.author_pk;
let pk_guard = pk_ident.map(|pk| {
quote! {
const _: fn(&#model_ident) -> i64 = |__autumn_commentable_model| {
__autumn_commentable_model.#pk
};
}
});
let counter_guard = spec.counter_column.as_ref().map(|column| {
let counter_ident = format_ident!("{column}");
quote! {
const _: fn(&#model_ident) -> i64 = |__autumn_commentable_model| {
__autumn_commentable_model.#counter_ident
};
}
});
let author_guard = spec.author_model.as_ref().map(|author_model| {
let author_pk = format_ident!("{}", spec.author_pk);
quote! {
const _: fn(&#author_model) = |__autumn_commentable_author| {
fn __autumn_commentable_author_key<
T: ::autumn_web::commentable::CommentAuthorKey,
>(
_: &T,
) {
}
__autumn_commentable_author_key(&__autumn_commentable_author.#author_pk);
};
}
});
let author_model_name = spec
.author_model
.as_ref()
.map_or_else(|| "<unset>".to_owned(), ToString::to_string);
let spec_doc = format!(
"The polymorphic comment binding for `{model_ident}`: the shared \
`{comments_table}` table keyed on `({type_column}, {id_column})` with \
`{parent_column}` threading, attached to `{parent_table}`."
);
let type_doc = format!(
"The discriminator this model stores in `{comments_table}.{type_column}`.\n\
\n\
Renaming the Rust type changes this value, which orphans existing \
comment rows — pin it with `#[commentable(type_name = \"…\")]` before \
renaming a model that already has comments in production."
);
let spec_fn_doc = format!(
"This model's [`CommentableSpec`](autumn_web::commentable::CommentableSpec).\n\
\n\
An **inherent** associated function, so no trait import is needed at \
the call site. Emitted by `#[commentable]`; the same static backs this \
model's registry entry, so `commentable_spec_for({type_name:?})` \
returns the very same reference."
);
let trait_doc = format!(
"Threaded, polymorphic comment helpers for `{model_ident}`'s \
`#[commentable(by = {author_model_name})]` declaration: the shared \
`{comments_table}` table, discriminated as `{type_name:?}`."
);
let counter_note = spec.counter_column.as_ref().map_or_else(
|| "This model keeps no comment counter, so no counter statement is issued.".to_owned(),
|column| {
format!(
"`{parent_table}.{column}` is incremented in the **same** \
transaction, with the counter-cache mechanism's atomic \
`SET {column} = {column} + 1`."
)
},
);
let tenant_note = if has_tenant_id {
"This model has a `tenant_id` column, so a `tenant_scoped` repository \
matches the parent on it too: another tenant's `parent_id` is \
`NotFound` before anything is written. `across_tenants()` opts out; a \
`tenant_scoped` repository with no tenant context is an error.\n\n"
} else {
""
};
let add_doc = format!(
"Post a comment on this record, optionally as a reply to `reply_to`.\n\
\n\
{counter_note}\n\
\n\
{tenant_note}\
Runs on its **own** pooled connection — it does not join an enclosing \
`Db::tx`. Do not hold a `Db` extractor across this call on a small \
pool.\n\
\n\
# Errors\n\
\n\
- `422` when the body is blank or over the {max_body_bytes}-byte cap, \
when `reply_to` is not a live comment on **this** record, or when the \
reply would nest deeper than `max_depth` ({max_depth}).\n\
- `404` when this record does not exist (or is soft-deleted).\n\
- Any database error."
);
let thread_doc = format!(
"This record's live comment thread, replies nested under their parent \
in stable creation order.\n\
\n\
One query for the comments plus one parent-visibility probe, whatever \
the depth — the tree is assembled in Rust, never with an N+1 walk. \
Soft-deleted comments are filtered out.\n\
\n\
{tenant_note}\
# Errors\n\
\n\
- `404` when this record does not exist (or is soft-deleted).\n\
- Any database error."
);
let recompute_doc = "Rebuild this record's comment counter from the comments table, and \
return the value written.\n\
\n\
The repair half of the counter: counters drift when rows arrive by \
import, seed, or hand-written SQL, and they are deliberately not \
clamped, so a drifted one can go negative. Idempotent — running it \
twice writes the same number. Returns `0`, having written nothing, for \
a model that keeps no counter.\n\
\n\
Do **not** reach for `counter_cache_recompute` instead: it keys on the \
foreign-key column alone, and `commentable_id` is shared across \
models, so it would count another model's comments that happen to \
share the id.\n\
\n\
# Errors\n\
\n\
- `404` when this record does not exist (or is soft-deleted).\n\
- Any database error.";
let delete_doc = format!(
"Delete `comment_id` **and every reply beneath it**, returning how many \
comments were removed.\n\
\n\
`parent_id` is part of the lookup, not decoration: without it any \
comment id would be deletable from any record of this model — the \
mirror image of the cross-record `reply_to` graft `add_comment` \
refuses.\n\
\n\
{counter_note} The decrement is the number of rows the cascade \
actually removed, so a repeat delete moves nothing.\n\
\n\
# Errors\n\
\n\
- `404` when `comment_id` is not a comment on **this** record, or the \
record is not visible.\n\
- Any database error."
);
let (resolve_tenant, tenant_arg) = if has_tenant_id {
(
quote! {
let __tenant: ::core::option::Option<::std::string::String> =
self.__autumn_m2m_tenant_scope()?;
},
quote! { __tenant.as_deref() },
)
} else {
(
quote! {
let _: ::core::option::Option<::std::string::String> =
self.__autumn_m2m_tenant_scope()?;
},
quote! { ::core::option::Option::None },
)
};
quote! {
#pk_guard
#counter_guard
#author_guard
#[doc = #spec_doc]
#[doc(hidden)]
#[allow(non_upper_case_globals)]
static #spec_static: ::autumn_web::commentable::CommentableSpec =
::autumn_web::commentable::CommentableSpec {
comments_table: #comments_table,
comment_pk: #comment_pk,
type_column: #type_column,
id_column: #id_column,
parent_column: #parent_column,
author_column: #author_column,
body_column: #body_column,
created_at_column: #created_at_column,
soft_delete: #soft_delete,
parent_table: #parent_table,
parent_pk: #parent_pk,
parent_soft_delete: #has_deleted_at,
counter_column: #counter_column,
parent_tenant_column: #parent_tenant_column,
parent_sharded: #is_sharded,
author_table: #author_table,
author_pk: #author_pk,
author_name_column: #author_name_column,
max_depth: #max_depth,
max_body_bytes: #max_body_bytes,
};
::autumn_web::reexports::inventory::submit! {
::autumn_web::commentable::CommentableDescriptor {
type_name: #type_name,
model: || ::core::any::type_name::<#model_ident>(),
spec: &#spec_static,
}
}
impl #model_ident {
#[doc = #type_doc]
pub const COMMENTABLE_TYPE: &'static str = #type_name;
#[doc = #spec_fn_doc]
#[must_use]
pub fn commentable_spec() -> &'static ::autumn_web::commentable::CommentableSpec {
&#spec_static
}
}
#[doc = #trait_doc]
#vis trait #trait_ident {
#[doc = #add_doc]
fn add_comment(
&self,
parent_id: i64,
author_id: i64,
body: &str,
reply_to: ::core::option::Option<i64>,
) -> impl ::std::future::Future<
Output = ::autumn_web::AutumnResult<::autumn_web::commentable::Comment>
> + Send;
#[doc = #thread_doc]
fn comment_thread(
&self,
parent_id: i64,
) -> impl ::std::future::Future<
Output = ::autumn_web::AutumnResult<
::std::vec::Vec<::autumn_web::commentable::CommentNode>
>
> + Send;
#[doc = #delete_doc]
fn delete_comment(
&self,
parent_id: i64,
comment_id: i64,
) -> impl ::std::future::Future<
Output = ::autumn_web::AutumnResult<usize>
> + Send;
#[doc = #recompute_doc]
fn recompute_comment_count(
&self,
parent_id: i64,
) -> impl ::std::future::Future<
Output = ::autumn_web::AutumnResult<i64>
> + Send;
}
impl<__R> #trait_ident for __R
where
__R: ::autumn_web::repository::M2mConnSource<Model = #model_ident>
+ ::core::marker::Sync,
{
async fn add_comment(
&self,
parent_id: i64,
author_id: i64,
body: &str,
reply_to: ::core::option::Option<i64>,
) -> ::autumn_web::AutumnResult<::autumn_web::commentable::Comment> {
#resolve_tenant
let mut conn = self.__autumn_m2m_write_conn().await?;
::autumn_web::commentable::add_comment(
&mut *conn,
&#spec_static,
#type_name,
parent_id,
author_id,
body,
reply_to,
#tenant_arg,
)
.await
}
async fn comment_thread(
&self,
parent_id: i64,
) -> ::autumn_web::AutumnResult<
::std::vec::Vec<::autumn_web::commentable::CommentNode>
> {
#resolve_tenant
let mut conn = self.__autumn_m2m_read_conn().await?;
::autumn_web::commentable::comment_thread(
&mut *conn,
&#spec_static,
#type_name,
parent_id,
#tenant_arg,
)
.await
}
async fn delete_comment(
&self,
parent_id: i64,
comment_id: i64,
) -> ::autumn_web::AutumnResult<usize> {
#resolve_tenant
let mut conn = self.__autumn_m2m_write_conn().await?;
::autumn_web::commentable::delete_comment(
&mut *conn,
&#spec_static,
#type_name,
parent_id,
comment_id,
#tenant_arg,
)
.await
}
async fn recompute_comment_count(
&self,
parent_id: i64,
) -> ::autumn_web::AutumnResult<i64> {
#resolve_tenant
let mut conn = self.__autumn_m2m_write_conn().await?;
::autumn_web::commentable::recompute_comment_count(
&mut *conn,
&#spec_static,
#type_name,
parent_id,
#tenant_arg,
)
.await
}
}
}
}
trait SpanOfAttr {
fn span_of_attr(&self) -> proc_macro2::Span;
}
impl SpanOfAttr for syn::Path {
fn span_of_attr(&self) -> proc_macro2::Span {
use syn::spanned::Spanned as _;
self.span()
}
}
#[cfg(test)]
mod tests {
use super::*;
use quote::ToTokens as _;
fn parse(attr: &proc_macro2::TokenStream) -> syn::Result<CommentableSpec> {
let model: syn::Ident = syn::parse_quote!(Post);
let attr: syn::Attribute = syn::parse_quote!(#[commentable #attr]);
parse_commentable_attr(&attr, &model)
}
fn error(attr: &proc_macro2::TokenStream) -> String {
parse(attr).expect_err("expected a rejection").to_string()
}
#[test]
fn by_must_be_a_bare_identifier() {
for attr in [
quote! { (by = "User") },
quote! { (by = 123) },
quote! { (by = true) },
] {
let message = error(&attr);
assert!(
message.contains("bare type name"),
"expected a directed rejection, got: {message}"
);
}
let spec = parse("e! { (by = User) }).expect("`by = User` is valid");
assert_eq!(
spec.author_model.map(|model| model.to_string()).as_deref(),
Some("User")
);
}
#[test]
fn a_bare_attribute_takes_every_convention() {
let spec = parse("e! {}).expect("`#[commentable]` alone is valid");
assert!(spec.author_model.is_none());
assert_eq!(spec.type_name, "Post");
assert_eq!(spec.table, "comments");
assert_eq!(spec.type_column, "commentable_type");
assert_eq!(spec.id_column, "commentable_id");
assert_eq!(spec.parent_column, "parent_id");
assert_eq!(spec.author_column, "author_id");
assert_eq!(spec.body_column, "body");
assert_eq!(spec.created_at_column, "created_at");
assert_eq!(spec.comment_pk, "id");
assert_eq!(spec.author_pk, "id");
assert!(spec.soft_delete);
assert_eq!(spec.counter_column.as_deref(), Some("comment_count"));
assert_eq!(spec.author_name_column, None);
assert_eq!(spec.max_depth, 5);
assert_eq!(spec.max_body_bytes, 10_000);
}
#[test]
fn by_derives_the_author_table() {
let spec = parse("e! { (by = AppUser, author_name = username) }).expect("valid");
assert_eq!(
spec.author_model
.as_ref()
.map(ToString::to_string)
.as_deref(),
Some("AppUser")
);
assert_eq!(spec.author_table, "app_users");
assert_eq!(spec.author_name_column.as_deref(), Some("username"));
}
#[test]
fn every_key_can_be_overridden() {
let spec = parse("e! {(
by = User,
type_name = "LegacyPost",
table = discussion,
comment_pk = pk,
type_column = kind,
id_column = target_id,
parent_column = reply_to,
author_column = writer_id,
body_column = text,
created_at_column = posted_at,
soft_delete = false,
counter_cache = discussion_count,
author_table = accounts,
author_pk = account_id,
author_name = handle,
max_depth = 2,
max_body = 500,
)})
.expect("valid");
assert_eq!(spec.type_name, "LegacyPost");
assert_eq!(spec.table, "discussion");
assert_eq!(spec.comment_pk, "pk");
assert_eq!(spec.type_column, "kind");
assert_eq!(spec.id_column, "target_id");
assert_eq!(spec.parent_column, "reply_to");
assert_eq!(spec.author_column, "writer_id");
assert_eq!(spec.body_column, "text");
assert_eq!(spec.created_at_column, "posted_at");
assert!(!spec.soft_delete);
assert_eq!(spec.counter_column.as_deref(), Some("discussion_count"));
assert_eq!(spec.author_table, "accounts");
assert_eq!(spec.author_pk, "account_id");
assert_eq!(spec.author_name_column.as_deref(), Some("handle"));
assert_eq!(spec.max_depth, 2);
assert_eq!(spec.max_body_bytes, 500);
}
#[test]
fn counter_cache_false_opts_out_of_the_counter() {
assert_eq!(
parse("e! { (counter_cache = false) })
.expect("valid")
.counter_column,
None
);
assert_eq!(
parse("e! { (counter_cache = true) })
.expect("valid")
.counter_column
.as_deref(),
Some("comment_count")
);
}
#[test]
fn a_repeated_key_is_rejected() {
let message = error("e! { (table = a, table = b) });
assert!(message.contains("duplicate `table = ...`"), "{message}");
}
#[test]
fn an_unknown_key_lists_the_ones_that_exist() {
let message = error("e! { (autor_name = username) });
assert!(message.contains("unknown key `autor_name`"), "{message}");
assert!(message.contains("author_name"), "{message}");
}
#[test]
fn a_non_identifier_value_is_rejected_before_it_reaches_sql() {
for attr in [
quote! { (table = "comments\"; DROP TABLE posts --") },
quote! { (counter_cache = "1bad") },
quote! { (by = User, author_name = "") },
quote! { (body_column = "has space") },
] {
let message = error(&attr);
assert!(
message.contains("is not a valid identifier"),
"expected an identifier rejection, got: {message}"
);
}
}
#[test]
fn an_empty_type_name_is_rejected() {
let message = error("e! { (type_name = " ") });
assert!(message.contains("must not be empty"), "{message}");
}
#[test]
fn a_route_or_selector_unsafe_type_name_is_rejected() {
for bad in ["blog/Post", "Blog Post", "blog:Post", "Post.v2", "Post#1"] {
let message = error("e! { (type_name = #bad) });
assert!(
message.contains("URL path segment") && message.contains("id selector"),
"{bad} should be rejected, got: {message}"
);
}
assert!(parse("e! { (by = User, type_name = "BlogPost") }).is_ok());
assert!(parse("e! { (by = User, type_name = "blog_post") }).is_ok());
assert!(parse("e! { (by = User, type_name = "blog-post-v2") }).is_ok());
}
#[test]
fn author_name_without_a_table_is_rejected() {
let message = error("e! { (author_name = username) });
assert!(message.contains("needs a table"), "{message}");
assert!(parse("e! { (by = User, author_name = username) }).is_ok());
assert!(parse("e! { (author_table = users, author_name = username) }).is_ok());
}
#[test]
fn typed_keys_reject_the_wrong_literal_kind() {
assert!(
error("e! { (soft_delete = yes) }).contains("takes `true` or `false`"),
"soft_delete must be a bool"
);
assert!(
error("e! { (max_depth = deep) }).contains("takes an integer"),
"max_depth must be an integer"
);
assert!(
error("e! { (max_body = big) }).contains("takes an integer"),
"max_body must be an integer"
);
}
#[test]
fn a_zero_max_body_is_rejected() {
let message = error("e! { (max_body = 0) });
assert!(message.contains("must be at least 1"), "{message}");
}
#[test]
fn at_most_one_commentable_per_model() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> = vec![
syn::parse_quote!(#[commentable(by = User)]),
syn::parse_quote!(#[commentable(by = User, table = other)]),
];
let message = resolve_commentable(&model, &attrs)
.expect_err("a second declaration must be rejected")
.to_string();
assert!(
message.contains("at most one `#[commentable]`"),
"{message}"
);
assert!(
resolve_commentable(&model, &attrs[..1])
.expect("one is fine")
.is_some()
);
assert!(
resolve_commentable(&model, &[])
.expect("none is fine")
.is_none()
);
}
#[test]
fn the_emitted_items_carry_the_documented_names() {
let spec = parse("e! { (by = User, author_name = username) }).expect("valid");
let model: syn::Ident = syn::parse_quote!(Post);
let vis: syn::Visibility = syn::parse_quote!(pub);
let pk: syn::Ident = syn::parse_quote!(id);
let emitted = emit_commentable_items(
&model,
&vis,
&spec,
"posts",
&ParentShape {
has_deleted_at: false,
has_tenant_id: false,
is_sharded: false,
pk_ident: Some(&pk),
},
)
.to_token_stream()
.to_string();
assert!(emitted.contains("PostComments"), "the trait name");
assert!(emitted.contains("COMMENTABLE_TYPE"));
assert!(emitted.contains("commentable_spec"));
assert!(emitted.contains("CommentableDescriptor"), "registry entry");
assert!(emitted.contains("add_comment"));
assert!(emitted.contains("comment_thread"));
assert!(emitted.contains("delete_comment"));
assert!(emitted.contains("CommentAuthorKey"), "the author key bound");
assert!(
emitted.contains("author_pk") || emitted.contains("id"),
"read through the author's key field"
);
}
#[test]
fn a_plain_model_emits_no_tenant_or_soft_delete_machinery() {
let spec = parse("e! { (by = User) }).expect("valid");
let model: syn::Ident = syn::parse_quote!(Post);
let vis: syn::Visibility = syn::parse_quote!(pub);
let pk: syn::Ident = syn::parse_quote!(id);
let plain = emit_commentable_items(
&model,
&vis,
&spec,
"posts",
&ParentShape {
has_deleted_at: false,
has_tenant_id: false,
is_sharded: false,
pk_ident: Some(&pk),
},
)
.to_token_stream()
.to_string();
assert!(plain.contains("parent_soft_delete : false"), "{plain}");
assert!(
plain.contains("parent_tenant_column : :: core :: option :: Option :: None"),
"{plain}"
);
let tenanted = emit_commentable_items(
&model,
&vis,
&spec,
"posts",
&ParentShape {
has_deleted_at: true,
has_tenant_id: true,
is_sharded: false,
pk_ident: Some(&pk),
},
)
.to_token_stream()
.to_string();
assert!(tenanted.contains("parent_soft_delete : true"), "{tenanted}");
assert!(tenanted.contains("\"tenant_id\""), "{tenanted}");
}
}