use crate::error::{Error, Result};
use crate::handle::SyncHandle;
use crate::pool::{ConnectionPool, Pool};
use rusqlite::Connection;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
pub(crate) type ConnCallback = Arc<dyn Fn(&Connection) -> Result<()> + Send + Sync>;
type QueryLogger = Box<dyn Fn(&str, Duration) + Send + Sync>;
#[cfg(feature = "live")]
thread_local! {
static HOOK_CAPTURES: std::cell::RefCell<Vec<HookCaptureFrame>> =
const { std::cell::RefCell::new(Vec::new()) };
}
#[cfg(feature = "live")]
#[derive(Default)]
pub(crate) struct HookCaptureFrame {
pub(crate) tables: std::collections::HashSet<String>,
pub(crate) changes: Vec<crate::live::TableChange>,
}
#[cfg(feature = "live")]
pub(crate) fn record_hook_change(change: crate::live::TableChange) {
HOOK_CAPTURES.with(|captures| {
if let Some(current) = captures.borrow_mut().last_mut() {
current.tables.insert(change.table.clone());
current.changes.push(change);
}
});
}
#[cfg(feature = "live")]
pub(crate) struct HookCapture {
depth: usize,
}
#[cfg(feature = "live")]
impl Drop for HookCapture {
fn drop(&mut self) {
HOOK_CAPTURES.with(|captures| captures.borrow_mut().truncate(self.depth));
}
}
#[cfg(feature = "live")]
pub(crate) fn install_preupdate_hook(
conn: &Connection,
columns: Arc<std::collections::HashMap<String, Vec<String>>>,
) -> Result<()> {
use rusqlite::hooks::PreUpdateCase;
use rusqlite::types::Value;
conn.preupdate_hook(Some(
move |_action, _db: &str, table: &str, change: &PreUpdateCase| {
let Some(column_names) = columns.get(&table.to_ascii_lowercase()) else {
return;
};
let read_old = |accessor: &rusqlite::hooks::PreUpdateOldValueAccessor| {
let mut row = std::collections::HashMap::new();
for (index, name) in column_names.iter().enumerate() {
let Ok(value) = accessor.get_old_column_value(index as i32) else {
return None;
};
row.insert(name.clone(), Value::from(value));
}
Some(row)
};
let read_new = |accessor: &rusqlite::hooks::PreUpdateNewValueAccessor| {
let mut row = std::collections::HashMap::new();
for (index, name) in column_names.iter().enumerate() {
let Ok(value) = accessor.get_new_column_value(index as i32) else {
return None;
};
row.insert(name.clone(), Value::from(value));
}
Some(row)
};
let (old, new) = match change {
PreUpdateCase::Insert(new) => (None, read_new(new)),
PreUpdateCase::Delete(old) => (read_old(old), None),
PreUpdateCase::Update {
old_value_accessor,
new_value_accessor,
} => (read_old(old_value_accessor), read_new(new_value_accessor)),
PreUpdateCase::Unknown => return,
};
if old.is_some() || new.is_some() {
record_hook_change(crate::live::TableChange {
table: table.to_string(),
old,
new,
});
}
},
))?;
Ok(())
}
#[derive(Clone)]
struct ConnectionSettings {
path: Option<PathBuf>,
mem_name: Option<String>,
busy_timeout: Duration,
on_open: Option<ConnCallback>,
#[cfg(feature = "cipher")]
encryption_key: Option<String>,
}
impl ConnectionSettings {
fn initialize(&self, conn: &Connection) -> Result<()> {
if let Some(cb) = &self.on_open {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cb(conn)))
.map_err(|_| Error::Internal("on_open 콜백 panic".into()))
.and_then(|result| result);
if !conn.is_autocommit() {
conn.execute_batch("ROLLBACK")?;
}
conn.pragma_update(None, "query_only", "OFF")?;
if self.mem_name.is_some() {
conn.pragma_update(None, "read_uncommitted", "ON")?;
}
result?;
}
Ok(())
}
fn open(&self, initialize: bool) -> Result<Connection> {
let conn = match (&self.mem_name, &self.path) {
(Some(uri), _) => {
use rusqlite::OpenFlags;
Connection::open_with_flags(
uri,
OpenFlags::SQLITE_OPEN_READ_WRITE
| OpenFlags::SQLITE_OPEN_CREATE
| OpenFlags::SQLITE_OPEN_URI
| OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?
}
(None, Some(path)) => Connection::open(path)?,
(None, None) => return Err(Error::Config("DB 경로가 설정되지 않았습니다".into())),
};
#[cfg(feature = "cipher")]
if let Some(key) = &self.encryption_key {
conn.pragma_update(None, "key", key)?;
}
conn.busy_timeout(self.busy_timeout)?;
if self.mem_name.is_none() {
conn.pragma_update(None, "journal_mode", "WAL")?;
}
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "query_only", "OFF")?;
if self.mem_name.is_some() {
conn.pragma_update(None, "read_uncommitted", "ON")?;
}
if initialize {
self.initialize(&conn)?;
}
Ok(conn)
}
}
pub(crate) fn escape_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
#[derive(Debug, Clone, Copy)]
pub struct ColumnMeta {
pub name: &'static str,
pub sql_type: &'static str,
pub not_null: bool,
pub pk: bool,
pub renamed_from: Option<&'static str>,
}
pub struct TableMeta {
pub name: &'static str,
pub columns: &'static [ColumnMeta],
pub ddl: &'static [&'static str],
}
pub struct SchemaDef {
pub version: u32,
pub ddl: Vec<&'static str>,
pub tables: Vec<TableMeta>,
}
impl SchemaDef {
fn validate_unique_tables(&self) -> Result<()> {
for (index, table) in self.tables.iter().enumerate() {
if self.tables[..index]
.iter()
.any(|previous| previous.name.eq_ignore_ascii_case(table.name))
{
return Err(Error::Config(format!(
"database entities에 SQLite 테이블 이름 중복: {}",
table.name
)));
}
}
Ok(())
}
pub fn to_snapshot(&self) -> roomrs_migrate::SchemaSnapshot {
roomrs_migrate::SchemaSnapshot {
version: self.version,
tables: self
.tables
.iter()
.map(|t| roomrs_migrate::TableSnapshot {
name: t.name.to_string(),
columns: t
.columns
.iter()
.map(|c| roomrs_migrate::ColumnSnapshot {
name: c.name.to_string(),
sql_type: c.sql_type.to_string(),
not_null: c.not_null,
pk: c.pk,
renamed_from: c.renamed_from.map(str::to_string),
})
.collect(),
ddl: t.ddl.iter().map(|d| d.to_string()).collect(),
})
.collect(),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct EmbeddedSchema {
pub version: u32,
pub compressed: &'static [u8],
}
impl EmbeddedSchema {
pub fn snapshot(&self) -> Result<roomrs_migrate::SchemaSnapshot> {
let raw = roomrs_migrate::decompress_snapshot(self.compressed).map_err(|e| {
Error::Migration(format!(
"내장 스냅샷(v{}) 압축 해제 실패: {e}",
self.version
))
})?;
roomrs_migrate::SchemaSnapshot::from_slice(&raw)
.map_err(|e| Error::Migration(format!("내장 스냅샷(v{}) 파스 실패: {e}", self.version)))
}
}
pub trait DatabaseSpec: Sized {
const VERSION: u32;
const DB_NAME: &'static str;
const SNAPSHOT_HASH: Option<u64> = None;
const EMBEDDED_SCHEMAS: &'static [EmbeddedSchema] = &[];
const SNAPSHOT_FILE_SEEN: bool = true;
fn schema() -> SchemaDef;
fn from_database(db: Database) -> Self;
}
pub fn write_schema_snapshot<T: DatabaseSpec>(manifest_dir: &str) -> Result<std::path::PathBuf> {
let schema = T::schema();
schema.validate_unique_tables()?;
let dir = roomrs_migrate::resolve_schema_dir(manifest_dir);
let path = roomrs_migrate::snapshot_path(&dir, T::DB_NAME, T::VERSION);
schema
.to_snapshot()
.write_to(&path)
.map_err(|e| Error::Config(format!("스냅샷 저장 실패: {e}")))?;
Ok(path)
}
pub fn check_schema_snapshot<T: DatabaseSpec>(manifest_dir: &str) -> Result<()> {
let schema = T::schema();
schema.validate_unique_tables()?;
let dir = roomrs_migrate::resolve_schema_dir(manifest_dir);
let path = roomrs_migrate::snapshot_path(&dir, T::DB_NAME, T::VERSION);
let file = roomrs_migrate::SchemaSnapshot::read_from(&path)
.map_err(|e| Error::SnapshotStale(format!("스냅샷 파일을 읽을 수 없습니다: {e}")))?;
let code = schema.to_snapshot();
if file.hash() != code.hash() {
return Err(Error::SnapshotStale(format!(
"스냅샷과 엔티티 정의가 다릅니다 — `write_schema_snapshot`으로 재생성 필요 (파일: {})",
path.display()
)));
}
Ok(())
}
pub fn export_schema_for_test<T: DatabaseSpec>(manifest_dir: &str) -> Result<()> {
if std::env::var("ROOMRS_SCHEMA_EXPORT").as_deref() == Ok("0") {
return Ok(());
}
let schema = T::schema();
schema.validate_unique_tables()?;
let code = schema.to_snapshot();
let dir = roomrs_migrate::resolve_schema_dir(manifest_dir);
let path = roomrs_migrate::snapshot_path(&dir, T::DB_NAME, T::VERSION);
let write = |snap: &roomrs_migrate::SchemaSnapshot| {
snap.write_to(&path)
.map_err(|e| Error::Config(format!("스냅샷 저장 실패 ({}): {e}", path.display())))
};
if !path.exists() {
write(&code)?;
return Err(Error::SnapshotStale(format!(
"스냅샷을 생성했습니다 — 커밋 후 `cargo clean -p <크레이트>` 또는 소스 touch 후 재빌드하세요: {}",
path.display()
)));
}
match roomrs_migrate::SchemaSnapshot::read_from(&path) {
Err(e) => {
write(&code)?;
Err(Error::SnapshotStale(format!(
"스냅샷 파일 파손({e}) — 재생성했습니다, 커밋하세요: {}",
path.display()
)))
}
Ok(file) if file.hash() == code.hash() => {
if !T::SNAPSHOT_FILE_SEEN {
return Err(Error::SnapshotStale(format!(
"스냅샷이 아직 바이너리에 반영되지 않았습니다 — `cargo clean -p <크레이트>` 또는 소스 touch 후 재빌드하세요: {}",
path.display()
)));
}
Ok(())
}
Ok(_) => {
write(&code)?;
Err(Error::SnapshotStale(format!(
"스냅샷이 스테일하여 재생성했습니다 — 커밋하세요: {} (반복되면 다른 #[database]와 DB_NAME 충돌 가능 — 구조체명 snake_case가 크레이트 내에서 유일해야 합니다, M-11)",
path.display()
)))
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MigrationPolicy {
#[default]
Auto,
Validate,
}
pub struct DatabaseInner {
pub(crate) pool: Pool,
query_logger: Option<QueryLogger>,
#[cfg(feature = "live")]
pub(crate) tracker: Arc<crate::live::Tracker>,
#[cfg(feature = "live")]
pub(crate) hook_columns: Arc<std::collections::HashMap<String, Vec<String>>>,
#[cfg(feature = "live")]
notifier_join: Option<std::thread::JoinHandle<()>>,
}
#[cfg(not(feature = "live"))]
impl Drop for DatabaseInner {
fn drop(&mut self) {
log::info!("database closed");
}
}
#[cfg(feature = "live")]
impl Drop for DatabaseInner {
fn drop(&mut self) {
log::info!("database closing — shutting down notifier");
self.tracker.shutdown();
if let Some(h) = self.notifier_join.take() {
if h.thread().id() == std::thread::current().id() {
log::warn!(
"database dropped on notifier thread — detaching notifier instead of joining"
);
} else {
let _ = h.join();
}
}
log::info!("database closed");
}
}
#[cfg(feature = "live")]
impl DatabaseInner {
pub(crate) fn begin_hook_capture(&self) -> HookCapture {
let depth = HOOK_CAPTURES.with(|captures| {
let mut captures = captures.borrow_mut();
let depth = captures.len();
captures.push(Default::default());
depth
});
HookCapture { depth }
}
pub(crate) fn take_hook_capture(&self) -> HookCaptureFrame {
HOOK_CAPTURES.with(|captures| captures.borrow_mut().pop().unwrap_or_default())
}
pub(crate) fn emit_after_write(&self, sql: &str) {
let capture = self.take_hook_capture();
let changed_tables: std::collections::HashSet<String> = capture
.changes
.iter()
.map(|change| change.table.clone())
.collect();
if !capture.changes.is_empty() {
self.tracker.invalidate_changes(capture.changes);
}
match crate::live::extract_write_tables(sql) {
crate::live::WriteTables::ReadOnly => {
let tables: std::collections::HashSet<String> = capture
.tables
.difference(&changed_tables)
.cloned()
.collect();
if !tables.is_empty() {
self.tracker.invalidate(Some(tables));
}
}
crate::live::WriteTables::Tables(mut t) => {
t.extend(capture.tables);
t.retain(|table| !changed_tables.contains(table));
if !t.is_empty() {
self.tracker.invalidate(Some(t));
}
}
crate::live::WriteTables::Unknown => self.tracker.invalidate(None),
}
}
}
impl DatabaseInner {
pub(crate) fn log_query<R>(&self, sql: &str, f: impl FnOnce() -> Result<R>) -> Result<R> {
log::trace!("SQL: {sql}");
match &self.query_logger {
None => f(),
Some(logger) => {
let start = std::time::Instant::now();
let out = f();
logger(sql, start.elapsed());
out
}
}
}
}
pub struct Database {
inner: Arc<DatabaseInner>,
}
impl Database {
pub fn run_sync(&self) -> SyncHandle<'_> {
SyncHandle { inner: &self.inner }
}
#[doc(hidden)]
pub fn inner_arc(&self) -> Arc<DatabaseInner> {
Arc::clone(&self.inner)
}
}
impl DatabaseInner {
#[doc(hidden)]
pub fn sync_handle(self: &Arc<Self>) -> SyncHandle<'_> {
SyncHandle { inner: self }
}
}
pub struct DatabaseBuilder<T: DatabaseSpec> {
path: Option<PathBuf>,
in_memory: bool,
connections: usize,
busy_timeout: Duration,
queue_timeout: Option<Duration>,
migrate: MigrationPolicy,
migrations: Vec<crate::migration::Migration>,
auto_migrate: bool,
destructive_fallback: bool,
#[cfg(feature = "cipher")]
encryption_key: Option<String>,
on_create: Option<ConnCallback>,
on_open: Option<ConnCallback>,
query_logger: Option<QueryLogger>,
_spec: std::marker::PhantomData<T>,
}
impl<T: DatabaseSpec> Default for DatabaseBuilder<T> {
fn default() -> Self {
let cores = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(2);
Self {
path: None,
in_memory: false,
connections: cores.clamp(1, 4) + 1,
busy_timeout: Duration::from_secs(5),
queue_timeout: None,
migrate: MigrationPolicy::Auto,
migrations: Vec::new(),
auto_migrate: false,
destructive_fallback: false,
#[cfg(feature = "cipher")]
encryption_key: None,
on_create: None,
on_open: None,
query_logger: None,
_spec: std::marker::PhantomData,
}
}
}
impl<T: DatabaseSpec> DatabaseBuilder<T> {
pub fn sqlite(mut self, path: impl Into<PathBuf>) -> Self {
let path = path.into();
if path == std::path::Path::new(":memory:") {
self.path = None;
self.in_memory = true;
} else {
self.path = Some(path);
self.in_memory = false;
}
self
}
pub fn in_memory(mut self) -> Self {
self.in_memory = true;
self.path = None;
self
}
pub fn connections(mut self, n: usize) -> Self {
self.connections = n.max(1);
self
}
pub fn busy_timeout(mut self, d: Duration) -> Self {
self.busy_timeout = d;
self
}
pub fn queue_timeout(mut self, d: Duration) -> Self {
self.queue_timeout = Some(d);
self
}
#[cfg(feature = "cipher")]
pub fn encryption_key(mut self, key: impl Into<String>) -> Self {
self.encryption_key = Some(key.into());
self
}
pub fn migrate(mut self, policy: MigrationPolicy) -> Self {
self.migrate = policy;
self
}
pub fn migration(mut self, m: crate::migration::Migration) -> Self {
self.migrations.push(m);
self
}
pub fn migrations(mut self, ms: impl IntoIterator<Item = crate::migration::Migration>) -> Self {
self.migrations.extend(ms);
self
}
pub fn auto_migrate(mut self, on: bool) -> Self {
self.auto_migrate = on;
self
}
pub fn fallback_to_destructive_migration(mut self, enable: bool) -> Self {
self.destructive_fallback = enable;
self
}
pub fn on_create(
mut self,
f: impl Fn(&Connection) -> Result<()> + Send + Sync + 'static,
) -> Self {
self.on_create = Some(Arc::new(f));
self
}
pub fn on_open(
mut self,
f: impl Fn(&Connection) -> Result<()> + Send + Sync + 'static,
) -> Self {
self.on_open = Some(Arc::new(f));
self
}
pub fn query_logger(mut self, f: impl Fn(&str, Duration) + Send + Sync + 'static) -> Self {
self.query_logger = Some(Box::new(f));
self
}
pub fn build(mut self) -> Result<T> {
let schema = T::schema();
schema.validate_unique_tables()?;
if self.in_memory {
self.connections = 1;
}
if let Some(embedded) = T::SNAPSHOT_HASH {
let runtime = schema.to_snapshot().hash();
if embedded != runtime {
return Err(Error::SnapshotStale(format!(
"스냅샷 해시 불일치 (파일={embedded:#x}, 엔티티={runtime:#x}) — \
엔티티 수정 후 스냅샷 재생성이 필요합니다"
)));
}
}
let mem_name = if self.in_memory {
use std::sync::atomic::{AtomicU64, Ordering};
static MEM_SEQ: AtomicU64 = AtomicU64::new(0);
Some(format!(
"file:roomrs_mem_{}?mode=memory&cache=shared",
MEM_SEQ.fetch_add(1, Ordering::Relaxed)
))
} else {
None
};
let first_conn = self.open_conn(mem_name.as_deref(), &schema, false)?;
let mut connections = Vec::with_capacity(self.connections);
connections.push(first_conn);
for _ in 1..self.connections {
let conn = self.open_conn(mem_name.as_deref(), &schema, false)?;
connections.push(conn);
}
#[cfg(feature = "live")]
let hook_columns = Arc::new(
schema
.tables
.iter()
.map(|table| {
(
table.name.to_ascii_lowercase(),
table
.columns
.iter()
.map(|column| column.name.to_string())
.collect(),
)
})
.collect(),
);
#[cfg(feature = "live")]
let (tracker, notifier_join) = {
let notifier_conn = self.open_conn(mem_name.as_deref(), &schema, false)?;
for conn in &connections {
install_preupdate_hook(conn, Arc::clone(&hook_columns))?;
}
crate::live::Tracker::start(notifier_conn)?
};
let reconnect_settings = ConnectionSettings {
path: self.path.clone(),
mem_name: mem_name.clone(),
busy_timeout: self.busy_timeout,
on_open: self.on_open.clone(),
#[cfg(feature = "cipher")]
encryption_key: self.encryption_key.clone(),
};
let connection_settings = reconnect_settings;
#[cfg(feature = "live")]
let connection_hook_columns = Arc::clone(&hook_columns);
let connection_reopen: Arc<dyn Fn() -> Result<Connection> + Send + Sync> =
Arc::new(move || {
let conn = connection_settings.open(false)?;
connection_settings.initialize(&conn)?;
#[cfg(feature = "live")]
{
install_preupdate_hook(&conn, Arc::clone(&connection_hook_columns))?;
}
Ok(conn)
});
let inner = DatabaseInner {
pool: Pool {
connections: ConnectionPool::new_with_preservation(
connections,
self.in_memory,
self.in_memory,
self.queue_timeout,
connection_reopen,
),
},
query_logger: self.query_logger.take(),
#[cfg(feature = "live")]
tracker,
#[cfg(feature = "live")]
hook_columns: Arc::clone(&hook_columns),
#[cfg(feature = "live")]
notifier_join: Some(notifier_join),
};
let db = Database {
inner: Arc::new(inner),
};
self.run_migration(&db, &schema)?;
if let Some(cb) = &self.on_open {
{
db.inner
.pool
.connections
.for_each_idle(|conn| Self::apply_on_open(conn, cb, false, self.in_memory))?;
}
#[cfg(feature = "live")]
db.inner
.tracker
.initialize(Arc::clone(cb), self.in_memory)?;
}
db.inner.pool.connections.for_each_idle(|_conn| {
#[cfg(feature = "live")]
{
install_preupdate_hook(_conn, Arc::clone(&hook_columns))?;
}
Ok::<(), Error>(())
})?;
log::info!(
"database opened: path={}, version={}",
self.path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| ":memory:".into()),
schema.version
);
Ok(T::from_database(db))
}
fn open_conn(
&self,
mem_name: Option<&str>,
_schema: &SchemaDef,
_read_only: bool,
) -> Result<Connection> {
let conn = match (mem_name, &self.path) {
(Some(uri), _) => {
use rusqlite::OpenFlags;
Connection::open_with_flags(
uri,
OpenFlags::SQLITE_OPEN_READ_WRITE
| OpenFlags::SQLITE_OPEN_CREATE
| OpenFlags::SQLITE_OPEN_URI
| OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?
}
(None, Some(path)) => Connection::open(path)?,
(None, None) => {
return Err(Error::Config(
"DB 경로가 설정되지 않았습니다 — .sqlite(path) 또는 .in_memory() 필요".into(),
));
}
};
#[cfg(feature = "cipher")]
if let Some(key) = &self.encryption_key {
conn.pragma_update(None, "key", key)?;
}
conn.busy_timeout(self.busy_timeout)?;
if mem_name.is_none() {
let deadline =
std::time::Instant::now() + self.busy_timeout.max(Duration::from_millis(500));
loop {
match conn.pragma_update(None, "journal_mode", "WAL") {
Ok(()) => break,
Err(rusqlite::Error::SqliteFailure(fe, _))
if fe.code == rusqlite::ErrorCode::DatabaseBusy
&& std::time::Instant::now() < deadline =>
{
std::thread::sleep(Duration::from_millis(10));
}
Err(e) => return Err(e.into()),
}
}
}
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "query_only", "OFF")?;
if mem_name.is_some() {
conn.pragma_update(None, "read_uncommitted", "ON")?;
}
log::debug!("read/write pool connection opened");
Ok(conn)
}
fn apply_on_open(
conn: &Connection,
cb: &ConnCallback,
read_only: bool,
read_uncommitted: bool,
) -> Result<()> {
let callback_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cb(conn)))
.map_err(|_| Error::Internal("on_open 콜백 panic".into()))
.and_then(|result| result);
if !conn.is_autocommit() {
conn.execute_batch("ROLLBACK")?;
}
let _ = read_only;
conn.pragma_update(None, "query_only", "OFF")?;
if read_uncommitted {
conn.pragma_update(None, "read_uncommitted", "ON")?;
}
callback_result
}
fn run_migration(&self, db: &Database, schema: &SchemaDef) -> Result<()> {
let h = db.run_sync();
let current: u32 =
h.with_connection(|c| Ok(c.query_row("PRAGMA user_version", [], |r| r.get(0))?))?;
let target = schema.version;
if current == 0 {
let created = h.transaction(|tx| {
let cur: u32 = tx.query_scalar("PRAGMA user_version", [])?;
if cur != 0 {
return Ok(false);
}
for ddl in &schema.ddl {
tx.raw_conn().execute_batch(ddl)?;
}
if let Some(cb) = &self.on_create {
cb(tx.raw_conn())?;
}
tx.execute_batch(&format!("PRAGMA user_version = {target}"))?;
Ok(true)
})?;
if created {
log::info!("schema created at version {target}");
return Ok(());
}
return self.run_migration(db, schema);
}
if current == target {
return Ok(());
}
if self.migrate == MigrationPolicy::Validate {
log::error!(
"migration failed: schema version mismatch (db={current}, code={target}, policy=Validate)"
);
return Err(Error::Migration(format!(
"스키마 버전 불일치: DB={current}, 코드={target} (Validate 정책)"
)));
}
let synthesized = if self.auto_migrate {
synthesize_embedded_steps(T::EMBEDDED_SCHEMAS, &self.migrations, current)?
} else {
SynthesizedSteps::default()
};
let all_steps: Vec<&crate::migration::Migration> = self
.migrations
.iter()
.chain(synthesized.steps.iter())
.collect();
let plan_result = check_destructive_gap(&all_steps, &synthesized, current, target)
.and_then(|()| crate::migration::plan_chain(&all_steps, current, target));
match plan_result {
Ok(plan) => {
for step in plan {
log::info!(
"migration step: v{}->v{}",
step.from_version(),
step.to_version()
);
h.transaction(|tx| {
let cur: u32 = tx.query_scalar("PRAGMA user_version", [])?;
if cur >= step.to_version() {
return Ok(());
}
if cur != step.from_version() {
return Err(Error::Migration(format!(
"동시 마이그레이션 감지: 예상 v{}, 실제 v{cur} — 체인 구성 상이",
step.from_version()
)));
}
step.run_up(tx)?;
tx.execute_batch(&format!("PRAGMA user_version = {}", step.to_version()))
})?;
}
Ok(())
}
Err(e) if self.destructive_fallback => {
log::warn!("migration chain insufficient — falling back to destructive migration");
let _ = e;
self.run_destructive(&h, schema)
}
Err(e) => {
log::error!("migration failed: {e}");
Err(e)
}
}
}
fn run_destructive(&self, h: &SyncHandle<'_>, schema: &SchemaDef) -> Result<()> {
h.with_connection(|c| {
c.pragma_update(None, "foreign_keys", "OFF")?;
let result: Result<()> = (|| {
c.execute_batch("BEGIN IMMEDIATE")?;
let migration: Result<()> = (|| {
let mut statement = c.prepare(
"SELECT type, name FROM sqlite_master \
WHERE name NOT LIKE 'sqlite_%' \
AND type IN ('trigger','view','index','table')",
)?;
let objs = statement
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
.collect::<std::result::Result<Vec<(String, String)>, _>>()?;
drop(statement);
for kind in ["trigger", "view", "index", "table"] {
for (t, name) in objs.iter().filter(|(t, _)| t == kind) {
c.execute_batch(&format!(
"DROP {} {}",
t.to_uppercase(),
escape_ident(name)
))?;
}
}
for ddl in &schema.ddl {
c.execute_batch(ddl)?;
}
c.execute_batch(&format!("PRAGMA user_version = {}", schema.version))?;
Ok(())
})();
match migration {
Ok(()) => c.execute_batch("COMMIT")?,
Err(error) => {
let _ = c.execute_batch("ROLLBACK");
return Err(error);
}
}
Ok(())
})();
let restore = c.pragma_update(None, "foreign_keys", "ON");
result.and(restore.map_err(Into::into))
})
}
}
#[derive(Default)]
struct SynthesizedSteps {
steps: Vec<crate::migration::Migration>,
refused: std::collections::HashMap<u32, (u32, String)>,
}
fn synthesize_embedded_steps(
embedded: &[EmbeddedSchema],
registered: &[crate::migration::Migration],
current: u32,
) -> Result<SynthesizedSteps> {
let mut out = SynthesizedSteps::default();
if embedded.len() < 2 {
return Ok(out);
}
let mut sorted: Vec<&EmbeddedSchema> = embedded.iter().collect();
sorted.sort_by_key(|e| e.version);
for pair in sorted.windows(2) {
let (a, b) = (pair[0], pair[1]);
if a.version == b.version {
continue;
}
if a.version < current {
continue;
}
if registered.iter().any(|m| m.from_version() == a.version) {
continue;
}
let old = a.snapshot()?;
let new = b.snapshot()?;
let plan = roomrs_migrate::diff_plan(&old, &new);
if plan.destructive.is_empty() {
log::info!(
"auto-migrate synthesized step: v{}->v{}",
a.version,
b.version
);
out.steps.push(crate::migration::Migration::sql(
a.version,
b.version,
plan.safe.join(";\n"),
));
} else {
out.refused
.insert(a.version, (b.version, plan.destructive.join("; ")));
}
}
Ok(out)
}
fn check_destructive_gap(
steps: &[&crate::migration::Migration],
synthesized: &SynthesizedSteps,
current: u32,
target: u32,
) -> Result<()> {
if synthesized.refused.is_empty() {
return Ok(());
}
let mut v = current;
while v < target {
match steps.iter().find(|s| s.from_version() == v) {
Some(s) if s.to_version() > v && s.to_version() <= target => v = s.to_version(),
Some(_) => return Ok(()),
None => {
if let Some((to, items)) = synthesized.refused.get(&v) {
return Err(Error::Migration(format!(
"v{v}->v{to} 자동 마이그레이션 불가 — 파괴적 변경 포함: {items}; \
수동 스텝을 등록하거나 fallback_to_destructive_migration 사용"
)));
}
return Ok(());
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use roomrs_migrate::{ColumnSnapshot, SchemaSnapshot, TableSnapshot};
#[test]
fn in_memory_uses_one_regular_connection() {
for builder in [
DatabaseBuilder::<DestructiveFkDb>::default()
.in_memory()
.connections(3),
DatabaseBuilder::<DestructiveFkDb>::default()
.connections(3)
.in_memory(),
DatabaseBuilder::<DestructiveFkDb>::default()
.connections(3)
.sqlite(":memory:"),
] {
let db = builder.build().unwrap().0;
let mut idle = 0;
db.inner
.pool
.connections
.for_each_idle(|_| {
idle += 1;
Ok(())
})
.unwrap();
assert_eq!(idle, 1);
}
}
#[test]
fn in_memory_serializes_concurrent_transactions() {
let db = Arc::new(
DatabaseBuilder::<DestructiveFkDb>::default()
.in_memory()
.connections(4)
.build()
.unwrap()
.0,
);
let mut workers = Vec::new();
for worker in 0..4 {
let db = Arc::clone(&db);
workers.push(std::thread::spawn(move || {
for item in 0..25 {
db.run_sync().transaction(|tx| {
tx.execute(
"INSERT INTO parents(id) VALUES (?1)",
[worker * 25 + item + 1],
)?;
Ok(())
})?;
}
Result::<()>::Ok(())
}));
}
for worker in workers {
worker.join().unwrap().unwrap();
}
let count: i64 = db
.run_sync()
.query_scalar("SELECT COUNT(*) FROM parents", [])
.unwrap();
assert_eq!(count, 100);
}
struct DestructiveFkDb(Database);
impl DatabaseSpec for DestructiveFkDb {
const VERSION: u32 = 1;
const DB_NAME: &'static str = "destructive_fk_db";
fn schema() -> SchemaDef {
SchemaDef {
version: 1,
ddl: vec![
"CREATE TABLE parents(id INTEGER PRIMARY KEY)",
"CREATE TABLE children(id INTEGER PRIMARY KEY, parent_id INTEGER NOT NULL REFERENCES parents(id))",
],
tables: Vec::new(),
}
}
fn from_database(db: Database) -> Self {
Self(db)
}
}
#[test]
fn destructive_migration_uses_one_connection_and_restores_foreign_keys() {
static FILE_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let file = std::env::temp_dir().join(format!(
"roomrs-destructive-fk-{}-{}.db",
std::process::id(),
FILE_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
let db = DatabaseBuilder::<DestructiveFkDb>::default()
.sqlite(&file)
.connections(3)
.build()
.unwrap()
.0;
db.run_sync()
.execute("INSERT INTO parents(id) VALUES (1)", [])
.unwrap();
db.run_sync()
.execute("INSERT INTO children(id, parent_id) VALUES (1, 1)", [])
.unwrap();
let target = SchemaDef {
version: 2,
ddl: vec!["CREATE TABLE replacements(id INTEGER PRIMARY KEY)"],
tables: Vec::new(),
};
DatabaseBuilder::<DestructiveFkDb>::default()
.run_destructive(&db.run_sync(), &target)
.unwrap();
db.inner
.pool
.connections
.for_each_idle(|conn| {
let enabled: i64 = conn.query_row("PRAGMA foreign_keys", [], |row| row.get(0))?;
assert_eq!(enabled, 1);
Ok(())
})
.unwrap();
let invalid_target = SchemaDef {
version: 3,
ddl: vec!["CREATE TABLE invalid("],
tables: Vec::new(),
};
assert!(
DatabaseBuilder::<DestructiveFkDb>::default()
.run_destructive(&db.run_sync(), &invalid_target)
.is_err()
);
db.inner
.pool
.connections
.for_each_idle(|conn| {
let enabled: i64 = conn.query_row("PRAGMA foreign_keys", [], |row| row.get(0))?;
assert_eq!(enabled, 1);
Ok(())
})
.unwrap();
drop(db);
std::fs::remove_file(file).unwrap();
}
#[test]
fn duplicate_entity_table_names_are_rejected() {
let schema = SchemaDef {
version: 1,
ddl: Vec::new(),
tables: vec![
TableMeta {
name: "items",
columns: &[],
ddl: &[],
},
TableMeta {
name: "ITEMS",
columns: &[],
ddl: &[],
},
],
};
assert!(matches!(
schema.validate_unique_tables(),
Err(Error::Config(_))
));
}
fn snap(version: u32, cols: Vec<(&str, &str, bool)>) -> SchemaSnapshot {
SchemaSnapshot {
version,
tables: vec![TableSnapshot {
name: "t".into(),
columns: cols
.into_iter()
.map(|(name, ty, not_null)| ColumnSnapshot {
name: name.into(),
sql_type: ty.into(),
not_null,
pk: name == "id",
renamed_from: None,
})
.collect(),
ddl: vec![],
}],
}
}
fn embed(snap: &SchemaSnapshot) -> EmbeddedSchema {
let comp = roomrs_migrate::compress_snapshot(snap.to_json().unwrap().as_bytes());
EmbeddedSchema {
version: snap.version,
compressed: Box::leak(comp.into_boxed_slice()),
}
}
#[test]
fn synthesize_spans_version_holes() {
let v1 = snap(1, vec![("id", "INTEGER", true)]);
let v3 = snap(3, vec![("id", "INTEGER", true), ("a", "TEXT", false)]);
let v4 = snap(
4,
vec![
("id", "INTEGER", true),
("a", "TEXT", false),
("b", "TEXT", false),
],
);
let embedded = [embed(&v1), embed(&v3), embed(&v4)];
let s = synthesize_embedded_steps(&embedded, &[], 1).unwrap();
assert!(s.refused.is_empty());
let spans: Vec<(u32, u32)> = s
.steps
.iter()
.map(|m| (m.from_version(), m.to_version()))
.collect();
assert_eq!(spans, vec![(1, 3), (3, 4)], "인접 가용 쌍으로 합성");
}
#[test]
fn synthesize_skips_registered_from() {
let v1 = snap(1, vec![("id", "INTEGER", true)]);
let v2 = snap(2, vec![("id", "INTEGER", true), ("a", "TEXT", false)]);
let registered = [crate::migration::Migration::sql(1, 2, "SELECT 1")];
let s = synthesize_embedded_steps(&[embed(&v1), embed(&v2)], ®istered, 1).unwrap();
assert!(s.steps.is_empty(), "등록 스텝 우선 — 합성 없음");
assert!(s.refused.is_empty());
}
#[test]
fn synthesize_refuses_destructive_pair() {
let v1 = snap(1, vec![("id", "INTEGER", true), ("c", "TEXT", true)]);
let v2 = snap(2, vec![("id", "INTEGER", true), ("c", "INTEGER", true)]);
let s = synthesize_embedded_steps(&[embed(&v1), embed(&v2)], &[], 1).unwrap();
assert!(s.steps.is_empty());
assert!(s.refused.contains_key(&1), "{:?}", s.refused);
match check_destructive_gap(&[], &s, 1, 2) {
Err(Error::Migration(msg)) => {
assert!(msg.contains("v1->v2 자동 마이그레이션 불가"), "{msg}");
assert!(msg.contains("파괴적 변경 포함"), "{msg}");
assert!(msg.contains("fallback_to_destructive_migration"), "{msg}");
}
other => panic!("Migration 에러 기대, 결과: {other:?}"),
}
let manual = crate::migration::Migration::sql(1, 2, "SELECT 1");
assert!(check_destructive_gap(&[&manual], &s, 1, 2).is_ok());
}
#[test]
fn synthesize_corrupt_embedded_errors() {
let v1 = snap(1, vec![("id", "INTEGER", true)]);
let bad = EmbeddedSchema {
version: 2,
compressed: b"\xff\x00\x12corrupt",
};
match synthesize_embedded_steps(&[embed(&v1), bad], &[], 1) {
Err(Error::Migration(msg)) => assert!(msg.contains("내장 스냅샷"), "{msg}"),
Err(other) => panic!("Migration 에러 기대, 결과: {other}"),
Ok(_) => panic!("파손 스냅샷이 통과되면 안 된다"),
}
}
#[test]
fn synthesize_skips_pairs_below_current() {
let bad_old = EmbeddedSchema {
version: 1,
compressed: b"\xff\x00\x12corrupt",
};
let v2 = snap(2, vec![("id", "INTEGER", true)]);
let v3 = snap(3, vec![("id", "INTEGER", true), ("a", "TEXT", false)]);
let s = synthesize_embedded_steps(&[bad_old, embed(&v2), embed(&v3)], &[], 2).unwrap();
let spans: Vec<(u32, u32)> = s
.steps
.iter()
.map(|m| (m.from_version(), m.to_version()))
.collect();
assert_eq!(spans, vec![(2, 3)], "v1 파손 스냅샷은 압축 해제하지 않음");
assert!(s.refused.is_empty());
}
}