Static Assertions for create! Required Fields
The create! macro does not check that all required fields are specified.
Missing a required field compiles successfully but fails at runtime when the
database rejects a NULL value in a NOT NULL column. This design adds
compile-time checking so that omitting a required field is a compilation error.
Problem
Given these models:
#![allow(unused)]
fn main() {
#[derive(Model)]
struct User {
#[key]
#[auto]
id: Id<User>,
name: String,
#[has_many]
todos: HasMany<Todo>,
}
#[derive(Model)]
struct Todo {
#[key]
#[auto]
id: Id<Todo>,
#[index]
user_id: Id<User>,
#[belongs_to(key = user_id, references = id)]
user: BelongsTo<User>,
title: String,
}
}
This compiles today but panics at runtime:
#![allow(unused)]
fn main() {
// Missing `name` — no compile error
let user = toasty::create!(User { }).exec(&mut db).await?;
}
Approach
Per-level validation with monomorphization
Each model carries a flat CreateMeta constant that lists only its own
fields — no pointers to other models’ metadata. Validation happens one
nesting level at a time, using the compiler’s type inference at each level
to resolve the target model.
This avoids const evaluation cycles entirely. A const in Rust must be
fully evaluated before it exists, so if User::CREATE_META contained a
&'static reference to Todo::CREATE_META and vice versa, the compiler
would detect a cycle and reject it. By keeping each model’s metadata flat
and resolving cross-model references at each nesting level through
monomorphization, no model’s const ever needs to reference another.
CreateMeta struct
A simple struct in toasty::schema::create_meta (re-exported through
codegen_support) describes the fields a model exposes on creation:
#![allow(unused)]
fn main() {
pub struct CreateMeta {
pub fields: &'static [CreateField],
pub model_name: &'static str,
}
pub struct CreateField {
pub name: &'static str,
pub required: bool,
}
}
Each field’s required flag is computed at compile time using the
Field::NULLABLE trait constant, so the proc macro does not need to parse
Option<T> syntactically:
#![allow(unused)]
fn main() {
// generated by #[derive(Model)]
const CREATE_META: CreateMeta = CreateMeta {
fields: &[
CreateField { name: "name", required: !<String as Field>::NULLABLE },
CreateField { name: "bio", required: !<Option<String> as Field>::NULLABLE },
],
model_name: "User",
};
}
<String as Field>::NULLABLE is false, so required is true.
<Option<String> as Field>::NULLABLE is true, so required is false.
A const fn helper performs the actual checking:
#![allow(unused)]
fn main() {
pub const fn assert_create_fields(meta: &CreateMeta, provided: &[&str]) {
// panics at compile time listing the missing field
}
}
This uses byte-level string comparison (str::as_bytes() in a while
loop) since const fn cannot call trait methods like PartialEq.
ValidateCreate trait
A #[doc(hidden)] trait carries the CreateMeta reference. This trait
is the single mechanism used for validation at every level — typed creates,
scoped creates, and nested creates all use it through monomorphization:
#![allow(unused)]
fn main() {
#[doc(hidden)]
pub trait ValidateCreate {
const CREATE_META: &'static CreateMeta;
}
}
The derive macro generates ValidateCreate impls for:
- Fields structs (
UserFields<Origin>,TodoFieldsList<Origin>) — so that nested field accessors likeUser::fields().todos()return a type that carries the target model’s metadata. - Relation scope types (
Many,One,OptionOne) — so that scoped expressions likeuser.todos()return a type that carries the target model’s metadata.
Each impl simply references the target model’s CREATE_META:
#![allow(unused)]
fn main() {
// On the fields struct for Todo (generated by derive)
impl<__Origin> ValidateCreate for TodoFieldsList<__Origin> {
const CREATE_META: &'static CreateMeta = &Todo::CREATE_META;
}
// On the relation scope type (generated by derive)
impl ValidateCreate for Many {
const CREATE_META: &'static CreateMeta = &Todo::CREATE_META;
}
}
Because ValidateCreate is separate from Scope and Model, it carries
no other obligations and can be implemented on any generated type without
affecting the existing trait hierarchy.
Model trait
CREATE_META remains an associated constant on Model as well. This is
the canonical owned constant — the ValidateCreate impls reference it:
#![allow(unused)]
fn main() {
pub trait Model {
// ...existing associated types and methods...
const CREATE_META: CreateMeta;
}
}
CREATE_META is removed from the Scope trait. Scoped validation now
goes through ValidateCreate instead.
Which fields are included
CreateMeta.fields contains all primitive fields that are:
- Not
#[auto] - Not
#[default(...)] - Not
#[update(...)]
Each of these fields has required set to !<T as Field>::NULLABLE, so
Option<T> fields are included but marked as not required.
These fields are always excluded from the list entirely:
- Relation fields (
BelongsTo,HasMany,HasOne) - FK source fields (fields referenced by a
#[belongs_to(key = ...)]on the same model)
FK source fields are excluded from CreateMeta.fields because in a
top-level create they are set implicitly when you provide the BelongsTo
relation. In a nested or scoped create the parent context fills them in.
For the models above:
| Model | Required | Not required | Excluded |
|---|---|---|---|
User | name | id (auto), todos (relation) | |
Todo | title | id (auto), user_id (FK source), user (relation) |
File layout
crates/toasty/src/schema/create_meta.rs — CreateMeta, CreateField, const fn helpers
crates/toasty/src/schema.rs — pub mod create_meta; pub use ...
crates/toasty/src/lib.rs — codegen_support re-exports
Typed creates
create!(User { name: "Alice" }) expands to:
#![allow(unused)]
fn main() {
{
const _CREATE: () = toasty::codegen_support::assert_create_fields(
&<User as toasty::codegen_support::Model>::CREATE_META,
&["name"],
);
User::create().name("Alice")
}
}
The const _CREATE: () block forces compile-time evaluation. If
assert_create_fields panics, the compiler reports the panic message as
an error at the create! call site.
Scoped creates
create!(in user.todos() { title: "buy milk" }) is harder because the
macro does not know the scope type — it only has the expression
user.todos().
The workaround uses monomorphization-time const evaluation. The macro
generates a local generic struct bounded on ValidateCreate whose
associated constant contains the assertion, then forces monomorphization by
calling a helper function that infers the type from the expression:
#![allow(unused)]
fn main() {
{
let __scope = user.todos();
struct __Check<__S: toasty::codegen_support::ValidateCreate>(
std::marker::PhantomData<__S>,
);
impl<__S: toasty::codegen_support::ValidateCreate> __Check<__S> {
const __ASSERT: () = toasty::codegen_support::assert_create_fields(
__S::CREATE_META,
&["title"],
);
}
fn __force_check<__S: toasty::codegen_support::ValidateCreate>(_: &__S) {
let _ = __Check::<__S>::__ASSERT;
}
__force_check(&__scope);
let __scope_fields = toasty::codegen_support::scope_fields(&__scope);
__scope.create().title("buy milk")
}
}
This works because user.todos() returns a type (e.g. todo::Many) that
implements ValidateCreate. When the compiler monomorphizes
__Check<todo::Many>::__ASSERT, it evaluates the const expression. If it
panics, the error points at the create! call site. No unstable features
required.
Nested creates
Nested creates use the same monomorphization trick, but through the fields structs rather than the scope expression. Consider:
#![allow(unused)]
fn main() {
create!(User { name: "Alice", todos: [{ title: "Do it" }] })
}
The create! macro expands this to:
#![allow(unused)]
fn main() {
{
// Level 0: validate User's fields directly (type is known)
const _CREATE: () = {
toasty::codegen_support::assert_create_fields(
&<User as toasty::codegen_support::Model>::CREATE_META,
&["name", "todos"],
);
};
let __fields = User::fields();
// Level 1: validate Todo's fields via monomorphization
// __fields.todos() returns TodoFieldsList<User>, which impls ValidateCreate
{
let __nested = __fields.todos();
struct __Check<__S: toasty::codegen_support::ValidateCreate>(
std::marker::PhantomData<__S>,
);
impl<__S: toasty::codegen_support::ValidateCreate> __Check<__S> {
const __ASSERT: () = toasty::codegen_support::assert_create_fields(
__S::CREATE_META,
&["title"],
);
}
fn __force<__S: toasty::codegen_support::ValidateCreate>(_: &__S) {
let _ = __Check::<__S>::__ASSERT;
}
__force(&__nested);
}
User::create()
.name("Alice")
.todos([__fields.todos().create().title("Do it")])
}
}
The key: User::fields().todos() returns TodoFieldsList<User>, which
implements ValidateCreate with CREATE_META = &Todo::CREATE_META. The
monomorphization trick infers the concrete type and evaluates the const
assertion for Todo’s fields.
Arbitrary nesting depth
Each nesting level is an independent const evaluation. For deeper nesting:
#![allow(unused)]
fn main() {
create!(User {
name: "Alice",
todos: [{
title: "Do it",
categories: [{ name: "Work" }]
}]
})
}
The macro emits three independent validation blocks:
- Level 0:
assert_create_fields(&User::CREATE_META, &["name", "todos"])— direct const, no monomorphization needed. - Level 1: monomorphize on
User::fields().todos()(which isTodoFieldsList<User>, targetingTodo) to check["title", "categories"]. - Level 2: monomorphize on
Todo::fields().categories()to check["name"].
No model’s CREATE_META ever references another model’s CREATE_META.
Each level resolves the target model through the type system at
monomorphization time, not through &'static pointers at const evaluation
time.
Why this avoids const cycles
The previous design embedded &'static CreateMeta pointers in a
CreateNested struct, so User::CREATE_META contained a reference to
Todo::CREATE_META and vice versa. This creates a const evaluation
cycle: the compiler must fully evaluate a const before it exists, but
evaluating User::CREATE_META requires Todo::CREATE_META which requires
User::CREATE_META.
The new design eliminates cross-model references entirely:
#![allow(unused)]
fn main() {
// User::CREATE_META — only knows about User's own fields
const CREATE_META: CreateMeta = CreateMeta {
fields: &[CreateField { name: "name", required: true }],
model_name: "User",
};
// Todo::CREATE_META — only knows about Todo's own fields
const CREATE_META: CreateMeta = CreateMeta {
fields: &[CreateField { name: "title", required: true }],
model_name: "Todo",
};
}
Cross-model resolution happens at monomorphization time through
ValidateCreate impls on the fields structs. Function definitions don’t
create const evaluation cycles — only const definitions that reference each
other do. So even for self-referential models:
#![allow(unused)]
fn main() {
#[derive(Model)]
struct Person {
#[key] #[auto] id: Id<Person>,
name: String,
#[has_many]
children: HasMany<Person>,
}
}
Person::CREATE_META contains only [CreateField { name: "name", ... }].
The derive generates ValidateCreate for PersonFieldsList<Origin> pointing
at &Person::CREATE_META. When the create! macro validates a nested
children: [{ name: "Kid" }], it monomorphizes through
Person::fields().children() which returns PersonFieldsList<Person>,
evaluating Person::CREATE_META — no cycle because Person::CREATE_META
doesn’t reference itself.
Batch and tuple creates
TypedBatch (User::[{ name: "A" }, { name: "B" }]): Each item in the
batch gets its own assertion since different items can specify different
field sets.
Tuple ((User { name: "A" }, Todo { title: "x" })): Each element is
a CreateItem and is checked independently.
Code generation changes
#[derive(Model)] changes
The derive macro generates:
-
CREATE_METAonimpl Model— a flatCreateMetacontaining only the model’s own primitive fields (filtered as described in “Which fields are included”). -
ValidateCreateimpls on the fields structs (UserFields<Origin>andUserFieldsList<Origin>) referencing&<Model>::CREATE_META. -
ValidateCreateimpls on the relation scope types (Many,One,OptionOne) referencing&<Model>::CREATE_META.
The Scope trait no longer carries CREATE_META.
create! macro changes
The expand function in create/expand.rs emits validation at each
nesting level:
- Typed top-level: a plain
constassertion using<Path as Model>::CREATE_METAdirectly. - Scoped top-level: a monomorphization block bounded on
ValidateCreate, inferring the type from the scope expression. - Each nested level: a monomorphization block bounded on
ValidateCreate, inferring the type from the fields struct accessor (e.g.User::fields().todos()).
The macro walks the parsed FieldSet tree recursively, emitting one
validation block per nesting level.
Example error messages
Missing a top-level field:
error[E0080]: evaluation panicked: missing required field `name` in create! for `User`
--> src/main.rs:10:5
|
10 | toasty::create!(User { })
| ^^^^^^^^^^^^^^^^^^^^^^^^^ evaluation of `_CREATE` failed inside this call
Missing a nested field:
error[E0080]: evaluation panicked: missing required field `title` in create! for `Todo`
--> src/main.rs:12:5
|
12 | toasty::create!(User { name: "Alice", todos: [{ }] })
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ evaluation of `_CREATE` failed inside this call
Limitations and future work
-
Embedded model fields are not included in
CreateMeta. Fields whose type implementsEmbed(via#[derive(Embed)]) are skipped because they are notFieldTy::Primitive. A future enhancement should include them. -
#[serialize]fields are excluded because their Rust types (e.g.Vec<String>,HashMap<K,V>, custom structs) do not implement theFieldtrait, so<T as Field>::NULLABLEcannot be evaluated. A future enhancement could infer nullability syntactically or introduce a separate trait bound for serialized fields. -
BelongsTorelation fields themselves are not checked. If you writecreate!(Todo { title: "x" })without providinguseroruser_id, it compiles but fails at the database. A future enhancement could add disjunction checking (requireuserORuser_idin top-level creates). In nested and scoped creates this is not a problem because the parent context provides the FK. -
Error messages include the field name but not a file/line pointer to the model definition. The Rust compiler’s error output shows the
create!call site, which is the actionable location.