Part 2: Models and Database
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:
| Database | Best For | Feature Flag | Default in standard Bundle |
|---|---|---|---|
| PostgreSQL | Production, complex queries, full-text search | db-postgres | ✅ Yes |
| SQLite | Development, small projects, prototyping | db-sqlite | ❌ No (must add explicitly) |
| MySQL | Existing MySQL infrastructure | db-mysql | ❌ No (must add explicitly) |
| CockroachDB | Distributed 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 pathPostgreSQL Setup (Recommended for Production)
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:17MySQL 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 theModeltrait 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 keysForeignKeyField<T>- Type-safe foreign key field referencing modelTString- Character field for textDateTime<Utc>- DateTime field for timestampsi32- 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 keymax_length = 200- Sets maximum length for string fieldsauto_now_add = true- Automatically sets timestamp on creationdefault = 0- Sets default value for the field
Relationship Attributes:
#[rel(foreign_key, related_name = "name")]- Defines a foreign key relationship- ⚠️ IMPORTANT:
related_nameis REQUIRED for#[rel(foreign_key)] - It defines the name for reverse access from the related model
- Example:
related_name = "choices"allowsQuestion.choices_accessor()
- ⚠️ IMPORTANT:
Model Macro Benefits
The #[model(...)] macro automatically generates:
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>>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 databaseQuerySet 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 makemigrationsThis 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 migrateYou should see output like:
Running migrations:
Applying polls.0001_initial... OKWhat happened?
makemigrationsdetected yourQuestionandChoicemodels- Generated Rust migration code in
migrations/polls/_0001_initial.rs migrateapplied 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 nameChoice::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(), andrefresh_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
Fexpressions 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:
- rstest fixtures: Reusable test setup with
#[fixture] - TestContainers: Real PostgreSQL database for each test
- Automatic cleanup: Containers are destroyed after tests
- Type-safe queries: Using field accessors in assertions
- F expressions: Testing atomic update operations
Run the test:
cargo test --package pollsUnderstanding 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:
- Registration: The server registers
DatabaseConnectionat startup - Injection: The
#[inject]attribute tells Reinhardt to inject the dependency - 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 connectionResult<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 dataBenefits:
- 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 (
makemigrationsandmigrate) - 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.