377 lines
12 KiB
Rust
377 lines
12 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use clap::Parser;
|
|
use rusqlite::{Connection, DatabaseName, OptionalExtension, Row, params};
|
|
use serde_json::json;
|
|
|
|
#[derive(Debug, Parser)]
|
|
#[command(about = "Merge ActivityWatch aw-server-rust SQLite databases")]
|
|
struct Cli {
|
|
#[arg(long)]
|
|
base: PathBuf,
|
|
|
|
#[arg(long)]
|
|
output: PathBuf,
|
|
|
|
#[arg(long)]
|
|
overlay: Option<PathBuf>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
struct BucketKey {
|
|
name: String,
|
|
type_: String,
|
|
client: String,
|
|
hostname: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct Bucket {
|
|
rowid: i64,
|
|
id: Option<String>,
|
|
key: BucketKey,
|
|
created: i64,
|
|
data_deprecated: Option<String>,
|
|
data: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
struct EventKey {
|
|
starttime: i64,
|
|
endtime: i64,
|
|
data: String,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct MergeStats {
|
|
inserted_buckets: usize,
|
|
inserted_events: usize,
|
|
}
|
|
|
|
fn main() {
|
|
let code = match run() {
|
|
Ok(()) => 0,
|
|
Err(err) => {
|
|
eprintln!("{err:#}");
|
|
1
|
|
}
|
|
};
|
|
std::process::exit(code);
|
|
}
|
|
|
|
fn run() -> Result<()> {
|
|
let cli = Cli::parse();
|
|
if !cli.base.is_file() {
|
|
bail!("Base DB not found: {}", cli.base.display());
|
|
}
|
|
ensure_parent(&cli.output)?;
|
|
let tmp_output = tmp_output_path(&cli.output);
|
|
if tmp_output.exists() {
|
|
fs::remove_file(&tmp_output)
|
|
.with_context(|| format!("remove stale tmp output {}", tmp_output.display()))?;
|
|
}
|
|
copy_sqlite_via_backup(&cli.base, &tmp_output)?;
|
|
|
|
let mut stats = MergeStats::default();
|
|
if let Some(overlay) = &cli.overlay {
|
|
if overlay.exists() {
|
|
stats = merge_overlay(&tmp_output, overlay)?;
|
|
}
|
|
}
|
|
|
|
fs::rename(&tmp_output, &cli.output)
|
|
.with_context(|| format!("move {} to {}", tmp_output.display(), cli.output.display()))?;
|
|
println!(
|
|
"{}",
|
|
json!({
|
|
"base": cli.base.to_string_lossy(),
|
|
"overlay": cli.overlay.as_ref().map(|p| p.to_string_lossy().to_string()),
|
|
"output": cli.output.to_string_lossy(),
|
|
"inserted_buckets": stats.inserted_buckets,
|
|
"inserted_events": stats.inserted_events,
|
|
})
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn copy_sqlite_via_backup(src: &Path, dst: &Path) -> Result<()> {
|
|
ensure_parent(dst)?;
|
|
let source =
|
|
Connection::open(src).with_context(|| format!("open base DB {}", src.display()))?;
|
|
source
|
|
.backup(DatabaseName::Main, dst, None)
|
|
.with_context(|| format!("backup {} to {}", src.display(), dst.display()))
|
|
}
|
|
|
|
fn merge_overlay(dest_path: &Path, overlay_path: &Path) -> Result<MergeStats> {
|
|
let dest =
|
|
connect(dest_path).with_context(|| format!("open output DB {}", dest_path.display()))?;
|
|
let source = connect(overlay_path)
|
|
.with_context(|| format!("open overlay DB {}", overlay_path.display()))?;
|
|
|
|
let mut stats = MergeStats::default();
|
|
let source_buckets = load_buckets(&source)?;
|
|
let mut dest_bucket_map = load_dest_bucket_key_map(&dest)?;
|
|
let mut dest_id_map = load_dest_bucket_id_map(&dest)?;
|
|
|
|
for src_bucket in source_buckets {
|
|
let dest_rowid = if let Some(src_id) = &src_bucket.id {
|
|
dest_id_map.get(src_id).copied()
|
|
} else {
|
|
None
|
|
}
|
|
.or_else(|| dest_bucket_map.get(&src_bucket.key).copied())
|
|
.or_else(|| {
|
|
find_bucket_by_name(&dest, &src_bucket.key.name)
|
|
.ok()
|
|
.flatten()
|
|
});
|
|
|
|
let dest_rowid = match dest_rowid {
|
|
Some(rowid) => {
|
|
update_bucket_by_rowid(&dest, rowid, &src_bucket)?;
|
|
rowid
|
|
}
|
|
None => {
|
|
stats.inserted_buckets += 1;
|
|
insert_bucket(&dest, &src_bucket)?
|
|
}
|
|
};
|
|
|
|
dest_bucket_map.insert(src_bucket.key.clone(), dest_rowid);
|
|
if let Some(id) = &src_bucket.id {
|
|
dest_id_map.insert(id.clone(), dest_rowid);
|
|
}
|
|
|
|
let mut existing_events = load_existing_events(&dest, dest_rowid)?;
|
|
let source_events = load_source_events(&source, src_bucket.rowid)?;
|
|
for event in source_events {
|
|
if existing_events.contains(&event) {
|
|
continue;
|
|
}
|
|
dest.execute(
|
|
"insert into events (bucketrow, starttime, endtime, data) values (?1, ?2, ?3, ?4)",
|
|
params![dest_rowid, event.starttime, event.endtime, event.data],
|
|
)
|
|
.with_context(|| format!("insert event into bucketrow {dest_rowid}"))?;
|
|
existing_events.insert(event);
|
|
stats.inserted_events += 1;
|
|
}
|
|
}
|
|
dest.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")
|
|
.context("checkpoint output DB")?;
|
|
Ok(stats)
|
|
}
|
|
|
|
fn connect(path: &Path) -> Result<Connection> {
|
|
let connection = Connection::open(path)?;
|
|
connection.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?;
|
|
Ok(connection)
|
|
}
|
|
|
|
fn load_buckets(connection: &Connection) -> Result<Vec<Bucket>> {
|
|
let mut stmt = connection.prepare(
|
|
"select rowid as bucketrow, id, name, type, client, hostname, created, data_deprecated, data from buckets order by rowid",
|
|
)?;
|
|
let rows = stmt.query_map([], bucket_from_row)?;
|
|
rows.collect::<rusqlite::Result<Vec<_>>>()
|
|
.context("load buckets")
|
|
}
|
|
|
|
fn bucket_from_row(row: &Row<'_>) -> rusqlite::Result<Bucket> {
|
|
Ok(Bucket {
|
|
rowid: row.get("bucketrow")?,
|
|
id: row.get("id")?,
|
|
key: BucketKey {
|
|
name: row.get("name")?,
|
|
type_: row.get("type")?,
|
|
client: row.get("client")?,
|
|
hostname: row.get("hostname")?,
|
|
},
|
|
created: row.get("created")?,
|
|
data_deprecated: row.get("data_deprecated")?,
|
|
data: row.get("data")?,
|
|
})
|
|
}
|
|
|
|
fn load_dest_bucket_key_map(connection: &Connection) -> Result<HashMap<BucketKey, i64>> {
|
|
Ok(load_buckets(connection)?
|
|
.into_iter()
|
|
.map(|bucket| (bucket.key, bucket.rowid))
|
|
.collect())
|
|
}
|
|
|
|
fn load_dest_bucket_id_map(connection: &Connection) -> Result<HashMap<String, i64>> {
|
|
Ok(load_buckets(connection)?
|
|
.into_iter()
|
|
.filter_map(|bucket| bucket.id.map(|id| (id, bucket.rowid)))
|
|
.collect())
|
|
}
|
|
|
|
fn find_bucket_by_name(connection: &Connection, name: &str) -> Result<Option<i64>> {
|
|
connection
|
|
.query_row(
|
|
"select rowid as bucketrow from buckets where name = ?1 order by rowid limit 1",
|
|
[name],
|
|
|row| row.get::<_, i64>("bucketrow"),
|
|
)
|
|
.optional()
|
|
.context("find bucket by name")
|
|
}
|
|
|
|
fn insert_bucket(connection: &Connection, bucket: &Bucket) -> Result<i64> {
|
|
connection.execute(
|
|
"insert into buckets (id, name, type, client, hostname, created, data_deprecated, data) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
params![
|
|
bucket.id,
|
|
bucket.key.name,
|
|
bucket.key.type_,
|
|
bucket.key.client,
|
|
bucket.key.hostname,
|
|
bucket.created,
|
|
bucket.data_deprecated,
|
|
bucket.data
|
|
],
|
|
)?;
|
|
Ok(connection.last_insert_rowid())
|
|
}
|
|
|
|
fn update_bucket_by_rowid(connection: &Connection, rowid: i64, bucket: &Bucket) -> Result<()> {
|
|
connection.execute(
|
|
"update buckets set type = ?1, client = ?2, hostname = ?3, created = ?4, data_deprecated = ?5, data = ?6 where rowid = ?7",
|
|
params![
|
|
bucket.key.type_,
|
|
bucket.key.client,
|
|
bucket.key.hostname,
|
|
bucket.created,
|
|
bucket.data_deprecated,
|
|
bucket.data,
|
|
rowid
|
|
],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn load_existing_events(connection: &Connection, bucketrow: i64) -> Result<HashSet<EventKey>> {
|
|
let mut stmt =
|
|
connection.prepare("select starttime, endtime, data from events where bucketrow = ?1")?;
|
|
let rows = stmt.query_map([bucketrow], |row| {
|
|
Ok(EventKey {
|
|
starttime: row.get(0)?,
|
|
endtime: row.get(1)?,
|
|
data: row.get(2)?,
|
|
})
|
|
})?;
|
|
Ok(rows.collect::<rusqlite::Result<HashSet<_>>>()?)
|
|
}
|
|
|
|
fn load_source_events(connection: &Connection, bucketrow: i64) -> Result<Vec<EventKey>> {
|
|
let mut stmt = connection
|
|
.prepare("select starttime, endtime, data from events where bucketrow = ?1 order by id")?;
|
|
let rows = stmt.query_map([bucketrow], |row| {
|
|
Ok(EventKey {
|
|
starttime: row.get(0)?,
|
|
endtime: row.get(1)?,
|
|
data: row.get(2)?,
|
|
})
|
|
})?;
|
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
|
}
|
|
|
|
fn ensure_parent(path: &Path) -> Result<()> {
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.with_context(|| format!("create parent directory {}", parent.display()))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn tmp_output_path(output: &Path) -> PathBuf {
|
|
let mut os = output.as_os_str().to_os_string();
|
|
os.push(".tmp");
|
|
PathBuf::from(os)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn tmp_output_appends_tmp_like_python_suffix() {
|
|
assert_eq!(
|
|
tmp_output_path(Path::new("/tmp/sqlite.db")),
|
|
PathBuf::from("/tmp/sqlite.db.tmp")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_inserts_new_bucket_and_skips_duplicate_event() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let base = dir.path().join("base.db");
|
|
let overlay = dir.path().join("overlay.db");
|
|
let output = dir.path().join("out.db");
|
|
create_fixture_db(&base, &[("bucket-a", "id-a")], &[(1, 10, 20, r#"{"a":1}"#)]);
|
|
create_fixture_db(
|
|
&overlay,
|
|
&[("bucket-a", "id-a"), ("bucket-b", "id-b")],
|
|
&[(1, 10, 20, r#"{"a":1}"#), (2, 30, 40, r#"{"b":1}"#)],
|
|
);
|
|
|
|
copy_sqlite_via_backup(&base, &output).unwrap();
|
|
let stats = merge_overlay(&output, &overlay).unwrap();
|
|
assert_eq!(stats.inserted_buckets, 1);
|
|
assert_eq!(stats.inserted_events, 1);
|
|
|
|
let conn = Connection::open(&output).unwrap();
|
|
let bucket_count: i64 = conn
|
|
.query_row("select count(*) from buckets", [], |row| row.get(0))
|
|
.unwrap();
|
|
let event_count: i64 = conn
|
|
.query_row("select count(*) from events", [], |row| row.get(0))
|
|
.unwrap();
|
|
assert_eq!(bucket_count, 2);
|
|
assert_eq!(event_count, 2);
|
|
}
|
|
|
|
fn create_fixture_db(path: &Path, buckets: &[(&str, &str)], events: &[(i64, i64, i64, &str)]) {
|
|
let conn = Connection::open(path).unwrap();
|
|
conn.execute_batch(
|
|
"create table buckets (
|
|
id text,
|
|
name text unique,
|
|
type text,
|
|
client text,
|
|
hostname text,
|
|
created integer,
|
|
data_deprecated text,
|
|
data text
|
|
);
|
|
create table events (
|
|
id integer primary key autoincrement,
|
|
bucketrow integer,
|
|
starttime integer,
|
|
endtime integer,
|
|
data text
|
|
);",
|
|
)
|
|
.unwrap();
|
|
for (name, id) in buckets {
|
|
conn.execute(
|
|
"insert into buckets (id, name, type, client, hostname, created, data_deprecated, data) values (?1, ?2, 'type', 'client', 'host', 1, null, '{}')",
|
|
params![id, name],
|
|
)
|
|
.unwrap();
|
|
}
|
|
for (bucketrow, starttime, endtime, data) in events {
|
|
conn.execute(
|
|
"insert into events (bucketrow, starttime, endtime, data) values (?1, ?2, ?3, ?4)",
|
|
params![bucketrow, starttime, endtime, data],
|
|
)
|
|
.unwrap();
|
|
}
|
|
}
|
|
}
|