#![allow(dead_code)]
use convert_case::{Case, Casing};
use prax_schema::{Field, FieldType, ScalarType};
use proc_macro2::{Span, TokenStream};
use quote::{format_ident, quote};
use super::LowerCtx;
use crate::generators::inputs::{FilterCategory, update_wrapper_ident};
use crate::macros::dsl::ast::{DslBlock, DslField, DslValue};
use crate::macros::lower::scalar_filter::category_for_scalar;
fn relation_phase_5b_error(rel: &str, model: &str) -> syn::Error {
let msg = format!(
"nested write on relation `{rel}` (model `{model}`) is not supported in this macro. \
Phase 5b lands nested `create` / `connect` on `create!` only; the other write macros \
(update!, upsert!, create_many!) gain nested-write support in phase 5c. \
For now, write the related rows in a separate operation and link via the FK column."
);
syn::Error::new(Span::call_site(), msg)
}
fn logical_in_data_error(op: &str, span: Span) -> syn::Error {
syn::Error::new(
span,
format!("`{op}` is a `where:` operator and is not valid inside `data:`"),
)
}
fn create_field_is_optional(field: &Field) -> bool {
if field.is_optional() {
return true;
}
let attrs = field.extract_attributes();
attrs.default.is_some()
}
fn category_for_field(field: &Field) -> Option<FilterCategory> {
match &field.field_type {
FieldType::Scalar(s) => category_for_scalar(s),
FieldType::Enum(_) => Some(FilterCategory::Enum),
FieldType::Model(_) | FieldType::Composite(_) | FieldType::Unsupported(_) => None,
}
}
pub struct CreateDataLowering {
pub scalar_input: TokenStream,
pub nested_ops: Vec<TokenStream>,
}
pub fn lower_create_data_with_nested(
block: &DslBlock,
ctx: &LowerCtx<'_>,
) -> syn::Result<CreateDataLowering> {
let model_ident = format_ident!("{}", ctx.model.name());
let module_ident = format_ident!("{}", ctx.model.name().to_case(Case::Snake));
let input_ident = format_ident!("{}CreateInput", ctx.model.name());
let mut scalar_stmts: Vec<TokenStream> = Vec::new();
let mut nested_ops: Vec<TokenStream> = Vec::new();
let mut field_iter = block.fields.iter().peekable();
let init = if let Some(DslField::Spread { expr, by_move, .. }) = field_iter.peek() {
let init_expr = if *by_move {
quote!(#expr)
} else {
quote!(::core::clone::Clone::clone(&(#expr)))
};
let _ = field_iter.next();
init_expr
} else {
quote!(<#module_ident::#input_ident as ::core::default::Default>::default())
};
for field in field_iter {
match field {
DslField::Pair { key, value, .. } => {
let key_str = key.to_string();
if matches!(key_str.as_str(), "and" | "or" | "not") {
return Err(logical_in_data_error(&key_str, key.span()));
}
let model_field = lookup_field_with_suggestion(ctx, &key_str, key.span())?;
if model_field.is_relation() {
let ops = super::data_relation::lower_create_relation(
model_field,
value,
key.span(),
ctx,
)?;
for op in ops {
nested_ops.push(op.op_expr);
}
} else {
scalar_stmts.push(lower_create_pair(key, value, ctx)?);
}
}
DslField::Spread { expr, by_move, .. } => {
let assign = if *by_move {
quote!(__d = #expr;)
} else {
quote!(__d = ::core::clone::Clone::clone(&(#expr));)
};
scalar_stmts.push(assign);
}
DslField::Conditional { .. } => {
scalar_stmts.push(lower_create_conditional(field, ctx)?);
}
}
}
let scalar_input = quote! {
{
let mut __d: #module_ident::#input_ident = #init;
#(#scalar_stmts)*
let _ = stringify!(#model_ident);
__d
}
};
Ok(CreateDataLowering {
scalar_input,
nested_ops,
})
}
pub fn lower_create_data(block: &DslBlock, ctx: &LowerCtx<'_>) -> syn::Result<TokenStream> {
let model_ident = format_ident!("{}", ctx.model.name());
let module_ident = format_ident!("{}", ctx.model.name().to_case(Case::Snake));
let input_ident = format_ident!("{}CreateInput", ctx.model.name());
let mut stmts: Vec<TokenStream> = Vec::new();
let mut field_iter = block.fields.iter().peekable();
let init = if let Some(DslField::Spread { expr, by_move, .. }) = field_iter.peek() {
let init_expr = if *by_move {
quote!(#expr)
} else {
quote!(::core::clone::Clone::clone(&(#expr)))
};
let _ = field_iter.next();
init_expr
} else {
quote!(<#module_ident::#input_ident as ::core::default::Default>::default())
};
for field in field_iter {
match field {
DslField::Pair { key, value, .. } => {
stmts.push(lower_create_pair(key, value, ctx)?);
}
DslField::Spread { expr, by_move, .. } => {
let assign = if *by_move {
quote!(__d = #expr;)
} else {
quote!(__d = ::core::clone::Clone::clone(&(#expr));)
};
stmts.push(assign);
}
DslField::Conditional { .. } => {
stmts.push(lower_create_conditional(field, ctx)?);
}
}
}
Ok(quote! {
{
let mut __d: #module_ident::#input_ident = #init;
#(#stmts)*
let _ = stringify!(#model_ident);
__d
}
})
}
pub struct UpdateDataLowering {
pub scalar_input: TokenStream,
pub nested_ops: Vec<TokenStream>,
}
pub fn lower_update_data_with_nested(
block: &DslBlock,
ctx: &LowerCtx<'_>,
) -> syn::Result<UpdateDataLowering> {
let model_ident = format_ident!("{}", ctx.model.name());
let module_ident = format_ident!("{}", ctx.model.name().to_case(Case::Snake));
let input_ident = format_ident!("{}UpdateInput", ctx.model.name());
let mut scalar_stmts: Vec<TokenStream> = Vec::new();
let mut nested_ops: Vec<TokenStream> = Vec::new();
let mut field_iter = block.fields.iter().peekable();
let init = if let Some(DslField::Spread { expr, by_move, .. }) = field_iter.peek() {
let init_expr = if *by_move {
quote!(#expr)
} else {
quote!(::core::clone::Clone::clone(&(#expr)))
};
let _ = field_iter.next();
init_expr
} else {
quote!(<#module_ident::#input_ident as ::core::default::Default>::default())
};
for field in field_iter {
match field {
DslField::Pair { key, value, .. } => {
let key_str = key.to_string();
if matches!(key_str.as_str(), "and" | "or" | "not") {
return Err(logical_in_data_error(&key_str, key.span()));
}
let model_field = lookup_field_with_suggestion(ctx, &key_str, key.span())?;
if model_field.is_relation() {
let ops = super::data_relation::lower_create_relation(
model_field,
value,
key.span(),
ctx,
)?;
for op in ops {
nested_ops.push(op.op_expr);
}
} else {
scalar_stmts.push(lower_update_pair(key, value, ctx)?);
}
}
DslField::Spread { expr, by_move, .. } => {
let assign = if *by_move {
quote!(__d = #expr;)
} else {
quote!(__d = ::core::clone::Clone::clone(&(#expr));)
};
scalar_stmts.push(assign);
}
DslField::Conditional { .. } => {
scalar_stmts.push(lower_update_conditional(field, ctx)?);
}
}
}
let scalar_input = quote! {
{
let mut __d: #module_ident::#input_ident = #init;
#(#scalar_stmts)*
let _ = stringify!(#model_ident);
__d
}
};
Ok(UpdateDataLowering {
scalar_input,
nested_ops,
})
}
pub fn lower_update_data(block: &DslBlock, ctx: &LowerCtx<'_>) -> syn::Result<TokenStream> {
let model_ident = format_ident!("{}", ctx.model.name());
let module_ident = format_ident!("{}", ctx.model.name().to_case(Case::Snake));
let input_ident = format_ident!("{}UpdateInput", ctx.model.name());
let mut stmts: Vec<TokenStream> = Vec::new();
let mut field_iter = block.fields.iter().peekable();
let init = if let Some(DslField::Spread { expr, by_move, .. }) = field_iter.peek() {
let init_expr = if *by_move {
quote!(#expr)
} else {
quote!(::core::clone::Clone::clone(&(#expr)))
};
let _ = field_iter.next();
init_expr
} else {
quote!(<#module_ident::#input_ident as ::core::default::Default>::default())
};
for field in field_iter {
match field {
DslField::Pair { key, value, .. } => {
stmts.push(lower_update_pair(key, value, ctx)?);
}
DslField::Spread { expr, by_move, .. } => {
let assign = if *by_move {
quote!(__d = #expr;)
} else {
quote!(__d = ::core::clone::Clone::clone(&(#expr));)
};
stmts.push(assign);
}
DslField::Conditional { .. } => {
stmts.push(lower_update_conditional(field, ctx)?);
}
}
}
Ok(quote! {
{
let mut __d: #module_ident::#input_ident = #init;
#(#stmts)*
let _ = stringify!(#model_ident);
__d
}
})
}
fn lower_create_pair(
key: &syn::Ident,
value: &DslValue,
ctx: &LowerCtx<'_>,
) -> syn::Result<TokenStream> {
let key_str = key.to_string();
if matches!(key_str.as_str(), "and" | "or" | "not") {
return Err(logical_in_data_error(&key_str, key.span()));
}
let field = lookup_field_with_suggestion(ctx, &key_str, key.span())?;
if field.is_computed() {
return Err(syn::Error::new(
key.span(),
format!(
"field `{}` is a computed virtual and cannot be assigned in `data:`",
key_str
),
));
}
if field.is_relation() {
return Err(relation_phase_5b_error(field.name(), ctx.model.name()));
}
let assign_ident = format_ident!("{}", field.name().to_case(Case::Snake));
let is_optional = create_field_is_optional(field);
let value_expr = lower_create_value(field, value, key.span())?;
let stmt = if is_optional {
quote! { __d.#assign_ident = ::core::option::Option::Some(#value_expr); }
} else {
quote! { __d.#assign_ident = #value_expr; }
};
Ok(stmt)
}
fn lower_update_pair(
key: &syn::Ident,
value: &DslValue,
ctx: &LowerCtx<'_>,
) -> syn::Result<TokenStream> {
let key_str = key.to_string();
if matches!(key_str.as_str(), "and" | "or" | "not") {
return Err(logical_in_data_error(&key_str, key.span()));
}
let field = lookup_field_with_suggestion(ctx, &key_str, key.span())?;
if field.is_computed() {
return Err(syn::Error::new(
key.span(),
format!(
"field `{}` is a computed virtual and cannot be assigned in `data:`",
key_str
),
));
}
if field.is_relation() {
return Err(relation_phase_5b_error(field.name(), ctx.model.name()));
}
let assign_ident = format_ident!("{}", field.name().to_case(Case::Snake));
let wrapper_expr = lower_update_value(field, value, key.span())?;
Ok(quote! {
__d.#assign_ident = ::core::option::Option::Some(#wrapper_expr);
})
}
fn lower_create_value(field: &Field, value: &DslValue, span: Span) -> syn::Result<TokenStream> {
match (&field.field_type, value) {
(FieldType::Enum(enum_name), DslValue::BareIdent(variant)) => {
let enum_ident = format_ident!("{}", enum_name.as_str());
Ok(quote! { #enum_ident::#variant })
}
(FieldType::Enum(_), DslValue::Path(p)) => Ok(quote! { #p }),
(FieldType::Enum(_), DslValue::Expr(e)) => Ok(quote! { #e }),
(_, DslValue::BareIdent(id)) => Err(syn::Error::new(
id.span(),
format!(
"bare identifier `{}` is only allowed for enum-typed fields. \
Field `{}` is not an enum.",
id,
field.name()
),
)),
(_, DslValue::Lit(lit)) => Ok(quote! { ::core::convert::Into::into(#lit) }),
(_, DslValue::Bool(b)) => Ok(quote! { #b }),
(_, DslValue::Expr(e)) => Ok(quote! { (#e) }),
(_, DslValue::Path(p)) => Ok(quote! { #p }),
(_, DslValue::Block(_)) => Err(syn::Error::new(
span,
format!(
"scalar field `{}` on the create path expects a literal, identifier, or `@(expr)`. \
`{{ set: ... }}` blocks are an update-path concept; on `create:` the value is the \
column's initial value.",
field.name()
),
)),
(_, DslValue::List(_)) => Err(syn::Error::new(
span,
format!(
"scalar field `{}` does not accept a list value on the create path",
field.name()
),
)),
}
}
fn lower_update_value(field: &Field, value: &DslValue, span: Span) -> syn::Result<TokenStream> {
let cat = category_for_field(field).ok_or_else(|| {
syn::Error::new(
span,
format!(
"scalar field `{}` has no DSL lowering for `data:` updates",
field.name()
),
)
})?;
let nullable = field.is_optional();
let wrapper_ident = update_wrapper_ident(cat, nullable);
let wrapper_path = wrapper_path_for(cat, &wrapper_ident, field)?;
match value {
DslValue::Lit(lit) => Ok(quote! {
<#wrapper_path as ::core::convert::From<_>>::from(#lit)
}),
DslValue::Bool(b) => {
if !matches!(cat, FilterCategory::Bool) {
return Err(syn::Error::new(
span,
format!(
"field `{}` (category {cat:?}) does not accept a bare bool value",
field.name()
),
));
}
Ok(quote! {
<#wrapper_path as ::core::convert::From<_>>::from(#b)
})
}
DslValue::Expr(e) => Ok(quote! {
<#wrapper_path as ::core::convert::From<_>>::from(#e)
}),
DslValue::Path(p) => {
if matches!(cat, FilterCategory::Enum) {
Ok(quote! {
#wrapper_path { set: ::core::option::Option::Some(#p), ..::core::default::Default::default() }
})
} else {
Ok(quote! {
<#wrapper_path as ::core::convert::From<_>>::from(#p)
})
}
}
DslValue::BareIdent(id) => {
let FieldType::Enum(enum_name) = &field.field_type else {
return Err(syn::Error::new(
id.span(),
format!(
"bare identifier `{id}` is only allowed for enum-typed fields. \
Field `{}` is not an enum.",
field.name()
),
));
};
let enum_ident = format_ident!("{}", enum_name.as_str());
Ok(quote! {
#wrapper_path { set: ::core::option::Option::Some(#enum_ident::#id), ..::core::default::Default::default() }
})
}
DslValue::Block(block) => {
lower_update_block(field, cat, nullable, &wrapper_path, block, span)
}
DslValue::List(_) => Err(syn::Error::new(
span,
format!(
"scalar field `{}` does not accept a list value on the update path",
field.name()
),
)),
}
}
fn wrapper_path_for(
cat: FilterCategory,
wrapper_ident: &syn::Ident,
field: &Field,
) -> syn::Result<TokenStream> {
if matches!(cat, FilterCategory::Enum) {
let FieldType::Enum(enum_name) = &field.field_type else {
return Err(syn::Error::new(
Span::call_site(),
"enum category requires an enum field type",
));
};
let enum_ident = format_ident!("{}", enum_name.as_str());
Ok(quote! { ::prax_query::inputs::#wrapper_ident::<#enum_ident> })
} else {
Ok(quote! { ::prax_query::inputs::#wrapper_ident })
}
}
fn lower_update_block(
field: &Field,
cat: FilterCategory,
nullable: bool,
wrapper_path: &TokenStream,
block: &DslBlock,
_span: Span,
) -> syn::Result<TokenStream> {
let mut setters: Vec<TokenStream> = Vec::new();
for entry in &block.fields {
let DslField::Pair { key, value, .. } = entry else {
return Err(syn::Error::new(
Span::call_site(),
format!(
"update block for `{}` does not support spread or conditional fields yet",
field.name()
),
));
};
let op = key.to_string();
match op.as_str() {
"set" => {
let v = lower_update_op_value(field, value, key.span())?;
setters.push(quote! { set: ::core::option::Option::Some(#v) });
}
"increment" | "decrement" | "multiply" | "divide" => {
if !category_has_arithmetic(cat) {
return Err(syn::Error::new(
key.span(),
format!(
"operator `{op}` is not valid on non-numeric field `{}` ({cat:?})",
field.name()
),
));
}
let v = lower_update_op_value(field, value, key.span())?;
let op_ident = format_ident!("{op}");
setters.push(quote! { #op_ident: ::core::option::Option::Some(#v) });
}
"unset" => {
if !nullable {
return Err(syn::Error::new(
key.span(),
format!(
"`unset` is only valid for nullable fields. Field `{}` is not nullable.",
field.name()
),
));
}
let DslValue::Bool(b) = value else {
return Err(syn::Error::new(
key.span(),
"`unset` expects `true` or `false`",
));
};
setters.push(quote! { unset: ::core::option::Option::Some(#b) });
}
other => {
return Err(syn::Error::new(
key.span(),
format!(
"unknown update operator `{other}` on `{}`. \
Valid: set, increment, decrement, multiply, divide, unset.",
field.name()
),
));
}
}
}
Ok(quote! {
#wrapper_path {
#(#setters,)*
..::core::default::Default::default()
}
})
}
fn lower_update_op_value(field: &Field, value: &DslValue, span: Span) -> syn::Result<TokenStream> {
match (&field.field_type, value) {
(FieldType::Enum(enum_name), DslValue::BareIdent(variant)) => {
let enum_ident = format_ident!("{}", enum_name.as_str());
Ok(quote! { #enum_ident::#variant })
}
(FieldType::Enum(_), DslValue::Path(p)) => Ok(quote! { #p }),
(FieldType::Enum(_), DslValue::Expr(e)) => Ok(quote! { #e }),
(_, DslValue::BareIdent(id)) => Err(syn::Error::new(
id.span(),
format!(
"bare identifier `{id}` is only allowed for enum-typed fields. \
Field `{}` is not an enum.",
field.name()
),
)),
(_, DslValue::Lit(lit)) => Ok(quote! { ::core::convert::Into::into(#lit) }),
(_, DslValue::Bool(b)) => Ok(quote! { #b }),
(_, DslValue::Expr(e)) => Ok(quote! { ::core::convert::Into::into(#e) }),
(_, DslValue::Path(p)) => Ok(quote! { ::core::convert::Into::into(#p) }),
(_, DslValue::Block(_)) => Err(syn::Error::new(
span,
format!(
"nested block is not a valid update-operator value for `{}`",
field.name()
),
)),
(_, DslValue::List(_)) => Err(syn::Error::new(
span,
format!(
"list is not a valid update-operator value for `{}`",
field.name()
),
)),
}
}
fn category_has_arithmetic(cat: FilterCategory) -> bool {
matches!(
cat,
FilterCategory::Int
| FilterCategory::BigInt
| FilterCategory::Float
| FilterCategory::Decimal
)
}
fn lookup_field_with_suggestion<'a>(
ctx: &LowerCtx<'a>,
key: &str,
span: Span,
) -> syn::Result<&'a Field> {
if let Some(f) = ctx.model.get_field(key) {
return Ok(f);
}
let candidates: Vec<String> = ctx.model.fields.keys().map(|k| k.to_string()).collect();
let suggestion = crate::macros::validate::suggest(key, &candidates);
let msg = match suggestion {
Some(c) => format!(
"unknown field `{key}` on model `{}` in `data:` block. did you mean `{c}`?",
ctx.model.name()
),
None => format!(
"unknown field `{key}` on model `{}` in `data:` block",
ctx.model.name()
),
};
Err(syn::Error::new(span, msg))
}
fn lower_create_conditional(field: &DslField, ctx: &LowerCtx<'_>) -> syn::Result<TokenStream> {
let DslField::Conditional {
cond,
kind,
key,
value,
..
} = field
else {
unreachable!("called with non-conditional");
};
let pair = lower_create_pair(key, value, ctx)?;
use crate::macros::dsl::ast::CondKind;
Ok(match kind {
CondKind::If => quote! { if #cond { #pair } },
CondKind::ElseIf => quote! { else if #cond { #pair } },
CondKind::Else => quote! { else { #pair } },
})
}
fn lower_update_conditional(field: &DslField, ctx: &LowerCtx<'_>) -> syn::Result<TokenStream> {
let DslField::Conditional {
cond,
kind,
key,
value,
..
} = field
else {
unreachable!("called with non-conditional");
};
let pair = lower_update_pair(key, value, ctx)?;
use crate::macros::dsl::ast::CondKind;
Ok(match kind {
CondKind::If => quote! { if #cond { #pair } },
CondKind::ElseIf => quote! { else if #cond { #pair } },
CondKind::Else => quote! { else { #pair } },
})
}
#[allow(dead_code)]
const _SCALAR_TYPE_USED: Option<ScalarType> = None;
#[cfg(test)]
mod tests {
use super::*;
use prax_schema::parse_schema;
use quote::quote;
const SCHEMA: &str = include_str!("../../../tests/fixtures/schema.prax");
fn parse_block(tokens: TokenStream) -> DslBlock {
syn::parse2::<DslBlock>(tokens).unwrap()
}
fn pretty(ts: TokenStream) -> String {
ts.to_string()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn parsed_schema() -> prax_schema::Schema {
let mut v = prax_schema::Validator::new();
v.validate(parse_schema(SCHEMA).unwrap()).unwrap()
}
fn create(model_name: &str, tokens: TokenStream) -> TokenStream {
let schema = parsed_schema();
let model = schema.get_model(model_name).unwrap().clone();
let ctx = LowerCtx::new(&schema, &model);
lower_create_data(&parse_block(tokens), &ctx).unwrap()
}
fn update(model_name: &str, tokens: TokenStream) -> TokenStream {
let schema = parsed_schema();
let model = schema.get_model(model_name).unwrap().clone();
let ctx = LowerCtx::new(&schema, &model);
lower_update_data(&parse_block(tokens), &ctx).unwrap()
}
#[test]
fn create_data_scalar_required_and_optional() {
let out = create("User", quote!({ email: "a@x.com", name: "Alice", age: 30 }));
let s = pretty(out);
assert!(s.contains("UserCreateInput"), "got: {s}");
assert!(s.contains("__d . email ="), "got: {s}");
assert!(s.contains("Some"), "got: {s}");
}
#[test]
fn create_data_bare_enum_ident() {
let out = create("User", quote!({ email: "a@x.com", role: Admin }));
let s = pretty(out);
assert!(s.contains("Role :: Admin"), "got: {s}");
}
#[test]
fn create_data_expression_escape() {
let out = create(
"User",
quote!({ email: @(format!("{}@x.com", name)), age: @(my_age) }),
);
let s = pretty(out);
assert!(s.contains("format !"), "got: {s}");
}
#[test]
fn create_data_with_spread() {
let out = create("User", quote!({ ..base, email: "a@x.com" }));
let s = pretty(out);
assert!(s.contains("Clone :: clone"), "got: {s}");
assert!(s.contains("base"), "got: {s}");
}
#[test]
fn create_data_relation_key_is_phase_5c_on_legacy_callers() {
let schema = parsed_schema();
let model = schema.get_model("User").unwrap().clone();
let ctx = LowerCtx::new(&schema, &model);
let block = parse_block(quote!({ email: "a@x.com", posts: { create: [] } }));
let err = lower_create_data(&block, &ctx).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("phase 5c"), "got: {msg}");
assert!(msg.contains("posts"), "got: {msg}");
}
#[test]
fn create_data_with_nested_lowers_relation_block_to_nested_ops() {
let schema = parsed_schema();
let model = schema.get_model("User").unwrap().clone();
let ctx = LowerCtx::new(&schema, &model);
let block = parse_block(quote!({
email: "a@x.com",
posts: { create: [{ title: "p1", published: true }] }
}));
let lowered = lower_create_data_with_nested(&block, &ctx).unwrap();
assert_eq!(lowered.nested_ops.len(), 1);
let scalar = pretty(lowered.scalar_input);
assert!(scalar.contains("UserCreateInput"), "got: {scalar}");
let nw = pretty(lowered.nested_ops[0].clone());
assert!(nw.contains("NestedWriteOp"), "got: {nw}");
}
#[test]
fn create_data_unknown_field_errors_with_suggestion() {
let schema = parsed_schema();
let model = schema.get_model("User").unwrap().clone();
let ctx = LowerCtx::new(&schema, &model);
let block = parse_block(quote!({ emial: "x" }));
let err = lower_create_data(&block, &ctx).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("unknown field"), "got: {msg}");
assert!(msg.contains("did you mean"), "got: {msg}");
assert!(msg.contains("email"), "got: {msg}");
}
#[test]
fn create_data_logical_op_rejected() {
let schema = parsed_schema();
let model = schema.get_model("User").unwrap().clone();
let ctx = LowerCtx::new(&schema, &model);
let block = parse_block(quote!({ or: [{}, {}] }));
let err = lower_create_data(&block, &ctx).unwrap_err();
assert!(err.to_string().contains("`where:` operator"), "got: {err}");
}
#[test]
fn update_data_plain_set_via_literal() {
let out = update("User", quote!({ email: "bob@x.com" }));
let s = pretty(out);
assert!(s.contains("UserUpdateInput"), "got: {s}");
assert!(s.contains("StringFieldUpdate"), "got: {s}");
assert!(s.contains("From"), "got: {s}");
}
#[test]
fn update_data_explicit_set_block() {
let out = update("User", quote!({ email: { set: "bob@x.com" } }));
let s = pretty(out);
assert!(s.contains("set :"), "got: {s}");
assert!(s.contains("Option :: Some"), "got: {s}");
}
#[test]
fn update_data_increment_on_numeric() {
let out = update("User", quote!({ age: { increment: 1 } }));
let s = pretty(out);
assert!(s.contains("IntNullableFieldUpdate"), "got: {s}");
assert!(s.contains("increment"), "got: {s}");
}
#[test]
fn update_data_decrement_on_numeric() {
let out = update("User", quote!({ age: { decrement: 2 } }));
let s = pretty(out);
assert!(s.contains("decrement"), "got: {s}");
}
#[test]
fn update_data_unset_on_nullable() {
let out = update("User", quote!({ name: { unset: true } }));
let s = pretty(out);
assert!(s.contains("StringNullableFieldUpdate"), "got: {s}");
assert!(s.contains("unset :"), "got: {s}");
assert!(s.contains("Option :: Some (true)"), "got: {s}");
}
#[test]
fn update_data_increment_on_string_errors() {
let schema = parsed_schema();
let model = schema.get_model("User").unwrap().clone();
let ctx = LowerCtx::new(&schema, &model);
let block = parse_block(quote!({ email: { increment: 1 } }));
let err = lower_update_data(&block, &ctx).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("increment"), "got: {msg}");
assert!(msg.contains("non-numeric"), "got: {msg}");
}
#[test]
fn update_data_unset_on_non_nullable_errors() {
let schema = parsed_schema();
let model = schema.get_model("User").unwrap().clone();
let ctx = LowerCtx::new(&schema, &model);
let block = parse_block(quote!({ email: { unset: true } }));
let err = lower_update_data(&block, &ctx).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("unset"), "got: {msg}");
assert!(msg.contains("nullable"), "got: {msg}");
}
#[test]
fn update_data_relation_key_is_phase_5c() {
let schema = parsed_schema();
let model = schema.get_model("User").unwrap().clone();
let ctx = LowerCtx::new(&schema, &model);
let block = parse_block(quote!({ posts: { update: [] } }));
let err = lower_update_data(&block, &ctx).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("phase 5c"), "got: {msg}");
}
#[test]
fn update_data_with_nested_lowers_relation_block_to_nested_ops() {
let schema = parsed_schema();
let model = schema.get_model("User").unwrap().clone();
let ctx = LowerCtx::new(&schema, &model);
let block = parse_block(quote!({
name: "Renamed",
posts: { create: [{ title: "p1", published: true }] }
}));
let lowered = lower_update_data_with_nested(&block, &ctx).unwrap();
assert_eq!(lowered.nested_ops.len(), 1);
let scalar = pretty(lowered.scalar_input);
assert!(scalar.contains("UserUpdateInput"), "got: {scalar}");
let nw = pretty(lowered.nested_ops[0].clone());
assert!(nw.contains("NestedWriteOp"), "got: {nw}");
}
#[test]
fn update_data_with_nested_mixes_scalar_and_disconnect() {
let schema = parsed_schema();
let model = schema.get_model("User").unwrap().clone();
let ctx = LowerCtx::new(&schema, &model);
let block = parse_block(quote!({
name: "Renamed",
posts: { disconnect: [{ id: 5 }] }
}));
let lowered = lower_update_data_with_nested(&block, &ctx).unwrap();
assert_eq!(
lowered.nested_ops.len(),
1,
"exactly one disconnect op expected"
);
let nw = pretty(lowered.nested_ops[0].clone());
assert!(nw.contains("Disconnect"), "got: {nw}");
}
fn create_err(model_name: &str, tokens: TokenStream) -> syn::Error {
let schema = parsed_schema();
let model = schema.get_model(model_name).unwrap().clone();
let ctx = LowerCtx::new(&schema, &model);
lower_create_data(&parse_block(tokens), &ctx).unwrap_err()
}
fn update_err(model_name: &str, tokens: TokenStream) -> syn::Error {
let schema = parsed_schema();
let model = schema.get_model(model_name).unwrap().clone();
let ctx = LowerCtx::new(&schema, &model);
lower_update_data(&parse_block(tokens), &ctx).unwrap_err()
}
#[test]
fn create_data_rejects_aggregate_field_with_computed_virtual_message() {
let err = create_err("User", quote!({ email: "a@x.com", post_count: 7 }));
let msg = err.to_string();
assert!(
msg.contains("computed virtual"),
"expected 'computed virtual' in error, got: {msg}"
);
assert!(
msg.contains("post_count"),
"expected field name in error, got: {msg}"
);
}
#[test]
fn create_data_rejects_generated_field_with_computed_virtual_message() {
let err = create_err(
"User",
quote!({ email: "a@x.com", full_name: "Alice Smith" }),
);
let msg = err.to_string();
assert!(
msg.contains("computed virtual"),
"expected 'computed virtual' in error, got: {msg}"
);
assert!(
msg.contains("full_name"),
"expected field name in error, got: {msg}"
);
}
#[test]
fn update_data_rejects_aggregate_field_with_computed_virtual_message() {
let err = update_err("User", quote!({ post_count: 7 }));
let msg = err.to_string();
assert!(
msg.contains("computed virtual"),
"expected 'computed virtual' in error, got: {msg}"
);
assert!(
msg.contains("post_count"),
"expected field name in error, got: {msg}"
);
}
#[test]
fn update_data_rejects_generated_field_with_computed_virtual_message() {
let err = update_err("User", quote!({ full_name: "Renamed" }));
let msg = err.to_string();
assert!(
msg.contains("computed virtual"),
"expected 'computed virtual' in error, got: {msg}"
);
assert!(
msg.contains("full_name"),
"expected field name in error, got: {msg}"
);
}
}