Part 2: Models and Database

In this tutorial, we'll set up a database and create our first models using Reinhardt's ORM layer, which is built on reinhardt-query.

Database Setup

Reinhardt supports multiple databases including PostgreSQL, MySQL, and SQLite. For this tutorial, we'll use SQLite for simplicity.

Configuring the Database

First, add the database dependencies to Cargo.toml:

[dependencies]
reinhardt = { workspace = true, features = ["database", "orm"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] }

Note: Instead of adding reinhardt-db directly, we enable the database and orm features on the reinhardt crate. This provides access to all database functionality through reinhardt::prelude::*.

Choosing a Database

Reinhardt supports multiple databases. Choose based on your project needs:

DatabaseBest ForFeature FlagDefault in standard Bundle
PostgreSQLProduction, complex queries, full-text searchdb-postgres✅ Yes
SQLiteDevelopment, small projects, prototypingdb-sqlite❌ No (must add explicitly)
MySQLExisting MySQL infrastructuredb-mysql❌ No (must add explicitly)
CockroachDBDistributed databases (uses Postgres protocol)db-cockroachdb❌ No (must add explicitly)

SQLite Setup (This Tutorial)

For this tutorial, we use SQLite for simplicity. SQLite requires no separate database server and stores data in a single file.

Cargo.toml:

[dependencies]
reinhardt = { version = "0.1.0-alpha.18", package = "reinhardt-web", default-features = false, features = ["standard", "db-sqlite"] }

settings/base.toml:

[database]
engine = "sqlite"
name = "polls.db"  # Database file path

PostgreSQL is included by default with the standard bundle and is recommended for production use.

Cargo.toml:

[dependencies]
reinhardt = { version = "0.1.0-alpha.18", package = "reinhardt-web", features = ["standard"] }
# db-postgres is automatically included with "standard"

settings/base.toml:

[database]
engine = "postgresql"
host = "localhost"
port = 5432
name = "mydb"
user = "postgres"
password = "postgres"

Docker setup (for local development):

docker run --name postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:17

MySQL Setup

Cargo.toml:

[dependencies]
reinhardt = { version = "0.1.0-alpha.18", package = "reinhardt-web", default-features = false, features = ["standard", "db-mysql"] }

settings/base.toml:

[database]
engine = "mysql"
host = "localhost"
port = 3306
name = "mydb"
user = "root"
password = "root"

For more database options and configurations, see the Feature Flags Guide.

Configuration for This Tutorial

Create or update settings/base.toml with SQLite configuration:

debug = true
secret_key = "your-secret-key-for-development"

[database]
engine = "sqlite"
name = "polls.db"

Note: Reinhardt projects generated by reinhardt-admin startproject already include settings configuration. The database connection is automatically established when you run cargo make runserver.

Creating Models

A model is the single, definitive source of information about your data. It contains the essential fields and behaviors of the data you're storing.

Let's create two models for our polls application:

  • Question - Stores poll questions with their publication date
  • Choice - Stores choices for each question with their vote counts

Create polls/models.rs:

use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use reinhardt::prelude::*;
use reinhardt::db::associations::ForeignKeyField;

#[model(app_label = "polls", table_name = "polls_question")]
#[derive(Serialize, Deserialize)]
pub struct Question {
    #[field(primary_key = true)]
    pub id: i64,

    #[field(max_length = 200)]
    pub question_text: String,

    #[field(auto_now_add = true)]
    pub pub_date: DateTime<Utc>,
}

#[model(app_label = "polls", table_name = "polls_choice")]
#[derive(Serialize, Deserialize)]
pub struct Choice {
    #[field(primary_key = true)]
    pub id: i64,

    // ⚠️ IMPORTANT: related_name is REQUIRED for #[rel(foreign_key)]
    // It defines the reverse accessor name on the Question model
    #[rel(foreign_key, related_name = "choices")]
    question: ForeignKeyField<Question>,

    #[field(max_length = 200)]
    pub choice_text: String,

    #[field(default = 0)]
    pub votes: i32,
}

impl Question {
    /// Check if this question was published recently (within the last day)
    pub fn was_published_recently(&self) -> bool {
        let now = Utc::now();
        let one_day_ago = now - chrono::Duration::days(1);
        self.pub_date >= one_day_ago && self.pub_date <= now
    }
}

These models define:

  • Question: Has an auto-incrementing ID (i64), question text, and publication date
  • Choice: Has an ID, references a Question (via ForeignKeyField<Question>), choice text, and vote count

Note: The #[model(...)] macro automatically generates a new() function for creating model instances. Primary key fields (like id) are auto-generated by the database and should not be passed to new().

Important: The #[model(...)] macro automatically derives the Model trait and generates all necessary CRUD methods. You don't need to add #[derive(Model)] manually. Only add #[derive(Serialize, Deserialize)] for serde support if needed.

Understanding Fields

Let's break down the field types:

  • i64 - Integer field for IDs and foreign keys
  • ForeignKeyField<T> - Type-safe foreign key field referencing model T
  • String - Character field for text
  • DateTime<Utc> - DateTime field for timestamps
  • i32 - Integer field for vote counts

Field Attributes

The #[field(...)] and #[rel(...)] attributes provide metadata for the ORM:

Field Attributes:

  • primary_key = true - Marks the field as the primary key
  • max_length = 200 - Sets maximum length for string fields
  • auto_now_add = true - Automatically sets timestamp on creation
  • default = 0 - Sets default value for the field

Relationship Attributes:

  • #[rel(foreign_key, related_name = "name")] - Defines a foreign key relationship
    • ⚠️ IMPORTANT: related_name is REQUIRED for #[rel(foreign_key)]
    • It defines the name for reverse access from the related model
    • Example: related_name = "choices" allows Question.choices_accessor()

Model Macro Benefits

The #[model(...)] macro automatically generates:

  1. Type-Safe Field Accessors: Access fields with type safety

    Question::field_id()           // Returns FieldRef<Question, Option<i64>>
    Question::field_question_text() // Returns FieldRef<Question, String>
    Question::field_pub_date()     // Returns FieldRef<Question, DateTime<Utc>>
  2. CRUD Methods: Basic database operations

    question.save(&conn).await?;           // Insert or update
    question.delete(&conn).await?;         // Delete from database
    question.refresh_from_db(&conn).await?; // Reload from database
  3. QuerySet Methods: Type-safe querying

    Question::objects()
        .filter(Question::field_pub_date().gte(Utc::now()))
        .all(&conn)
        .await?;

These methods are generated at compile time, providing zero-cost abstractions with full type safety.

Creating the Database Schema with Migrations

Instead of manually creating SQL files, Reinhardt provides Django-style automatic migration generation:

cargo make makemigrations

This command analyzes your models and automatically generates migration files in migrations/polls/. The generated migration will create tables with proper schema.

To apply the migrations:

cargo make migrate

You should see output like:

Running migrations:
  Applying polls.0001_initial... OK

What happened?

  1. makemigrations detected your Question and Choice models
  2. Generated Rust migration code in migrations/polls/_0001_initial.rs
  3. migrate applied these migrations to create the database tables

Playing with the Database API

Now let's use Reinhardt's ORM to interact with the database. With #[derive(Model)], many common operations are automatically available. Here are some examples:

Creating Records

use reinhardt::prelude::*;
use chrono::Utc;

// Create a new question using the auto-generated new() function
let mut question = Question::new(
    "What's your favorite programming language?".to_string(),
    Utc::now()
);

// Save to database (generated by Model macro)
question.save(&conn).await?;
println!("Created question with ID: {}", question.id);

Querying Records

// Get all questions ordered by publication date (using type-safe field accessors)
let questions = Question::objects()
    .order_by(Question::field_pub_date(), false)  // false = DESC
    .all(&conn)
    .await?;

// Filter questions by date
let recent_questions = Question::objects()
    .filter(Question::field_pub_date().gte(Utc::now() - chrono::Duration::days(7)))
    .all(&conn)
    .await?;

// Get a specific question by ID
let question = Question::objects()
    .filter(Question::field_id().eq(1))
    .first(&conn)
    .await?
    .ok_or("Question not found")?;

Relationships

The #[rel] macro automatically generates accessor methods for relationships:

// Auto-generated accessor for reverse relationship (Question -> Choice)
let choices_accessor = Choice::question_accessor().reverse(&question, &conn);

// Get all related choices
let choices = choices_accessor.all().await?;

// Count related choices
let choice_count = choices_accessor.count().await?;

// Filter related choices
let active_choices = choices_accessor
    .filter(Choice::field_votes().gt(0))
    .all()
    .await?;

Key Points:

  • related_name = "choices" in the #[rel] attribute defines the reverse accessor name
  • Choice::question_accessor() provides the accessor for the forward relationship
  • .reverse(&question, &conn) creates a reverse query from Question to Choice
  • The accessor API provides: all(), count(), filter(), paginate(), etc.
  • Type safety: Compile-time validation of relationships
  • Lazy loading: Queries execute only when needed

Updating Records

// Update using F expressions (atomic database operations)
Choice::objects()
    .filter(Choice::field_id().eq(choice_id))
    .update()
    .set(Choice::field_votes(), F::new(Choice::field_votes()) + 1)
    .execute(&conn)
    .await?;

Deleting Records

// Delete a record (generated by Model macro)
question.delete(&conn).await?;

Custom Helper Methods

You can still add custom methods to your models:

impl Question {
    /// Check if this question was published recently (within the last day)
    pub fn was_published_recently(&self) -> bool {
        let now = Utc::now();
        let one_day_ago = now - chrono::Duration::days(1);
        self.pub_date >= one_day_ago && self.pub_date <= now
    }

    /// Get all choices for this question
    /// Note: This is equivalent to using the auto-generated accessor:
    ///   Choice::question_accessor().reverse(&self, conn).all().await
    pub async fn choices(
        &self,
        conn: &DatabaseConnection
    ) -> Result<Vec<Choice>, Box<dyn std::error::Error>> {
        let choices_accessor = Choice::question_accessor().reverse(self, conn);
        choices_accessor.all().await
    }
}

Recommended Approach:

Instead of manually implementing choices(), use the auto-generated accessor directly:

// Directly use auto-generated accessor (recommended)
let choices_accessor = Choice::question_accessor().reverse(&question, &conn);
let choices = choices_accessor.all().await?;

// Or implement a custom helper method (shown above) for convenience
let choices = question.choices(&conn).await?;

The auto-generated accessors provide:

  • Type safety: Compile-time validation
  • Rich API: Filtering, pagination, counting out of the box
  • Lazy loading: Queries only when needed
  • Performance: Optimized query generation

Key Points:

  • save(), delete(), and refresh_from_db() are automatically generated by #[model(...)]
  • Use Model::objects() to start building queries
  • Use type-safe field accessors like Question::field_id() instead of string literals
  • Use F expressions for atomic database updates (e.g., incrementing counters)

Testing the Models

Let's create proper tests using rstest and TestContainers. First, add test dependencies to Cargo.toml:

[dev-dependencies]
rstest = "0.22"
testcontainers = "0.23"
tokio = { version = "1", features = ["full"] }

Create polls/tests.rs:

use super::models::{Question, Choice};
use reinhardt::prelude::*;
use reinhardt::test::fixtures::*;
use chrono::Utc;
use rstest::*;
use testcontainers::ContainerAsync;
use testcontainers_modules::postgres::Postgres;

#[fixture]
async fn postgres_db() -> (ContainerAsync<Postgres>, Arc<DatabaseConnection>) {
    let postgres = Postgres::default()
        .start()
        .await
        .expect("Failed to start PostgreSQL");

    let port = postgres.get_host_port_ipv4(5432).await.unwrap();
    let url = format!("postgres://postgres:postgres@localhost:{}/test_db", port);

    let conn = DatabaseConnection::connect(&url).await.unwrap();
    let conn = Arc::new(conn);

    // Run migrations
    run_migrations(&conn).await.unwrap();

    (postgres, conn)
}

#[rstest]
#[tokio::test]
async fn test_create_question_and_choices(
    #[future] postgres_db: (ContainerAsync<Postgres>, Arc<DatabaseConnection>)
) {
    let (_container, conn) = postgres_db.await;

    // Create a question using the auto-generated new() function
    let mut question = Question::new(
        "What's your favorite programming language?".to_string(),
        Utc::now()
    );

    question.save(&conn).await.unwrap();
    assert!(question.id > 0);

    // Add some choices
    let question_id = question.id;

    let mut rust_choice = Choice::new(
        ForeignKeyField::new(question_id),
        "Rust".to_string(),
        0
    );
    rust_choice.save(&conn).await.unwrap();

    let mut python_choice = Choice::new(
        ForeignKeyField::new(question_id),
        "Python".to_string(),
        0
    );
    python_choice.save(&conn).await.unwrap();

    // Retrieve the question
    let retrieved_question = Question::objects()
        .filter(Question::field_id().eq(question_id))
        .first(&conn)
        .await
        .unwrap()
        .expect("Question not found");

    assert_eq!(retrieved_question.question_text, question.question_text);
    assert!(retrieved_question.was_published_recently());

    // Get choices using auto-generated accessor
    let choices_accessor = Choice::question_accessor().reverse(&retrieved_question, &conn);
    let choices = choices_accessor.all().await.unwrap();
    assert_eq!(choices.len(), 2);

    // Container is automatically cleaned up when dropped
}

#[rstest]
#[tokio::test]
async fn test_increment_votes(
    #[future] postgres_db: (ContainerAsync<Postgres>, Arc<DatabaseConnection>)
) {
    let (_container, conn) = postgres_db.await;

    // Create question and choice
    let mut question = Question::new(
        "Test question".to_string(),
        Utc::now()
    );
    question.save(&conn).await.unwrap();

    let mut choice = Choice::new(
        ForeignKeyField::new(question.id),
        "Test choice".to_string(),
        0
    );
    choice.save(&conn).await.unwrap();
    let choice_id = choice.id;

    // Increment votes using F expression
    Choice::objects()
        .filter(Choice::field_id().eq(choice_id))
        .update()
        .set(Choice::field_votes(), F::new(Choice::field_votes()) + 1)
        .execute(&conn)
        .await
        .unwrap();

    // Verify votes incremented
    let updated_choice = Choice::objects()
        .filter(Choice::field_id().eq(choice_id))
        .first(&conn)
        .await
        .unwrap()
        .expect("Choice not found");

    assert_eq!(updated_choice.votes, 1);
}

Key Testing Features:

  1. rstest fixtures: Reusable test setup with #[fixture]
  2. TestContainers: Real PostgreSQL database for each test
  3. Automatic cleanup: Containers are destroyed after tests
  4. Type-safe queries: Using field accessors in assertions
  5. F expressions: Testing atomic update operations

Run the test:

cargo test --package polls

Understanding Dependency Injection

Reinhardt provides FastAPI-style dependency injection via the #[inject] attribute. This eliminates boilerplate code for accessing shared resources like database connections.

Without DI (manual approach):

async fn index(req: Request) -> Result<Response> {
    // Manually extract from request extensions
    let conn = req.extensions
        .get::<Arc<DatabaseConnection>>()
        .ok_or("Database not configured")?;

    let questions = Question::all(conn).await?;
    // ...
}

With DI (automatic approach):

#[get("/", name = "index")]
async fn index(
    #[inject] conn: Arc<DatabaseConnection>,  // Automatically injected!
) -> Result<Response> {
    let questions = Question::all(&conn).await?;
    // ...
}

How it works:

  1. Registration: The server registers DatabaseConnection at startup
  2. Injection: The #[inject] attribute tells Reinhardt to inject the dependency
  3. Type safety: Compile error if the dependency type isn't registered

Caching behavior:

  • #[inject] - Cached (default) - Reuses the same instance across requests
  • #[inject(cache = false)] - Non-cached - Creates a new instance per request

Benefits:

  • Less boilerplate - No manual extraction from request extensions
  • Type-safe - Compiler verifies dependency types
  • Testable - Easy to mock dependencies in tests
  • Flexible - Support for both cached and non-cached dependencies

For HTTP method decorators with dependency injection, see the REST API Tutorial - Dependency Injection.

Using the ORM in Server Functions

Now that we have models, let's use them in server functions to fetch data for our components.

Define Shared Types

First, create data transfer objects (DTOs) in src/shared/types.rs:

use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuestionInfo {
    pub id: i64,
    pub question_text: String,
    pub pub_date: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChoiceInfo {
    pub id: i64,
    pub question_id: i64,
    pub choice_text: String,
    pub votes: i32,
}

// Server-side conversion (NOT available in WASM)
#[cfg(native)]
impl From<crate::apps::polls::models::Question> for QuestionInfo {
    fn from(q: crate::apps::polls::models::Question) -> Self {
        Self {
            id: q.id,
            question_text: q.question_text,
            pub_date: q.pub_date,
        }
    }
}

#[cfg(native)]
impl From<crate::apps::polls::models::Choice> for ChoiceInfo {
    fn from(c: crate::apps::polls::models::Choice) -> Self {
        Self {
            id: c.id,
            question_id: c.question_id(),
            choice_text: c.choice_text,
            votes: c.votes,
        }
    }
}

Why DTOs?

  • Separation - Models contain database logic, DTOs are pure data
  • Serialization - DTOs are designed for JSON serialization
  • WASM compatibility - DTOs work in both server and client code
  • Flexibility - Can reshape data for specific use cases

Create Server Functions

Create src/server_fn/polls.rs:

use reinhardt::pages::server_fn::{ServerFnError, server_fn};
use crate::shared::types::{QuestionInfo, ChoiceInfo};

/// Get all questions
#[server_fn(use_inject = true)]
pub async fn get_questions(
    #[inject] _db: reinhardt::DatabaseConnection,
) -> Result<Vec<QuestionInfo>, ServerFnError> {
    use crate::apps::polls::models::Question;
    use reinhardt::Model;

    let questions = Question::objects()
        .all()
        .all()
        .await
        .map_err(|e| ServerFnError::application(e.to_string()))?;

    Ok(questions.into_iter()
        .map(QuestionInfo::from)
        .collect())
}

/// Get question detail with choices
#[server_fn(use_inject = true)]
pub async fn get_question_detail(
    question_id: i64,
    #[inject] _db: reinhardt::DatabaseConnection,
) -> Result<(QuestionInfo, Vec<ChoiceInfo>), ServerFnError> {
    use crate::apps::polls::models::{Question, Choice};
    use reinhardt::Model;

    // Get question
    let question = Question::objects()
        .get(question_id)
        .await
        .map_err(|e| ServerFnError::application(e.to_string()))?;

    // Get choices for this question
    let choices = Choice::objects()
        .filter(Choice::field_question().eq(question_id))
        .all()
        .await
        .map_err(|e| ServerFnError::application(e.to_string()))?;

    Ok((
        QuestionInfo::from(question),
        choices.into_iter().map(ChoiceInfo::from).collect()
    ))
}

Key features:

  • #[server_fn(use_inject = true)] - Enables dependency injection
  • #[inject] _db: reinhardt::DatabaseConnection - Auto-injected database connection
  • Result<T, ServerFnError> - Required return type for server functions
  • Type conversion (QuestionInfo::from(question)) - Convert models to DTOs

Use in Components

Now use these server functions in components. Update src/client/components/polls.rs:

use reinhardt::pages::component::View;
use reinhardt::pages::page;
use reinhardt::pages::reactive::hooks::use_state;
use reinhardt::pages::Signal;
use crate::shared::types::{QuestionInfo, ChoiceInfo};

#[cfg(wasm)]
use {
    crate::server_fn::polls::{get_questions, get_question_detail},
    wasm_bindgen_futures::spawn_local,
};

pub fn polls_index() -> View {
    let (questions, set_questions) = use_state(Vec::<QuestionInfo>::new());
    let (loading, set_loading) = use_state(true);

    #[cfg(wasm)]
    {
        let set_questions = set_questions.clone();
        let set_loading = set_loading.clone();

        spawn_local(async move {
            match get_questions().await {
                Ok(qs) => {
                    set_questions(qs);
                    set_loading(false);
                }
                Err(e) => {
                    log::error!("Failed to load questions: {}", e);
                    set_loading(false);
                }
            }
        });
    }

    let questions_signal = questions.clone();
    let loading_signal = loading.clone();

    page!(|questions_signal: Signal<Vec<QuestionInfo>>, loading_signal: Signal<bool>| {
        div {
            class: "max-w-4xl mx-auto px-4 mt-12",
            h1 {
                class: "text-3xl font-bold mb-6",
                "Polls"
            }
            watch {
                if loading_signal.get() {
                    div { "Loading..." }
                } else {
                    div {
                        class: "space-y-2",
                        // Render questions list
                        // (see examples-tutorial-basis for complete implementation)
                    }
                }
            }
        }
    })(questions_signal, loading_signal)
}

How It Works

Data Flow:

Component Mount

spawn_local(async { get_questions().await })

HTTP POST to server function endpoint

Server executes with injected DB connection

Query database → Convert to DTOs → Return JSON

Client deserializes JSON → Update signal

Component re-renders with new data

Benefits:

  • Type safety - Compiler checks types across client/server boundary
  • No manual API - Server functions generate endpoints automatically
  • Automatic serialization - No need to manually convert to/from JSON
  • Dependency injection - Database connection injected automatically

Introduction to the Reinhardt Admin

The Reinhardt admin is an automatically-generated interface for managing your data. Let's enable it for our models.

Add the admin dependency to Cargo.toml:

[dependencies]
reinhardt = { version = "0.1.0-alpha.18", package = "reinhardt-web", features = ["standard", "admin"] }

Register your models in polls/admin.rs:

use reinhardt::contrib::admin::ModelAdmin;
use crate::models::{Question, Choice};

pub fn register_admin(admin_site: &mut AdminSite) {
    admin_site.register::<Question>(ModelAdmin::default());
    admin_site.register::<Choice>(ModelAdmin::default());
}

The admin interface will be covered in more detail in Part 7, but for now, know that you can access it at http://127.0.0.1:8000/admin/ after creating a superuser.

Summary

In this tutorial, you learned:

  • How to configure a database connection using settings files
  • How to define models with Reinhardt's ORM
  • How to use automatic migration generation (makemigrations and migrate)
  • How to perform CRUD operations using QuerySet API
  • How to use dependency injection to access the database in views
  • The relationship between models (foreign keys)
  • How to query the database using the ORM API (not raw SQL)

What's Next?

Now that our models are set up, we can start building views that display this data to users. In the next tutorial, we'll create views that show poll questions and their details.

Continue to Part 3: Views and URLs.