use crate::util::validate_sql_identifier;
use proc_macro2::TokenStream;
use quote::quote;
use syn::parse::Parser;
use syn::spanned::Spanned;
use syn::{Attribute, Fields, ItemStruct, LitStr, Type};
struct Column {
ident: syn::Ident,
name: String,
ty: Type,
sql_type: &'static str,
not_null: bool,
pk: bool,
autoincrement: bool,
unique: bool,
index: bool,
default: Option<String>,
json: bool,
renamed_from: Option<String>,
}
struct EntityArgs {
table: Option<String>,
multi_instance: bool,
}
fn parse_args(args: TokenStream) -> syn::Result<EntityArgs> {
let mut out = EntityArgs {
table: None,
multi_instance: false,
};
if args.is_empty() {
return Ok(out);
}
let parser = syn::meta::parser(|meta| {
if meta.path.is_ident("table") {
let lit: LitStr = meta.value()?.parse()?;
validate_sql_identifier(&lit.value(), lit.span())?;
out.table = Some(lit.value());
Ok(())
} else if meta.path.is_ident("multi_instance") {
out.multi_instance = true;
Ok(())
} else {
Err(meta.error("알 수 없는 entity 인자 — table / multi_instance 만 지원"))
}
});
parser.parse2(args)?;
Ok(out)
}
fn map_type(ty: &Type) -> (&'static str, bool) {
if let Type::Path(tp) = ty {
if let Some(seg) = tp.path.segments.last() {
if seg.ident == "Option" {
if let syn::PathArguments::AngleBracketed(ab) = &seg.arguments {
if let Some(syn::GenericArgument::Type(inner)) = ab.args.first() {
let (sql, _) = map_type(inner);
return (sql, false); }
}
}
}
}
let name = match ty {
Type::Path(tp) => tp
.path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default(),
_ => String::new(),
};
let sql = match name.as_str() {
"bool" | "i8" | "i16" | "i32" | "i64" | "isize" | "u8" | "u16" | "u32" | "u64"
| "usize" => "INTEGER",
"f32" | "f64" => "REAL",
"String" => "TEXT",
"OffsetDateTime" | "PrimitiveDateTime" | "Date" | "Time" => "TEXT",
"Uuid" => "BLOB",
"Vec" => {
match ty {
Type::Path(tp) => tp
.path
.segments
.last()
.and_then(|seg| match &seg.arguments {
syn::PathArguments::AngleBracketed(ab) => ab.args.first(),
_ => None,
})
.and_then(|arg| match arg {
syn::GenericArgument::Type(Type::Path(inner)) => Some(inner),
_ => None,
})
.filter(|inner| inner.path.is_ident("u8"))
.map_or("", |_| "BLOB"),
_ => "",
}
}
_ => "", };
(sql, true) }
fn option_inner(ty: &Type) -> Option<&Type> {
let Type::Path(path) = ty else { return None };
let segment = path.path.segments.last()?;
if segment.ident != "Option" {
return None;
}
let syn::PathArguments::AngleBracketed(arguments) = &segment.arguments else {
return None;
};
match arguments.args.first() {
Some(syn::GenericArgument::Type(inner)) => Some(inner),
_ => None,
}
}
fn parse_field(field: &syn::Field) -> syn::Result<Option<Column>> {
let ident = field
.ident
.clone()
.expect("named struct만 허용 — 상위에서 검증");
let mut col = Column {
name: ident.to_string(),
ident,
ty: field.ty.clone(),
sql_type: "",
not_null: true,
pk: false,
autoincrement: false,
unique: false,
index: false,
default: None,
json: false,
renamed_from: None,
};
let mut ignored = false;
for attr in &field.attrs {
if attr.path().is_ident("pk") {
col.pk = true;
if !matches!(attr.meta, syn::Meta::Path(_)) {
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("autoincrement") {
col.autoincrement = true;
Ok(())
} else {
Err(meta.error("알 수 없는 pk 인자 — autoincrement 만 지원"))
}
})?;
}
} else if attr.path().is_ident("json") {
col.json = true;
} else if attr.path().is_ident("column") {
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("ignore") {
ignored = true;
Ok(())
} else if meta.path.is_ident("name") {
let lit: LitStr = meta.value()?.parse()?;
validate_sql_identifier(&lit.value(), lit.span())?;
col.name = lit.value();
Ok(())
} else if meta.path.is_ident("unique") {
col.unique = true;
Ok(())
} else if meta.path.is_ident("index") {
col.index = true;
Ok(())
} else if meta.path.is_ident("renamed_from") {
let lit: LitStr = meta.value()?.parse()?;
validate_sql_identifier(&lit.value(), lit.span())?;
col.renamed_from = Some(lit.value());
Ok(())
} else if meta.path.is_ident("default") {
let lit: LitStr = meta.value()?.parse()?;
col.default = Some(render_default(&lit)?);
Ok(())
} else {
Err(meta
.error("알 수 없는 column 인자 — name/unique/index/default/ignore/renamed_from 만 지원"))
}
})?;
}
}
if ignored {
return Ok(None);
}
let (sql_type, not_null) = map_type(&col.ty);
col.sql_type = if col.json { "TEXT" } else { sql_type };
col.not_null = not_null;
Ok(Some(col))
}
fn render_default(lit: &LitStr) -> syn::Result<String> {
let v = lit.value();
if v.eq_ignore_ascii_case("now") || v.eq_ignore_ascii_case("current_timestamp") {
return Ok("CURRENT_TIMESTAMP".to_string());
}
if v.eq_ignore_ascii_case("current_date") || v.eq_ignore_ascii_case("current_time") {
return Ok(v.to_ascii_uppercase());
}
if v == "true" {
return Ok("1".to_string());
}
if v == "false" {
return Ok("0".to_string());
}
if v.starts_with('(') {
if !parens_balanced(&v) {
return Err(syn::Error::new(
lit.span(),
format!("default SQL 식 \"{v}\" 의 괄호가 불균형합니다"),
));
}
return Ok(v);
}
if let Ok(n) = v.parse::<f64>() {
if n.is_finite() {
return Ok(v);
}
return Err(syn::Error::new(
lit.span(),
format!(
"default 값 \"{v}\" 은 SQLite DEFAULT 로 표현할 수 없습니다 — 유한 숫자만 지원"
),
));
}
Ok(format!("'{}'", v.replace('\'', "''")))
}
fn parens_balanced(s: &str) -> bool {
let mut depth: i64 = 0;
let mut in_str = false;
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'\'' => {
if in_str && i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
i += 1;
} else {
in_str = !in_str;
}
}
b'(' if !in_str => depth += 1,
b')' if !in_str => {
depth -= 1;
if depth < 0 {
return false;
}
}
_ => {}
}
i += 1;
}
depth == 0 && !in_str
}
fn strip_helper_attrs(attrs: &mut Vec<Attribute>) {
attrs.retain(|a| {
!(a.path().is_ident("pk") || a.path().is_ident("json") || a.path().is_ident("column"))
});
}
fn render_ddl(table: &str, cols: &[Column]) -> Vec<String> {
let mut defs: Vec<String> = Vec::new();
for c in cols {
let mut d = format!("\"{}\"", c.name);
if !c.sql_type.is_empty() {
d.push(' ');
d.push_str(c.sql_type);
}
if c.pk {
d.push_str(" PRIMARY KEY");
if c.sql_type != "INTEGER" {
d.push_str(" NOT NULL");
}
if c.autoincrement {
d.push_str(" AUTOINCREMENT");
}
} else {
if c.not_null {
d.push_str(" NOT NULL");
}
if c.unique {
d.push_str(" UNIQUE");
}
}
if let Some(def) = &c.default {
d.push_str(&format!(" DEFAULT {def}"));
}
defs.push(d);
}
let mut out = vec![format!(
"CREATE TABLE IF NOT EXISTS \"{table}\" ({})",
defs.join(", ")
)];
for c in cols.iter().filter(|c| c.index) {
out.push(format!(
"CREATE INDEX IF NOT EXISTS \"idx_{table}_{name}\" ON \"{table}\"(\"{name}\")",
name = c.name
));
}
out
}
fn param_expr(c: &Column) -> TokenStream {
let ident = &c.ident;
if c.json {
if option_inner(&c.ty).is_some() {
quote! {
match &self.#ident {
Some(value) => ::roomrs::ToSqlOutput::Owned(
::roomrs::rusqlite::types::Value::Text(
::roomrs::__private::serde_json::to_string(value)?,
),
),
None => ::roomrs::ToSqlOutput::Owned(
::roomrs::rusqlite::types::Value::Null,
),
}
}
} else {
quote! {
::roomrs::ToSqlOutput::Owned(::roomrs::rusqlite::types::Value::Text(
::roomrs::__private::serde_json::to_string(&self.#ident)?,
))
}
}
} else {
quote! { ::roomrs::ToSql::to_sql(&self.#ident)? }
}
}
fn from_row_expr(c: &Column) -> TokenStream {
let name = &c.name;
let ty = &c.ty;
if c.json {
if let Some(inner) = option_inner(ty) {
quote! {{
let raw: Option<String> = row.get(#name)?;
match raw {
Some(raw) if raw == "null" => None,
Some(raw) => Some(::roomrs::__private::serde_json::from_str::<#inner>(&raw).map_err(|e| {
::roomrs::rusqlite::Error::FromSqlConversionFailure(
0,
::roomrs::rusqlite::types::Type::Text,
Box::new(e),
)
})?),
None => None,
}
}}
} else {
quote! {{
let raw: String = row.get(#name)?;
::roomrs::__private::serde_json::from_str::<#ty>(&raw).map_err(|e| {
::roomrs::rusqlite::Error::FromSqlConversionFailure(
0,
::roomrs::rusqlite::types::Type::Text,
Box::new(e),
)
})?
}}
}
} else {
quote! { row.get::<_, #ty>(#name)? }
}
}
pub fn expand(args: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
let args = parse_args(args)?;
let mut item: ItemStruct = syn::parse2(input)?;
let Fields::Named(_) = &item.fields else {
return Err(syn::Error::new(
item.span(),
"#[entity]는 named 필드 구조체에만 사용할 수 있습니다",
));
};
let struct_ident = item.ident.clone();
let table = args.table.unwrap_or_else(|| struct_ident.to_string());
let multi_instance = args.multi_instance;
let mut cols: Vec<Column> = Vec::new();
let mut ignored_idents: Vec<syn::Ident> = Vec::new();
for field in item.fields.iter() {
match parse_field(field)? {
Some(c) => cols.push(c),
None => ignored_idents.push(field.ident.clone().expect("named 필드 보장")),
}
}
for (i, c) in cols.iter().enumerate() {
if let Some(prev) = cols[..i]
.iter()
.find(|p| p.name.eq_ignore_ascii_case(&c.name))
{
return Err(syn::Error::new(
c.ident.span(),
format!(
"컬럼명 중복: \"{}\" — 필드 {} 와 충돌합니다 (#[column(name)] 확인)",
c.name, prev.ident
),
));
}
}
let pk_count = cols.iter().filter(|c| c.pk).count();
if pk_count > 1 {
return Err(syn::Error::new(
struct_ident.span(),
"#[pk] 필드는 최대 1개만 허용됩니다 (복합 PK는 후속 지원)",
));
}
if let Some(c) = cols
.iter()
.find(|c| c.autoincrement && c.sql_type != "INTEGER")
{
return Err(syn::Error::new(
c.ident.span(),
"#[pk(autoincrement)]는 정수 타입 필드에만 사용할 수 있습니다",
));
}
let ddl = render_ddl(&table, &cols);
let columns_joined = cols
.iter()
.map(|c| format!("\"{}\"", c.name))
.collect::<Vec<_>>()
.join(", ");
let ins_cols: Vec<&Column> = cols.iter().filter(|c| !c.autoincrement).collect();
let ins_columns = ins_cols
.iter()
.map(|c| format!("\"{}\"", c.name))
.collect::<Vec<_>>()
.join(", ");
let ins_placeholders = (1..=ins_cols.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(", ");
let keep_cols = cols
.iter()
.map(|c| format!("\"{}\"", c.name))
.collect::<Vec<_>>()
.join(", ");
let keep_placeholders = (1..=cols.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(", ");
let ins_params: Vec<TokenStream> = ins_cols.iter().map(|c| param_expr(c)).collect();
let keep_params: Vec<TokenStream> = cols.iter().map(param_expr).collect();
let field_reads: Vec<TokenStream> = cols
.iter()
.map(|c| {
let ident = &c.ident;
let expr = from_row_expr(c);
quote! { #ident: #expr }
})
.collect();
let ignored_reads: Vec<TokenStream> = ignored_idents
.iter()
.map(|ident| quote! { #ident: ::core::default::Default::default() })
.collect();
for field in item.fields.iter_mut() {
strip_helper_attrs(&mut field.attrs);
}
let ddl_lits: Vec<LitStr> = ddl
.iter()
.map(|s| LitStr::new(s, struct_ident.span()))
.collect();
let column_metas: Vec<TokenStream> = cols
.iter()
.map(|c| {
let name = &c.name;
let sql_type = c.sql_type;
let not_null = c.not_null;
let pk = c.pk;
let renamed = match &c.renamed_from {
Some(s) => quote! { Some(#s) },
None => quote! { None },
};
quote! {
::roomrs::ColumnMeta {
name: #name,
sql_type: #sql_type,
not_null: #not_null,
pk: #pk,
renamed_from: #renamed,
}
}
})
.collect();
Ok(quote! {
#item
impl ::roomrs::FromRow for #struct_ident {
fn from_row(row: &::roomrs::__private::Row<'_>) -> ::roomrs::rusqlite::Result<Self> {
Ok(Self {
#(#field_reads,)*
#(#ignored_reads,)*
})
}
}
impl ::roomrs::Entity for #struct_ident {
const TABLE: &'static str = #table;
const DDL: &'static [&'static str] = &[#(#ddl_lits),*];
const COLUMNS: &'static str = #columns_joined;
const COLUMNS_META: &'static [::roomrs::ColumnMeta] = &[#(#column_metas),*];
const MULTI_INSTANCE: bool = #multi_instance;
}
impl ::roomrs::Insertable for #struct_ident {
const INSERT_COLUMNS: &'static str = #ins_columns;
const INSERT_PLACEHOLDERS: &'static str = #ins_placeholders;
const INSERT_COLUMNS_KEEP_PK: &'static str = #keep_cols;
const INSERT_PLACEHOLDERS_KEEP_PK: &'static str = #keep_placeholders;
fn insert_params(&self) -> ::roomrs::Result<Vec<::roomrs::ToSqlOutput<'_>>> {
Ok(vec![#(#ins_params),*])
}
fn insert_params_keep_pk(&self) -> ::roomrs::Result<Vec<::roomrs::ToSqlOutput<'_>>> {
Ok(vec![#(#keep_params),*])
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use proc_macro2::Span;
fn rd(v: &str) -> syn::Result<String> {
render_default(&LitStr::new(v, Span::call_site()))
}
#[test]
fn default_time_keywords_case_insensitive() {
assert_eq!(rd("now").unwrap(), "CURRENT_TIMESTAMP");
assert_eq!(rd("NOW").unwrap(), "CURRENT_TIMESTAMP");
assert_eq!(rd("Now").unwrap(), "CURRENT_TIMESTAMP");
assert_eq!(rd("current_timestamp").unwrap(), "CURRENT_TIMESTAMP");
assert_eq!(rd("Current_Date").unwrap(), "CURRENT_DATE");
assert_eq!(rd("CURRENT_TIME").unwrap(), "CURRENT_TIME");
}
#[test]
fn default_expr_paren_balance() {
assert_eq!(rd("(datetime('now'))").unwrap(), "(datetime('now'))");
assert_eq!(rd("(concat('(', ''''))").unwrap(), "(concat('(', ''''))");
assert!(rd("(datetime('now')").is_err(), "여는 괄호 초과");
assert!(rd("(a))(").is_err(), "음수 깊이");
assert!(rd("('미종결").is_err(), "닫히지 않은 문자열");
}
#[test]
fn default_plain_values() {
assert_eq!(rd("true").unwrap(), "1");
assert_eq!(rd("false").unwrap(), "0");
assert_eq!(rd("3.5").unwrap(), "3.5");
assert_eq!(rd("abc").unwrap(), "'abc'");
assert_eq!(rd("o'clock").unwrap(), "'o''clock'");
assert!(rd("nan").is_err());
}
}