SeaORM beside rusqlite: entities, schema sync, a second connection
The first step of moving to SeaORM (#18, phase 1). Nothing a user sees changes. - src/entity.rs: the seven tables as SeaORM entities, matching the SQLite schema. Strings are Text, as the columns are; yes/no columns are bool, which is BOOLEAN on Postgres and stays INTEGER in the existing SQLite file (sync notes the difference and leaves it alone). - Db holds a SeaORM connection to the same SQLite file beside the rusqlite one; functions move to it one at a time, and rusqlite goes with the last of them. - db::sync creates what a database is missing from the entities (SeaORM's schema-sync, experimental, so sea-orm is pinned to ~2.0), plus the two indexes an entity cannot express. Checked against a copy of production: it added the lower(name) index and changed nothing else. - Test databases are now built from the entities alone, in a temporary file (two connections to one ":memory:" are two databases), so every test also checks that the entities describe what the queries need. That caught the one difference: finding a user by name relied on COLLATE NOCASE, which Postgres lacks; it now compares lower() on both sides. - rusqlite steps back to 0.39: 0.40's libsqlite3-sys is newer than sqlx accepts, and only one may link SQLite. It goes away at the end of this phase. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
191
src/db.rs
191
src/db.rs
@@ -7,8 +7,63 @@ use std::sync::Mutex;
|
||||
|
||||
/// ponytail: one global connection mutex. Writes here are tiny and rare; move to a
|
||||
/// spawn_blocking pool if a large feed count ever makes it contend.
|
||||
///
|
||||
/// Mid-way through moving to SeaORM (issue #18): `orm` is the new connection, to the same
|
||||
/// SQLite file, and functions move to it one at a time; `conn` goes when the last one has.
|
||||
pub struct Db {
|
||||
conn: Mutex<Connection>,
|
||||
orm: sea_orm::DatabaseConnection,
|
||||
/// A test's database file, removed when the test is done with it.
|
||||
#[cfg(test)]
|
||||
tmp: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for Db {
|
||||
fn drop(&mut self) {
|
||||
if let Some(p) = &self.tmp {
|
||||
for ext in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(format!("{}{ext}", p.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates whatever the database is missing from the entities in `crate::entity`: tables,
|
||||
/// columns, unique keys, foreign keys. It only ever adds. What an entity cannot say -- an
|
||||
/// index on an expression, or on two columns -- follows as plain SQL both databases accept.
|
||||
///
|
||||
/// ponytail: schema sync is experimental in SeaORM 2 and exempt from semver, hence the
|
||||
/// `~2.0` pin in Cargo.toml; if it changes shape, move to sea-orm-migration files.
|
||||
async fn sync(orm: &sea_orm::DatabaseConnection) -> Result<()> {
|
||||
use crate::entity::*;
|
||||
use sea_orm::ConnectionTrait;
|
||||
orm.get_schema_builder()
|
||||
.register(feeds::Entity)
|
||||
.register(entries::Entity)
|
||||
.register(enclosures::Entity)
|
||||
.register(users::Entity)
|
||||
.register(subscriptions::Entity)
|
||||
.register(entry_state::Entity)
|
||||
.register(sessions::Entity)
|
||||
.sync(orm)
|
||||
.await
|
||||
.context("creating the schema")?;
|
||||
for sql in [
|
||||
"CREATE INDEX IF NOT EXISTS enclosures_entry ON enclosures (feed_id, guid)",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS users_name_lower ON users (lower(name))",
|
||||
] {
|
||||
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn connect(path: &Path) -> Result<sea_orm::DatabaseConnection> {
|
||||
let mut opts = sea_orm::ConnectOptions::new(format!("sqlite://{}?mode=rwc", path.display()));
|
||||
opts.sqlx_logging(false);
|
||||
sea_orm::Database::connect(opts)
|
||||
.await
|
||||
.with_context(|| format!("opening {}", path.display()))
|
||||
}
|
||||
|
||||
const SCHEMA: &str = "
|
||||
@@ -243,7 +298,7 @@ pub struct FeedSummary {
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
pub async fn open(path: &Path) -> Result<Self> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)
|
||||
.with_context(|| format!("creating {}", dir.display()))?;
|
||||
@@ -255,18 +310,33 @@ impl Db {
|
||||
conn.pragma_update(None, "busy_timeout", 5000)?;
|
||||
conn.execute_batch(SCHEMA).context("creating schema")?;
|
||||
migrate(&conn).context("migrating schema")?;
|
||||
Ok(Self { conn: Mutex::new(conn) })
|
||||
let orm = connect(path).await?;
|
||||
sync(&orm).await?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
orm,
|
||||
#[cfg(test)]
|
||||
tmp: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// In-memory database, for tests.
|
||||
/// A fresh database for a test, in a file of its own: two connections to one ":memory:"
|
||||
/// would be two databases. Its schema comes from the entities alone (`sync`), not SCHEMA,
|
||||
/// so every test also checks that the entities describe everything the queries need.
|
||||
#[cfg(test)]
|
||||
pub fn memory() -> Result<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch(SCHEMA)?;
|
||||
// Same path as a real open, so a column added only in migrate() cannot pass the
|
||||
// tests while being missing in production (or the reverse).
|
||||
migrate(&conn)?;
|
||||
Ok(Self { conn: Mutex::new(conn) })
|
||||
pub async fn memory() -> Result<Self> {
|
||||
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"ipx-test-{}-{}.db",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
));
|
||||
let orm = connect(&path).await?;
|
||||
sync(&orm).await?;
|
||||
let conn = Connection::open(&path)?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
conn.pragma_update(None, "busy_timeout", 5000)?;
|
||||
Ok(Self { conn: Mutex::new(conn), orm, tmp: Some(path) })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1134,8 +1204,11 @@ impl Db {
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Without regard to case, on either database: lower() both sides, which the unique index
|
||||
/// on lower(name) serves. SQLite's COLLATE NOCASE on the column did this before, and
|
||||
/// Postgres has no such thing.
|
||||
pub fn user_by_name(&self, name: &str) -> Result<Option<User>> {
|
||||
self.one_user(&format!("SELECT {USER_COLS} FROM users WHERE name = ?1"), name)
|
||||
self.one_user(&format!("SELECT {USER_COLS} FROM users WHERE lower(name) = lower(?1)"), name)
|
||||
}
|
||||
|
||||
pub fn user_by_id(&self, id: i64) -> Result<Option<User>> {
|
||||
@@ -1594,9 +1667,9 @@ pub fn now() -> i64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_file_wordpress_listed_twice_is_folded_into_one() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn a_file_wordpress_listed_twice_is_folded_into_one() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||
(1,'f','a','https://x/a.mp3','/d/a-2.mp3','done'),
|
||||
@@ -1652,9 +1725,9 @@ mod tests {
|
||||
assert_eq!(row(&conn).0.as_deref(), Some("e2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_sort_column_runs_and_orders_both_ways() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn every_sort_column_runs_and_orders_both_ways() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1);
|
||||
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(1,'g');
|
||||
@@ -1697,9 +1770,9 @@ mod tests {
|
||||
assert!(!order_sql("x'; --", "asc", false).contains("x'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_shared_file_asks_about_everyone_else() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn deleting_a_shared_file_asks_about_everyone_else() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0),(3,'kit',0);
|
||||
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f'),(3,'f');
|
||||
@@ -1723,9 +1796,9 @@ mod tests {
|
||||
assert_eq!(db.others_wanting(1, 3).unwrap(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_state_belongs_to_one_person() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn read_state_belongs_to_one_person() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0);
|
||||
INSERT INTO entries (feed_id, guid, title, first_seen) VALUES
|
||||
@@ -1760,9 +1833,9 @@ mod tests {
|
||||
assert_eq!(db.unread_count(1, "f").unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_is_idempotent_and_summary_handles_unknown_feeds() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn schema_is_idempotent_and_summary_handles_unknown_feeds() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
// Re-running the schema must not fail: open() does this on every start.
|
||||
db.conn.lock().unwrap().execute_batch(SCHEMA).unwrap();
|
||||
|
||||
@@ -1826,9 +1899,9 @@ mod tests {
|
||||
assert_eq!(kept, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_account_keeps_everything_but_its_name() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn a_renamed_account_keeps_everything_but_its_name() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
let ray = db.create_user("rays", None, true).unwrap();
|
||||
db.create_user("sam", None, false).unwrap();
|
||||
db.subscribe(ray, "f").unwrap();
|
||||
@@ -1840,9 +1913,9 @@ mod tests {
|
||||
assert!(db.rename_user(ray, "sam").is_err(), "a taken name is refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_account_knows_when_it_was_made_and_last_signed_in() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn an_account_knows_when_it_was_made_and_last_signed_in() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
let id = db.create_user("ray", None, true).unwrap();
|
||||
let get = || db.user_by_id(id).unwrap().unwrap();
|
||||
assert!(get().created.is_some_and(|t| t > 0));
|
||||
@@ -1858,11 +1931,11 @@ mod tests {
|
||||
assert!(get().last_login.unwrap() >= first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_admin_starts_with_the_catalogue_and_only_once() {
|
||||
#[tokio::test]
|
||||
async fn the_first_admin_starts_with_the_catalogue_and_only_once() {
|
||||
// Cutting this along with the dead read columns left the browser suite's admin with an
|
||||
// empty sidebar: it is how a fresh install's first account gets config.toml's feeds.
|
||||
let db = Db::memory().unwrap();
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test("INSERT INTO users (id, name, is_admin) VALUES (1,'admin',1);")
|
||||
.unwrap();
|
||||
let subs = || -> i64 {
|
||||
@@ -1877,12 +1950,12 @@ mod tests {
|
||||
assert_eq!(subs(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_filter_works_with_and_without_a_search_term() {
|
||||
#[tokio::test]
|
||||
async fn every_filter_works_with_and_without_a_search_term() {
|
||||
// Regression: the search clause used to be omitted when no term was given, while
|
||||
// ?2 was still bound -- rusqlite rejects a parameter the statement never mentions,
|
||||
// so plain filtering failed with "Wrong number of parameters passed to query".
|
||||
let db = Db::memory().unwrap();
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO entries (feed_id, guid, title, description, first_seen, duration) VALUES
|
||||
('f','a','Alpha dive','notes one', 100,NULL),
|
||||
@@ -1952,10 +2025,10 @@ mod tests {
|
||||
assert_eq!(listening(), ["h", "e"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_takes_the_latest_episodes_first() {
|
||||
#[tokio::test]
|
||||
async fn pending_takes_the_latest_episodes_first() {
|
||||
// A cap of 3 must mean the three newest, not the three recorded first.
|
||||
let db = Db::memory().unwrap();
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO entries (feed_id, guid, published, first_seen) VALUES
|
||||
('f','old',100,100), ('f','mid',200,200), ('f','new',300,300);
|
||||
@@ -1969,9 +2042,9 @@ mod tests {
|
||||
assert_eq!(got, vec!["u-new", "u-mid"], "newest first, oldest left for later");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restart_requeues_interrupted_downloads() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn a_restart_requeues_interrupted_downloads() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO enclosures (id, feed_id, guid, url, state, path) VALUES
|
||||
(1,'f','a','u1','downloading',NULL),
|
||||
@@ -1990,9 +2063,9 @@ mod tests {
|
||||
assert_eq!(state(4), "done");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_show_takes_over_what_its_creator_held() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn a_show_takes_over_what_its_creator_held() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1);
|
||||
INSERT INTO enclosures (id, feed_id, guid, url, state, path, last_error) VALUES
|
||||
@@ -2026,9 +2099,9 @@ mod tests {
|
||||
assert!(db.skipped_by_filter("other").unwrap().is_empty(), "torrents disabled is not a filter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feed_in_a_group_follows_your_settings_on_the_group() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn a_feed_in_a_group_follows_your_settings_on_the_group() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0);
|
||||
INSERT INTO subscriptions (user_id, feed_id, allow_explicit) VALUES
|
||||
@@ -2045,9 +2118,9 @@ mod tests {
|
||||
assert_eq!(explicit(None), [None, Some(false)], "outside a group nothing is inherited");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_since_marks_the_start_of_a_run_of_failures_and_clears_on_success() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn error_since_marks_the_start_of_a_run_of_failures_and_clears_on_success() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.set_feed_error("f", "http://x", "HTTP 404").unwrap();
|
||||
// Backdate it, as if this feed had already been failing a while, so a second
|
||||
// failure landing "now" is distinguishable from the first.
|
||||
@@ -2065,9 +2138,9 @@ mod tests {
|
||||
assert_eq!(after.error_since, None, "a clean check ends the run of failures");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pin_survives_saving_the_feeds_settings_and_needs_a_subscription() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn a_pin_survives_saving_the_feeds_settings_and_needs_a_subscription() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
let me = db.create_user("pat", None, false).unwrap();
|
||||
assert!(!db.set_pinned(me, "f", true).unwrap(), "not subscribed: nothing to pin");
|
||||
db.subscribe(me, "f").unwrap();
|
||||
@@ -2080,9 +2153,9 @@ mod tests {
|
||||
assert!(db.pinned_feeds(me).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosure_url_is_the_dedupe_key() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn enclosure_url_is_the_dedupe_key() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
let conn = db.conn.lock().unwrap();
|
||||
let insert = "INSERT INTO enclosures (feed_id, guid, url, state) VALUES ('f', 'g', 'http://x/a.mp3', 'pending')";
|
||||
conn.execute(insert, []).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user