Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 like User::fields().todos() return a type that carries the target model’s metadata.
  • Relation scope types (Many, One, OptionOne) — so that scoped expressions like user.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:

ModelRequiredNot requiredExcluded
Usernameid (auto), todos (relation)
Todotitleid (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:

  1. Level 0: assert_create_fields(&User::CREATE_META, &["name", "todos"]) — direct const, no monomorphization needed.
  2. Level 1: monomorphize on User::fields().todos() (which is TodoFieldsList<User>, targeting Todo) to check ["title", "categories"].
  3. 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:

  1. CREATE_META on impl Model — a flat CreateMeta containing only the model’s own primitive fields (filtered as described in “Which fields are included”).

  2. ValidateCreate impls on the fields structs (UserFields<Origin> and UserFieldsList<Origin>) referencing &<Model>::CREATE_META.

  3. ValidateCreate impls 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 const assertion using <Path as Model>::CREATE_META directly.
  • 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 implements Embed (via #[derive(Embed)]) are skipped because they are not FieldTy::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 the Field trait, so <T as Field>::NULLABLE cannot be evaluated. A future enhancement could infer nullability syntactically or introduce a separate trait bound for serialized fields.

  • BelongsTo relation fields themselves are not checked. If you write create!(Todo { title: "x" }) without providing user or user_id, it compiles but fails at the database. A future enhancement could add disjunction checking (require user OR user_id in 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.