Replace FS with Sqlite #20

Merged
pleshevskiy merged 8 commits from sqlite into main 2022-08-20 15:42:20 +03:00
11 changed files with 546 additions and 12 deletions

114
Cargo.lock generated
View file

@ -2,6 +2,17 @@
# It is not intended for manual editing.
version = 3
[[package]]
name = "ahash"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47"
dependencies = [
"getrandom",
"once_cell",
"version_check",
]
[[package]]
name = "autocfg"
version = "1.1.0"
@ -14,6 +25,12 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "cc"
version = "1.0.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11"
[[package]]
name = "cfg-if"
version = "1.0.0"
@ -76,6 +93,18 @@ dependencies = [
"winapi",
]
[[package]]
name = "fallible-iterator"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "getrandom"
version = "0.2.7"
@ -92,6 +121,18 @@ name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash",
]
[[package]]
name = "hashlink"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d452c155cb93fecdfb02a73dd57b5d8e442c2063bd7aac72f1bc5e4263a43086"
dependencies = [
"hashbrown",
]
[[package]]
name = "heck"
@ -121,6 +162,17 @@ version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "505e71a4706fa491e9b1b55f51b95d4037d0821ee40131190475f692b35b009b"
[[package]]
name = "libsqlite3-sys"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f0455f2c1bc9a7caa792907026e469c1d91761fb0ea37cbb16427c77280cf35"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "log"
version = "0.4.17"
@ -130,6 +182,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "num_threads"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44"
dependencies = [
"libc",
]
[[package]]
name = "once_cell"
version = "1.13.0"
@ -142,6 +203,12 @@ version = "6.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "648001efe5d5c0102d8cea768e348da85d90af8ba91f0bea908f157951493cd4"
[[package]]
name = "pkg-config"
version = "0.3.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae"
[[package]]
name = "proc-macro-error"
version = "1.0.4"
@ -204,6 +271,21 @@ dependencies = [
"thiserror",
]
[[package]]
name = "rusqlite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01e213bc3ecb39ac32e81e51ebe31fd888a940515173e3a18a35f8c6e896422a"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
"time",
]
[[package]]
name = "ryu"
version = "1.0.11"
@ -241,6 +323,12 @@ dependencies = [
"serde",
]
[[package]]
name = "smallvec"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1"
[[package]]
name = "syn"
version = "1.0.99"
@ -258,8 +346,10 @@ version = "0.1.0"
dependencies = [
"clap",
"log",
"rusqlite",
"serde",
"serde_json",
"time",
"xdg",
]
@ -289,12 +379,36 @@ dependencies = [
"syn",
]
[[package]]
name = "time"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db76ff9fa4b1458b3c7f077f3ff9887394058460d21e634355b273aaf11eea45"
dependencies = [
"itoa",
"libc",
"num_threads",
"time-macros",
]
[[package]]
name = "time-macros"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792"
[[package]]
name = "unicode-ident"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4f5b37a154999a8f3f98cc23a628d850e154479cd94decf3414696e12e31aaf"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.4"

View file

@ -5,6 +5,7 @@ repository = "https://git.pleshevski.ru/pleshevskiy/tas"
version = "0.1.0"
edition = "2021"
license = "GPL-3.0+"
default-run = "tas"
[badges]
maintenance = { status = "experimental" }
@ -12,6 +13,10 @@ maintenance = { status = "experimental" }
[dependencies]
clap = { version = "3.2.16", default-features = false, features = ["derive", "std"] }
log = "0.4.17"
rusqlite = { version = "0.28.0", features = ["bundled", "time"] }
serde = { version = "1.0.142", features = ["derive"] }
serde_json = "1.0.83"
time = "0.3"
xdg = "2.4.1"

View file

@ -0,0 +1,26 @@
CREATE TABLE _tas_info (
version INTEGER PRIMARY KEY
);
CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
project TEXT ,
link TEXT ,
dir_path TEXT ,
current BOOLEAN NOT NULL DEFAULT false,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at DATETIME
);
CREATE VIEW active_tasks
AS
SELECT
t.*,
row_number() OVER (ORDER BY t.created_at) AS idx
FROM tasks AS t
WHERE t.finished_at IS NULL
ORDER BY t.created_at
;

26
database/schema.sql Normal file
View file

@ -0,0 +1,26 @@
CREATE TABLE _tas_info (
version INTEGER PRIMARY KEY
);
CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
project TEXT ,
link TEXT ,
dir_path TEXT ,
current BOOLEAN NOT NULL DEFAULT false,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at DATETIME
);
CREATE VIEW active_tasks
AS
SELECT
t.*,
row_number() OVER (ORDER BY t.created_at) AS idx
FROM tasks AS t
WHERE t.finished_at IS NULL
ORDER BY t.created_at
;

26
src/bin/fs_to_sqlite.rs Normal file
View file

@ -0,0 +1,26 @@
use tas::repo::{self, Repository};
use xdg::BaseDirectories;
fn main() {
let xdg_dirs = BaseDirectories::with_prefix(env!("CARGO_PKG_NAME")).unwrap();
let fs_repo = repo::fs::FsRepo::new(xdg_dirs.clone());
let tasks = fs_repo.get_tasks().unwrap();
let sqlite_repo = repo::sqlite::SqliteRepo::new(xdg_dirs).unwrap();
for task in tasks {
log::info!("task: {}", task.name);
log::info!(" inserting...");
sqlite_repo
.insert_task(repo::InsertTaskData {
name: task.name,
project: task.project,
link: task.link,
dir_path: task.dir_path,
index: None,
})
.unwrap();
log::info!(" inserted");
}
}

View file

@ -15,14 +15,14 @@
//!
use std::path::PathBuf;
pub type TaskId = usize;
pub type TaskIdx = usize;
pub struct Task {
pub name: String,
pub project: Option<String>,
pub link: Option<String>,
pub dir_path: Option<PathBuf>,
// created_at
pub created_at: time::OffsetDateTime,
}
pub struct CurrentTaskInfo {

2
src/lib.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod domain;
pub mod repo;

View file

@ -27,7 +27,7 @@
//---------------------------------------------------------------------
use clap::Parser;
use repo::fs::FsRepo;
use repo::sqlite::SqliteRepo;
use xdg::BaseDirectories;
mod cli;
@ -38,7 +38,14 @@ fn main() {
let args = cli::Args::parse();
let xdg_dirs = BaseDirectories::with_prefix(env!("CARGO_PKG_NAME")).unwrap();
let repo = FsRepo::new(xdg_dirs);
let repo = match SqliteRepo::new(xdg_dirs) {
Ok(repo) => repo,
Err(err) => return eprintln!("Cannot connect to repository: {err}"),
};
if let Err(err) = repo.upgrade() {
return eprintln!("Cannot upgrade database: {err}");
}
match args.command {
cli::SubCommand::Add(args) => {

View file

@ -14,6 +14,7 @@
//! along with tas. If not, see <https://www.gnu.org/licenses/>.
//!
pub mod fs;
pub mod sqlite;
use std::path::PathBuf;
@ -21,17 +22,27 @@ use crate::domain;
#[derive(Debug)]
pub enum Error {
Connect,
PrepareQuery,
QueryData,
NotFound,
InvalidData,
InsertData,
UpdateData,
RemoveData,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Connect => f.write_str("Cannot connect to the repository"),
Error::PrepareQuery => f.write_str("Cannot prepare query"),
Error::QueryData => f.write_str("Cannot query data"),
Error::NotFound => f.write_str("Cannot find data"),
Error::InvalidData => f.write_str("Invalid data format"),
Error::InsertData => f.write_str("Cannot insert data"),
Error::UpdateData => f.write_str("Cannot update data"),
Error::RemoveData => f.write_str("Cannot remove data"),
}
}
}
@ -56,21 +67,21 @@ pub struct UpdateTaskData {
pub trait Repository {
fn get_current_task_opt(&self) -> Result<Option<domain::CurrentTaskInfo>, Error>;
fn get_task_opt(&self, id: domain::TaskId) -> Result<Option<domain::Task>, Error>;
fn get_task_opt(&self, id: domain::TaskIdx) -> Result<Option<domain::Task>, Error>;
fn get_tasks(&self) -> Result<Vec<domain::Task>, Error>;
fn remove_task(&self, id: domain::TaskId) -> Result<domain::Task, Error>;
fn remove_task(&self, id: domain::TaskIdx) -> Result<domain::Task, Error>;
fn update_task(
&self,
id: domain::TaskId,
id: domain::TaskIdx,
update_data: UpdateTaskData,
) -> Result<domain::Task, Error>;
fn insert_task(&self, insert_data: InsertTaskData) -> Result<domain::Task, Error>;
fn start_task(&self, id: domain::TaskId) -> Result<domain::Task, Error>;
fn start_task(&self, id: domain::TaskIdx) -> Result<domain::Task, Error>;
fn stop_task(&self) -> Result<domain::Task, Error>;

View file

@ -39,6 +39,7 @@ impl From<Task> for domain::Task {
project: repo.group,
link: repo.link,
dir_path: repo.path.map(PathBuf::from),
created_at: time::OffsetDateTime::now_utc(),
}
}
}
@ -79,7 +80,7 @@ impl Repository for FsRepo {
.map(|cur_task| cur_task.map(From::from))
}
fn get_task_opt(&self, id: domain::TaskId) -> Result<Option<domain::Task>, Error> {
fn get_task_opt(&self, id: domain::TaskIdx) -> Result<Option<domain::Task>, Error> {
let tasks = self.get_tasks_impl()?;
if id == 0 || id > tasks.len() {
return Err(Error::NotFound);
@ -93,7 +94,7 @@ impl Repository for FsRepo {
.map(|tasks| tasks.into_iter().map(Task::into).collect())
}
fn remove_task(&self, id: domain::TaskId) -> Result<domain::Task, Error> {
fn remove_task(&self, id: domain::TaskIdx) -> Result<domain::Task, Error> {
let mut tasks = self.get_tasks_impl()?;
if id == 0 || id > tasks.len() {
return Err(Error::NotFound);
@ -107,7 +108,7 @@ impl Repository for FsRepo {
fn update_task(
&self,
id: domain::TaskId,
id: domain::TaskIdx,
update_data: UpdateTaskData,
) -> Result<domain::Task, Error> {
let mut tasks = self.get_tasks_impl()?;
@ -161,7 +162,7 @@ impl Repository for FsRepo {
Ok(new_task.into())
}
fn start_task(&self, id: domain::TaskId) -> Result<domain::Task, Error> {
fn start_task(&self, id: domain::TaskIdx) -> Result<domain::Task, Error> {
let tasks = self.get_tasks_impl()?;
if id == 0 || id > tasks.len() {
return Err(Error::NotFound);

316
src/repo/sqlite.rs Normal file
View file

@ -0,0 +1,316 @@
use std::path::PathBuf;
use rusqlite::{Connection, OptionalExtension};
use xdg::BaseDirectories;
use crate::domain;
use crate::repo::{Error, Repository};
struct Task {
id: i64,
name: String,
project: Option<String>,
link: Option<String>,
dir_path: Option<String>,
/*
current: bool,
finished_at: Option<time::OffsetDateTime>,
*/
created_at: time::OffsetDateTime,
idx: i64,
}
impl From<Task> for domain::Task {
fn from(repo: Task) -> Self {
Self {
name: repo.name,
project: repo.project,
link: repo.link,
dir_path: repo.dir_path.map(PathBuf::from),
created_at: repo.created_at,
}
}
}
impl<'r> TryFrom<&'r rusqlite::Row<'_>> for Task {
type Error = rusqlite::Error;
fn try_from(row: &'r rusqlite::Row<'_>) -> Result<Self, Self::Error> {
Ok(Self {
id: row.get("id")?,
name: row.get("name")?,
project: row.get("project")?,
link: row.get("link")?,
dir_path: row.get("dir_path")?,
/*
current: row.get("current")?,
finished_at: row.get("finished_at")?,
*/
created_at: row.get("created_at")?,
idx: row.get("idx")?,
})
}
}
const SCHEMA_FILE: &str = "schema.sql";
pub struct SqliteRepo {
conn: Connection,
}
impl SqliteRepo {
pub fn new(xdg_dirs: BaseDirectories) -> Result<Self, Error> {
let file_path = xdg_dirs.get_data_file(SCHEMA_FILE);
let conn = Connection::open(file_path).map_err(|_| Error::Connect)?;
Ok(Self { conn })
}
}
impl Repository for SqliteRepo {
fn get_current_task_opt(&self) -> Result<Option<domain::CurrentTaskInfo>, Error> {
let db_task = self.get_current_task_opt_impl()?;
Ok(db_task.map(|db_task| domain::CurrentTaskInfo {
task_idx: db_task.idx as usize,
task: db_task.into(),
}))
}
fn get_task_opt(&self, id: domain::TaskIdx) -> Result<Option<domain::Task>, Error> {
self.get_task_opt_impl(id).map(|t| t.map(From::from))
}
fn get_tasks(&self) -> Result<Vec<domain::Task>, Error> {
let mut stmt = self
.conn
.prepare("SELECT * FROM active_tasks")
.map_err(|_| Error::PrepareQuery)?;
let rows = stmt
.query_map([], |row| Task::try_from(row))
.map_err(|_| Error::QueryData)?;
rows.into_iter()
.map(|db_task| db_task.map(From::from).map_err(|_| Error::QueryData))
.collect::<Result<Vec<_>, Error>>()
.map_err(|_| Error::InvalidData)
}
fn remove_task(&self, idx: domain::TaskIdx) -> Result<domain::Task, Error> {
let db_task = self.get_task_opt_impl(idx)?.ok_or(Error::NotFound)?;
let mut stmt = self
.conn
.prepare("DELETE FROM tasks WHERE id = ?")
.map_err(|_| Error::PrepareQuery)?;
stmt.execute([&(db_task.id)])
.map_err(|_| Error::RemoveData)?;
Ok(db_task.into())
}
fn insert_task(&self, insert_data: super::InsertTaskData) -> Result<domain::Task, Error> {
let mut stmt = self
.conn
.prepare(
"INSERT INTO tasks (name, project, link, dir_path)
VALUES (?1, ?2, ?3, ?4)",
)
.map_err(|_| Error::PrepareQuery)?;
let id = stmt
.insert((
&insert_data.name,
&insert_data.project,
&insert_data.link,
&insert_data
.dir_path
.and_then(|p| p.into_os_string().into_string().ok()),
))
.map_err(|_| Error::InsertData)?;
self.get_task_by_id_impl(id).map(From::from)
}
fn update_task(
&self,
idx: domain::TaskIdx,
update_data: super::UpdateTaskData,
) -> Result<domain::Task, Error> {
let task = self.get_task_opt_impl(idx)?.ok_or(Error::NotFound)?;
let mut stmt = self
.conn
.prepare(
"UPDATE tasks
SET name = ?1,
project = ?2,
link = ?3,
dir_path = ?4
WHERE id = ?5
",
)
.map_err(|_| Error::PrepareQuery)?;
stmt.execute((
&update_data.name.unwrap_or(task.name),
&update_data.project.unwrap_or(task.project),
&update_data.link.unwrap_or(task.link),
&update_data
.dir_path
.unwrap_or(task.dir_path.map(PathBuf::from))
.and_then(|p| p.into_os_string().into_string().ok()),
&task.id,
))
.map_err(|_| Error::UpdateData)?;
self.get_task_opt(idx)?.ok_or(Error::NotFound)
}
fn start_task(&self, idx: domain::TaskIdx) -> Result<domain::Task, Error> {
let db_task = self.get_task_opt_impl(idx)?.ok_or(Error::NotFound)?;
self.conn
.execute(
"UPDATE tasks SET current = true WHERE id = ?1",
[&db_task.id],
)
.map_err(|_| Error::UpdateData)?;
Ok(db_task.into())
}
fn stop_task(&self) -> Result<domain::Task, Error> {
let db_task = self.get_current_task_opt_impl()?.ok_or(Error::NotFound)?;
self.conn
.execute(
"UPDATE tasks SET current = false WHERE id = ?1",
[&db_task.id],
)
.map_err(|_| Error::UpdateData)?;
Ok(db_task.into())
}
fn finish_task(&self) -> Result<domain::Task, Error> {
let db_task = self.get_current_task_opt_impl()?.ok_or(Error::NotFound)?;
self.conn
.execute(
"UPDATE tasks
SET current = false,
finished_at = CURRENT_TIMESTAMP
WHERE id = ?1",
[&db_task.id],
)
.map_err(|_| Error::UpdateData)?;
Ok(db_task.into())
}
}
impl SqliteRepo {
fn get_task_by_id_impl(&self, id: i64) -> Result<Task, Error> {
let mut stmt = self
.conn
.prepare("SELECT * FROM active_tasks WHERE id = ?")
.map_err(|_| Error::PrepareQuery)?;
stmt.query_row([id], |row| Task::try_from(row))
.map_err(|_| Error::QueryData)
}
fn get_task_opt_impl(&self, idx: domain::TaskIdx) -> Result<Option<Task>, Error> {
let mut stmt = self
.conn
.prepare("SELECT * FROM active_tasks WHERE idx = ?")
.map_err(|_| Error::PrepareQuery)?;
stmt.query_row([idx as i64], |row| Task::try_from(row))
.optional()
.map_err(|_| Error::QueryData)
}
fn get_current_task_opt_impl(&self) -> Result<Option<Task>, Error> {
let mut stmt = self
.conn
.prepare("SELECT * FROM active_tasks WHERE current IS true")
.map_err(|_| Error::PrepareQuery)?;
let row = stmt
.query_row([], |row| Task::try_from(row))
.optional()
.map_err(|_| Error::QueryData)?;
Ok(row)
}
}
macro_rules! run_migration {
($this:ident, $ver:ident = $version:expr) => {
$this
.conn
.execute_batch(&format!(
"BEGIN; {} COMMIT;",
include_str!(concat!("../../database/migrations/", $version, ".sql"))
))
.map_err(|_| MigrationError::Upgrade)?;
$ver.replace($version);
};
}
// TODO: move migration to a separate repository.
#[derive(Debug)]
pub enum MigrationError {
Upgrade,
DeleteInfo,
InsertInfo,
}
impl std::fmt::Display for MigrationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MigrationError::Upgrade => f.write_str("Cannot upgrade the migration to a new version"),
MigrationError::DeleteInfo => {
f.write_str("Cannot delete the tas info from the database")
}
MigrationError::InsertInfo => {
f.write_str("Cannot insert a new tas information to the database")
}
}
}
}
impl std::error::Error for MigrationError {}
impl SqliteRepo {
pub fn upgrade(&self) -> Result<(), MigrationError> {
let mut version = self.version();
if version.is_none() {
run_migration!(self, version = 202208162308);
}
self.conn
.execute("DELETE FROM _tas_info", [])
.map_err(|_| MigrationError::DeleteInfo)?;
self.conn
.execute(
"INSERT INTO _tas_info (version) VALUES (?)",
[version.unwrap()],
)
.map_err(|_| MigrationError::InsertInfo)?;
Ok(())
}
fn version(&self) -> Option<i64> {
self.conn
.query_row("SELECT version FROM _tas_info", [], |r| r.get("version"))
.unwrap_or_default()
}
}