Przejdź do głównej zawartości

SQLx Patterns

SQLx to async SQL toolkit dla Rust z compile-time verified queries. Vista używa SQLx zamiast tradycyjnego ORM.

CechaOpis
Compile-time verificationBłędy SQL wykrywane podczas kompilacji
Type-safeAutomatyczne mapowanie typów Rust ↔ SQL
AsyncPełna integracja z Tokio
No ORM overheadRaw SQL z pełną kontrolą

// Kompilator sprawdzi:
// 1. Czy tabela istnieje
// 2. Czy kolumny istnieją
// 3. Czy typy się zgadzają
let visits = sqlx::query!(
"SELECT visit_id, patient_id, soap_subjective
FROM visits
WHERE user_id = ? AND date_time > ?",
user_id,
since_date
).fetch_all(&pool).await?;
// Dostęp do pól - sprawdzony compile-time
for row in visits {
println!("{}: {}", row.visit_id, row.soap_subjective.unwrap_or_default());
}
#[derive(sqlx::FromRow)]
pub struct Visit {
pub visit_id: String,
pub patient_id: String,
pub user_id: String,
pub date_time: String,
pub visit_status: String,
pub soap_subjective: Option<String>,
// ...
}
// Automatyczne mapowanie na struct
let visits = sqlx::query_as!(
Visit,
"SELECT * FROM visits WHERE user_id = ? ORDER BY date_time DESC LIMIT ? OFFSET ?",
user_id,
limit,
offset
).fetch_all(&pool).await?;

src-tauri/src/database/mod.rs
pub struct Database {
pub pool: SqlitePool,
}
impl Database {
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
let pool = SqlitePoolOptions::new()
.max_connections(10) // Max concurrent connections
.acquire_timeout(Duration::from_secs(30))
.idle_timeout(Duration::from_secs(600)) // 10 minutes
.connect(database_url)
.await?;
Ok(Database { pool })
}
}
// Wykonywane przy inicjalizacji połączenia
PRAGMA journal_mode = WAL; // Write-Ahead Logging
PRAGMA synchronous = NORMAL; // Balans bezpieczeństwo/wydajność
PRAGMA foreign_keys = ON; // Wymuszaj FK constraints
PRAGMA busy_timeout = 3000; // 3s timeout przy blokadach

pub async fn create_visit_with_recording(
pool: &SqlitePool,
visit_data: CreateVisitRequest,
recording_data: CreateRecordingRequest,
) -> Result<(Visit, Recording), Error> {
// Rozpocznij transakcję
let mut tx = pool.begin().await?;
// 1. Utwórz wizytę
let visit = sqlx::query_as!(Visit,
"INSERT INTO visits (...) VALUES (...) RETURNING *",
// ... parameters
).fetch_one(&mut *tx).await?;
// 2. Utwórz nagranie powiązane z wizytą
let recording = sqlx::query_as!(Recording,
"INSERT INTO recordings (visit_id, ...) VALUES (?, ...) RETURNING *",
visit.visit_id,
// ... parameters
).fetch_one(&mut *tx).await?;
// 3. Zatwierdź transakcję (lub rollback przy błędzie)
tx.commit().await?;
Ok((visit, recording))
}
pub async fn update_visit_with_audit(
pool: &SqlitePool,
visit_id: &str,
updates: VisitUpdate,
user_id: &str,
) -> Result<Visit, Error> {
let mut tx = pool.begin().await?;
// Aktualizacja wizyty
let result = sqlx::query!(
"UPDATE visits SET soap_subjective = ?, version = version + 1
WHERE visit_id = ? AND version = ?",
updates.soap_subjective,
visit_id,
updates.expected_version
).execute(&mut *tx).await?;
// Sprawdź optimistic locking
if result.rows_affected() == 0 {
// Rollback następuje automatycznie gdy tx wychodzi ze scope
return Err(Error::ConcurrentModification);
}
// Audit trail
sqlx::query!(
"INSERT INTO audit_trail (audit_id, user_id, action, resource_type, resource_id)
VALUES (?, ?, 'update', 'visit', ?)",
uuid::Uuid::new_v4().to_string(),
user_id,
visit_id
).execute(&mut *tx).await?;
tx.commit().await?;
// Pobierz zaktualizowany rekord
get_visit_by_id(pool, visit_id).await
}

-- Tabela visits używa version field
UPDATE visits SET
soap_subjective = ?,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE visit_id = ? AND version = ?;
-- Jeśli affected_rows = 0, ktoś inny edytował - conflict!
pub async fn update_visit_soap(
pool: &SqlitePool,
visit_id: &str,
soap_data: SoapUpdate,
expected_version: i32,
) -> Result<Visit, Error> {
let result = sqlx::query!(
"UPDATE visits SET soap_subjective = ?, version = version + 1
WHERE visit_id = ? AND version = ?",
soap_data.subjective,
visit_id,
expected_version
).execute(pool).await?;
if result.rows_affected() == 0 {
return Err(Error::ConcurrentModification);
}
// Pobierz zaktualizowany rekord
get_visit_by_id(pool, visit_id).await
}

#[tauri::command]
pub async fn get_visits_paginated(
db: State<'_, Database>,
user_id: String,
page: i64,
limit: i64,
filters: VisitFilters,
) -> Result<PaginatedVisits, String> {
let offset = (page - 1) * limit;
let visits = sqlx::query_as!(
Visit,
r#"
SELECT * FROM visits
WHERE user_id = ?
AND ($1 IS NULL OR visit_type = $1)
AND ($2 IS NULL OR date_time >= $2)
ORDER BY date_time DESC
LIMIT ? OFFSET ?
"#,
user_id,
filters.visit_type,
filters.start_date,
limit,
offset
).fetch_all(&db.pool).await?;
let total = get_visits_count(&db, &user_id, &filters).await?;
Ok(PaginatedVisits {
visits,
pagination: PaginationInfo {
page,
limit,
total,
total_pages: (total + limit - 1) / limit,
}
})
}

src-tauri/src/database/
├── mod.rs # Database struct, pool initialization
├── models.rs # Rust structs (sqlx::FromRow)
├── schema.rs # SCHEMA_STATEMENTS (CREATE TABLE)
└── migrations.rs # Migration logic
src-tauri/src/database/models.rs
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct User {
pub user_id: String, // snake_case - tak samo w JSON
pub name: String,
pub email: String,
pub password_hash: Option<String>,
pub roles: String, // JSON array jako TEXT
pub active: bool,
pub biometric_enabled: i32, // SQLite INTEGER -> Rust i32
pub created_at: String,
pub updated_at: String,
}

Okno terminala
# Generuj metadata dla compile-time verification
cd src-tauri
cargo sqlx prepare -- --all-features
# Tworzy plik .sqlx/ z informacjami o schemacie
Okno terminala
# Sprawdź czy wszystkie queries są valid
cargo sqlx prepare --check

match result {
Ok(data) => Ok(data),
Err(sqlx::Error::RowNotFound) => {
Err("Record not found".to_string())
}
Err(sqlx::Error::Database(db_err)) => {
if db_err.is_unique_violation() {
Err("Duplicate entry".to_string())
} else if db_err.is_foreign_key_violation() {
Err("Referenced record does not exist".to_string())
} else {
Err(format!("Database error: {}", db_err))
}
}
Err(e) => Err(format!("Unexpected error: {}", e))
}
// SQLite może zwrócić SQLITE_BUSY przy concurrent writes
// busy_timeout=3000ms obsługuje większość przypadków
// Dla krytycznych operacji - retry logic:
let mut attempts = 0;
loop {
match execute_query(&pool).await {
Ok(result) => return Ok(result),
Err(e) if e.to_string().contains("database is locked") && attempts < 3 => {
attempts += 1;
tokio::time::sleep(Duration::from_millis(100 * attempts)).await;
}
Err(e) => return Err(e),
}
}