Files
keep/src/db.rs
Andrew Phillips 52e9787edb refactor: deduplicate filter plugins, extract helpers across codebase
Bug fixes:
- client: add error field to ApiResponse to avoid swallowing server errors
- args/config: fix list_format default mismatch (5 vs 7 columns)
- client: url-encode size param in set_item_size

Dedup - filter plugins:
- Extract count_option() and pattern_option() helpers, replace 7 identical options()
- Add #[derive(Clone)] to all filter structs; remove verbose clone_box() impls
- Simplify FilterChain clone() and impl Clone for Box<dyn FilterPlugin>
- Add filter_clone_box! macro for future use
- Fix doctest example missing clone_box

Dedup - server API:
- Extract spawn_body_reader() with LimitBehavior enum for body streaming
- Extract check_binary_content() helper
- Extract stream_with_offset_and_length() helper
- Extract generate_status() helper in status.rs
- Extract append_query_params() helper in client.rs

Dedup - other:
- Extract yaml_value_to_string() in meta_plugin/mod.rs
- Extract item_from_row() in db.rs
- Delete unused DisplayListItem struct

Misc:
- Remove duplicate doc comment in compression_service.rs
2026-03-20 15:54:33 -03:00

1563 lines
43 KiB
Rust

use anyhow::{Context, Error, Result, anyhow};
use chrono::prelude::*;
use lazy_static::lazy_static;
use log::*;
use rusqlite::{Connection, OpenFlags, Row, params};
use rusqlite_migration::{M, Migrations};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::rc::Rc;
/*
Database module for the Keep application.
This module provides SQLite database operations for storing and retrieving
items, tags, and metadata. It includes schema migrations, CRUD operations,
and query utilities for efficient data access.
# Schema
The database uses three main tables:
- `items`: Core item information (ID, timestamp, uncompressed_size, compressed_size, closed, compression).
- `tags`: Item-tag associations (many-to-many).
- `metas`: Item-metadata associations (many-to-many).
Foreign keys are enforced with cascading deletes. Indexes on tag and meta
names improve query performance.
# Migrations
Automatic schema migrations are applied on database open using
`rusqlite_migration`. The current schema includes:
- Items table with auto-increment ID.
- Tags and metas tables with composite primary keys.
- Indexes on tag and meta names.
# Usage
Open a connection:
```ignore
let conn = db::open(PathBuf::from("keep.db"))?;
```
Insert an item:
```ignore
let item = db::Item { id: None, ts: Utc::now(), uncompressed_size: None, compressed_size: None, closed: false, compression: "lz4".to_string() };
let id = db::insert_item(&conn, item)?;
```
*/
lazy_static! {
// Database schema migrations for the Keep application.
//
// Defines the sequence of migrations to create and update the schema.
// Applied automatically when opening a database connection.
static ref MIGRATIONS: Migrations<'static> = Migrations::new(vec![
M::up(
"CREATE TABLE items(
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
ts TEXT NOT NULL,
size INTEGER NULL,
compression TEXT NOT NULL)"
),
M::up(
"CREATE TABLE tags (
id INTEGER NOT NULL,
name TEXT NOT NULL,
FOREIGN KEY(id) REFERENCES items(id) ON DELETE CASCADE,
PRIMARY KEY(id, name));"
),
M::up(
"CREATE TABLE metas (
id INTEGER NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
FOREIGN KEY(id) REFERENCES items(id) ON DELETE CASCADE,
PRIMARY KEY(id, name));"
),
M::up("CREATE INDEX idx_tags_name ON tags(name)"),
M::up("CREATE INDEX idx_metas_name ON metas(name)"),
M::up("UPDATE items SET compression = 'raw' WHERE compression = 'none'"),
M::up("ALTER TABLE items RENAME COLUMN size TO uncompressed_size"),
M::up("ALTER TABLE items ADD COLUMN compressed_size INTEGER NULL"),
M::up("ALTER TABLE items ADD COLUMN closed BOOLEAN NOT NULL DEFAULT 1"),
]);
}
/// Represents an item stored in the database.
///
/// Core structure for items containing basic properties.
///
/// # Fields
///
/// * `id` - Unique identifier, `None` for new items.
/// * `ts` - Creation timestamp in UTC.
/// * `uncompressed_size` - Uncompressed content size in bytes, `None` if not set.
/// * `compressed_size` - Compressed file size on disk, `None` if not set.
/// * `closed` - Whether the item has been fully written and closed.
/// * `compression` - Compression algorithm used.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Item {
/// Unique identifier for the item, None for new items.
pub id: Option<i64>,
/// Timestamp when the item was created.
pub ts: DateTime<Utc>,
/// Uncompressed size of the item content in bytes, None if not set.
pub uncompressed_size: Option<i64>,
/// Compressed file size on disk in bytes, None if not set.
pub compressed_size: Option<i64>,
/// Whether the item has been fully written and closed.
pub closed: bool,
/// Compression algorithm used for the item content.
pub compression: String,
}
fn item_from_row(row: &Row) -> Result<Item> {
Ok(Item {
id: row.get(0)?,
ts: row.get(1)?,
uncompressed_size: row.get(2)?,
compressed_size: row.get(3)?,
closed: row.get(4)?,
compression: row.get(5)?,
})
}
/// Represents a tag associated with an item.
///
/// Defines the relationship between items and tags in a many-to-many structure.
///
/// # Fields
///
/// * `id` - Item ID this tag belongs to.
/// * `name` - Tag name (unique per item).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tag {
/// ID of the item this tag belongs to.
pub id: i64,
/// Name of the tag.
pub name: String,
}
/// Represents metadata associated with an item.
///
/// Stores key-value metadata for items in a many-to-many relationship.
///
/// # Fields
///
/// * `id` - Item ID this metadata belongs to.
/// * `name` - Metadata field name (unique per item).
/// * `value` - Metadata field value.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Meta {
/// ID of the item this metadata belongs to.
pub id: i64,
/// Name of the metadata field.
pub name: String,
/// Value of the metadata field.
pub value: String,
}
/// Opens a database connection and ensures the schema is up to date.
///
/// Creates or opens the SQLite database file and applies any pending migrations.
/// Enables foreign keys and the array virtual table for advanced queries.
///
/// # Arguments
///
/// * `path` - Path to the SQLite database file.
///
/// # Returns
///
/// * `Result<Connection, Error>` - A SQLite connection on success, or an error if opening or migration fails.
///
/// # Errors
///
/// * Database file access issues.
/// * Migration failures.
/// * SQLite configuration errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// # Ok(())
/// # }
/// ```
pub fn open(path: PathBuf) -> Result<Connection, Error> {
debug!("DB: Opening file: {path:?}");
let mut conn = Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
)
.context("Problem opening file")?;
conn.pragma_update(None, "foreign_keys", "ON")
.context("Problem enabling SQLite foreign_keys pragma")?;
MIGRATIONS
.to_latest(&mut conn)
.context("Problem performing database migrations")?;
rusqlite::vtab::array::load_module(&conn).context("Problem enabling array module")?;
Ok(conn)
}
/// Inserts a new item into the database.
///
/// Creates a new row in the `items` table and returns the generated ID.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `item` - Item to insert (ID will be set automatically).
///
/// # Returns
///
/// * `Result<i64>` - The ID of the newly inserted item on success, or an error if insertion fails.
///
/// # Errors
///
/// * Database constraint violations.
/// * Connection issues.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use chrono::Utc;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let item = Item {
/// id: None,
/// ts: Utc::now(),
/// uncompressed_size: None,
/// compressed_size: None,
/// closed: false,
/// compression: "lz4".to_string(),
/// };
/// let id = db::insert_item(&conn, item)?;
/// assert!(id > 0);
/// # Ok(())
/// # }
/// ```
pub fn insert_item(conn: &Connection, item: Item) -> Result<i64> {
debug!("DB: Inserting item: {item:?}");
conn.execute(
"INSERT INTO items (ts, uncompressed_size, compressed_size, closed, compression) VALUES (?1, ?2, ?3, ?4, ?5)",
params![item.ts, item.uncompressed_size, item.compressed_size, item.closed, item.compression],
)?;
Ok(conn.last_insert_rowid())
}
/// Creates a new item in the database with the current timestamp.
///
/// Convenience function to create and insert a new item with default values.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `compression_type` - Compression type to use for the item.
///
/// # Returns
///
/// * `Result<Item>` - The created item with its ID set, or an error if creation fails.
///
/// # Errors
///
/// * Database insertion errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use keep::compression_engine::CompressionType;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let compression = CompressionType::LZ4;
/// let item = db::create_item(&conn, compression)?;
/// assert!(item.id.is_some());
/// # Ok(())
/// # }
/// ```
pub fn create_item(
conn: &Connection,
compression_type: crate::compression_engine::CompressionType,
) -> Result<Item> {
let item = Item {
id: None,
ts: chrono::Utc::now(),
uncompressed_size: None,
compressed_size: None,
closed: false,
compression: compression_type.to_string(),
};
let item_id = insert_item(conn, item.clone())?;
Ok(Item {
id: Some(item_id),
..item
})
}
/// Creates a new item with a specific timestamp (for import).
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `ts` - Timestamp to use for the item.
/// * `compression` - Compression type string (e.g., "lz4", "gzip", "raw").
///
/// # Returns
///
/// * `Result<Item>` - The created item with its ID set.
pub fn insert_item_with_ts(
conn: &Connection,
ts: chrono::DateTime<chrono::Utc>,
compression: &str,
) -> Result<Item> {
let item = Item {
id: None,
ts,
uncompressed_size: None,
compressed_size: None,
closed: false,
compression: compression.to_string(),
};
let item_id = insert_item(conn, item.clone())?;
Ok(Item {
id: Some(item_id),
..item
})
}
/// Adds a tag to an item.
///
/// Inserts a new tag association in the `tags` table.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `item_id` - ID of the item to tag.
/// * `tag_name` - Name of the tag to add.
///
/// # Returns
///
/// * `Result<()>` - Success or error if the operation fails.
///
/// # Errors
///
/// * Duplicate tag constraint violation.
/// * Database errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use chrono::Utc;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let item = Item { id: None, ts: Utc::now(), uncompressed_size: None, compressed_size: None, closed: false, compression: "lz4".to_string() };
/// let item_id = db::insert_item(&conn, item)?;
/// db::add_tag(&conn, item_id, "important")?;
/// # Ok(())
/// # }
/// ```
pub fn add_tag(conn: &Connection, item_id: i64, tag_name: &str) -> Result<()> {
let tag = Tag {
id: item_id,
name: tag_name.to_string(),
};
insert_tag(conn, tag)
}
/// Adds a tag to an item, ignoring if the tag already exists.
///
/// Uses `INSERT OR IGNORE` to make the operation idempotent.
pub fn upsert_tag(conn: &Connection, item_id: i64, tag_name: &str) -> Result<()> {
debug!("DB: Upserting tag: item={item_id}, tag={tag_name}");
conn.execute(
"INSERT OR IGNORE INTO tags (id, name) VALUES (?1, ?2)",
params![item_id, tag_name],
)?;
Ok(())
}
/// Adds metadata to an item.
///
/// Inserts a new metadata entry in the `metas` table.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `item_id` - ID of the item.
/// * `name` - Name of the metadata field.
/// * `value` - Value of the metadata field.
///
/// # Returns
///
/// * `Result<()>` - Success or error if the operation fails.
///
/// # Errors
///
/// * Duplicate metadata constraint violation.
/// * Database errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use chrono::Utc;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let item = Item { id: None, ts: Utc::now(), uncompressed_size: None, compressed_size: None, closed: false, compression: "lz4".to_string() };
/// let item_id = db::insert_item(&conn, item)?;
/// db::add_meta(&conn, item_id, "mime_type", "text/plain")?;
/// # Ok(())
/// # }
/// ```
pub fn add_meta(conn: &Connection, item_id: i64, name: &str, value: &str) -> Result<()> {
let meta = Meta {
id: item_id,
name: name.to_string(),
value: value.to_string(),
};
store_meta(conn, meta)
}
/// Updates an existing item in the database.
///
/// Modifies size and compression fields for an existing item.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `item` - Item with updated values (ID must be set).
///
/// # Returns
///
/// * `Result<()>` - Success or error if the operation fails.
///
/// # Errors
///
/// * Item not found.
/// * Database errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use chrono::Utc;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let item = Item { id: Some(1), ts: Utc::now(), uncompressed_size: Some(1024), compressed_size: Some(512), closed: true, compression: "lz4".to_string() };
/// db::update_item(&conn, item)?;
/// # Ok(())
/// # }
/// ```
pub fn update_item(conn: &Connection, item: Item) -> Result<()> {
debug!("DB: Updating item: {item:?}");
conn.execute(
"UPDATE items SET uncompressed_size=?2, compressed_size=?3, closed=?4, compression=?5 WHERE id=?1",
params![item.id, item.uncompressed_size, item.compressed_size, item.closed, item.compression,],
)?;
Ok(())
}
/// Deletes an item from the database.
///
/// Removes the item and cascades to delete associated tags and metadata.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `item` - Item to delete (ID must be set).
///
/// # Returns
///
/// * `Result<()>` - Success or error if the operation fails.
///
/// # Errors
///
/// * Item not found.
/// * Database errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use chrono::Utc;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let item = Item { id: Some(1), ts: Utc::now(), uncompressed_size: None, compressed_size: None, closed: false, compression: "lz4".to_string() };
/// db::delete_item(&conn, item)?;
/// # Ok(())
/// # }
/// ```
pub fn delete_item(conn: &Connection, item: Item) -> Result<()> {
debug!("DB: Deleting item: {item:?}");
conn.execute("DELETE FROM items WHERE id=?1", params![item.id])?;
Ok(())
}
/// Deletes a metadata entry from the database.
///
/// Removes a specific metadata field for an item.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `meta` - Metadata entry to delete.
///
/// # Returns
///
/// * `Result<()>` - Success or error if the operation fails.
///
/// # Errors
///
/// * Metadata not found.
/// * Database errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let meta = Meta { id: 1, name: "temp".to_string(), value: "".to_string() };
/// db::query_delete_meta(&conn, meta)?;
/// # Ok(())
/// # }
/// ```
pub fn query_delete_meta(conn: &Connection, meta: Meta) -> Result<()> {
debug!("DB: Deleting meta: {meta:?}");
conn.execute(
"DELETE FROM metas WHERE id=?1 AND name=?2",
params![meta.id, meta.name],
)?;
Ok(())
}
/// Inserts or updates a metadata entry in the database.
///
/// Uses UPSERT to handle both insert and update cases.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `meta` - Metadata entry to upsert.
///
/// # Returns
///
/// * `Result<()>` - Success or error if the operation fails.
///
/// # Errors
///
/// * Database constraint violations.
/// * Connection issues.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use chrono::Utc;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let item = Item { id: None, ts: Utc::now(), uncompressed_size: None, compressed_size: None, closed: false, compression: "lz4".to_string() };
/// let item_id = db::insert_item(&conn, item)?;
/// let meta = Meta { id: item_id, name: "mime_type".to_string(), value: "text/plain".to_string() };
/// db::query_upsert_meta(&conn, meta)?;
/// # Ok(())
/// # }
/// ```
pub fn query_upsert_meta(conn: &Connection, meta: Meta) -> Result<()> {
debug!("DB: Inserting meta: {meta:?}");
conn.execute(
"INSERT INTO metas (id, name, value) VALUES (?1, ?2, ?3)
ON CONFLICT(id, name) DO UPDATE SET value=?3",
params![meta.id, meta.name, meta.value],
)?;
Ok(())
}
/// Stores a metadata entry, deleting it if the value is empty.
///
/// Handles both insertion/update and deletion based on value presence.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `meta` - Metadata entry to store (empty value triggers deletion).
///
/// # Returns
///
/// * `Result<()>` - Success or error if the operation fails.
///
/// # Errors
///
/// * Database errors during insert/update/delete.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use chrono::Utc;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let item = Item { id: None, ts: Utc::now(), uncompressed_size: None, compressed_size: None, closed: false, compression: "lz4".to_string() };
/// let item_id = db::insert_item(&conn, item)?;
/// // Insert new metadata
/// let meta = Meta { id: item_id, name: "source".to_string(), value: "cli".to_string() };
/// db::store_meta(&conn, meta)?;
///
/// // Delete metadata with empty value
/// let meta = Meta { id: item_id, name: "temp".to_string(), value: "".to_string() };
/// db::store_meta(&conn, meta)?;
/// # Ok(())
/// # }
/// ```
pub fn store_meta(conn: &Connection, meta: Meta) -> Result<()> {
if meta.value.is_empty() {
query_delete_meta(conn, meta)?;
} else {
query_upsert_meta(conn, meta)?;
}
Ok(())
}
/// Inserts a tag into the database.
///
/// Creates a new tag association (fails if duplicate).
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `tag` - Tag to insert.
///
/// # Returns
///
/// * `Result<()>` - Success or error if the operation fails.
///
/// # Errors
///
/// * Duplicate tag constraint violation.
/// * Database errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use chrono::Utc;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let item = Item { id: None, ts: Utc::now(), uncompressed_size: None, compressed_size: None, closed: false, compression: "lz4".to_string() };
/// let item_id = db::insert_item(&conn, item)?;
/// let tag = Tag { id: item_id, name: "work".to_string() };
/// db::insert_tag(&conn, tag)?;
/// # Ok(())
/// # }
/// ```
pub fn insert_tag(conn: &Connection, tag: Tag) -> Result<()> {
debug!("DB: Inserting tag: {tag:?}");
conn.execute(
"INSERT INTO tags (id, name) VALUES (?1, ?2)",
params![tag.id, tag.name],
)?;
Ok(())
}
/// Deletes all tags associated with an item.
///
/// Clears all tag associations for the specified item.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `item` - Item whose tags should be deleted (ID must be set).
///
/// # Returns
///
/// * `Result<()>` - Success or error if the operation fails.
///
/// # Errors
///
/// * Database errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use chrono::Utc;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let item = Item { id: Some(1), ts: Utc::now(), uncompressed_size: None, compressed_size: None, closed: false, compression: "lz4".to_string() };
/// db::delete_item_tags(&conn, item)?;
/// # Ok(())
/// # }
/// ```
pub fn delete_item_tags(conn: &Connection, item: Item) -> Result<()> {
debug!("DB: Deleting all item tags: {item:?}");
conn.execute("DELETE FROM tags WHERE id=?1", params![item.id])?;
Ok(())
}
/// Sets the tags for an item, replacing any existing tags.
///
/// Deletes existing tags and inserts the new set.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `item` - Item to set tags for (ID must be set).
/// * `tags` - Vector of tag names to set.
///
/// # Returns
///
/// * `Result<()>` - Success or error if the operation fails.
///
/// # Errors
///
/// * Database insertion errors.
/// * Item ID missing.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use chrono::Utc;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let item = Item { id: None, ts: Utc::now(), uncompressed_size: None, compressed_size: None, closed: false, compression: "lz4".to_string() };
/// let item_id = db::insert_item(&conn, item)?;
/// let item = Item { id: Some(item_id), ts: Utc::now(), uncompressed_size: None, compressed_size: None, closed: false, compression: "lz4".to_string() };
/// let tags = vec!["project_a".to_string(), "urgent".to_string()];
/// db::set_item_tags(&conn, item, &tags)?;
/// # Ok(())
/// # }
/// ```
pub fn set_item_tags(conn: &Connection, item: Item, tags: &Vec<String>) -> Result<()> {
debug!("DB: Setting tags for item: {item:?} ?{tags:?}");
let item_id = item
.id
.ok_or_else(|| anyhow!("Item ID is required for set_item_tags"))?;
let tx = conn.unchecked_transaction()?;
delete_item_tags(&tx, item)?;
for tag_name in tags {
insert_tag(
&tx,
Tag {
id: item_id,
name: tag_name.to_string(),
},
)?;
}
tx.commit()?;
Ok(())
}
/// Queries all items from the database, ordered by ID.
///
/// Retrieves all items in ascending ID order.
///
/// # Arguments
///
/// * `conn` - Database connection.
///
/// # Returns
///
/// * `Result<Vec<Item>>` - All items in the database or an error.
///
/// # Errors
///
/// * Query execution errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let all_items = db::query_all_items(&conn)?;
/// assert!(all_items.len() >= 0);
/// # Ok(())
/// # }
/// ```
pub fn query_all_items(conn: &Connection) -> Result<Vec<Item>> {
debug!("DB: Querying all items");
let mut statement = conn
.prepare("SELECT id, ts, uncompressed_size, compressed_size, closed, compression FROM items ORDER BY id ASC")
.context("Problem preparing SQL statement")?;
let mut rows = statement.query(params![])?;
let mut items = Vec::new();
while let Some(row) = rows.next()? {
items.push(item_from_row(row)?);
}
Ok(items)
}
/// Queries items that have all the specified tags.
///
/// Uses array-based subquery to find items matching all tags.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `tags` - Vector of tag names to match (all must match).
///
/// # Returns
///
/// * `Result<Vec<Item>>` - Matching items or an error.
///
/// # Errors
///
/// * Query preparation or execution errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let tags = vec!["work".to_string(), "urgent".to_string()];
/// let tagged_items = db::query_tagged_items(&conn, &tags)?;
/// # Ok(())
/// # }
/// ```
pub fn query_tagged_items<'a>(conn: &'a Connection, tags: &'a Vec<String>) -> Result<Vec<Item>> {
debug!("DB: Querying tagged items: {tags:?}");
let mut statement = conn
.prepare_cached(
"
SELECT items.id,
items.ts,
items.uncompressed_size,
items.compressed_size,
items.closed,
items.compression,
count(tags_match.id) as tags_score
FROM items,
(SELECT tags.id FROM tags WHERE tags.name IN rarray(?1)) as tags_match
WHERE items.id = tags_match.id
GROUP BY items.id
HAVING tags_score = ?2
ORDER BY items.id ASC",
)
.context("Problem preparing SQL statement")?;
let tags_values: Vec<rusqlite::types::Value> = tags
.iter()
.map(|s| rusqlite::types::Value::from(s.clone()))
.collect();
let tags_ptr = Rc::new(tags_values);
let mut rows = statement.query(params![&tags_ptr, &tags.len()])?;
let mut items = Vec::new();
while let Some(row) = rows.next()? {
items.push(item_from_row(row)?);
}
Ok(items)
}
/// Gets all items from the database.
///
/// Alias for `query_all_items` for consistency.
///
/// # Arguments
///
/// * `conn` - Database connection.
///
/// # Returns
///
/// * `Result<Vec<Item>>` - All items or an error.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let items = db::get_items(&conn)?;
/// # Ok(())
/// # }
/// ```
pub fn get_items(conn: &Connection) -> Result<Vec<Item>> {
debug!("DB: Getting all items");
query_all_items(conn)
}
/// Gets items matching specified tags and metadata criteria.
///
/// First filters by tags (if provided), then applies metadata matching.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `tags` - Vector of tag names to match (all must match).
/// * `meta` - HashMap of metadata key-value pairs to match (exact match).
///
/// # Returns
///
/// * `Result<Vec<Item>>` - Matching items or an error.
///
/// # Errors
///
/// * Query errors.
/// * Metadata query failures.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use std::collections::HashMap;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let tags = vec!["project".to_string()];
/// let meta = HashMap::from([("status".to_string(), Some("active".to_string()))]);
/// let matching = db::get_items_matching(&conn, &tags, &meta)?;
/// # Ok(())
/// # }
/// ```
pub fn get_items_matching(
conn: &Connection,
tags: &Vec<String>,
meta: &HashMap<String, Option<String>>,
) -> Result<Vec<Item>> {
debug!("DB: Getting items matching: tags={tags:?} meta={meta:?}");
let items = match tags.is_empty() {
true => query_all_items(conn)?,
false => query_tagged_items(conn, tags)?,
};
if meta.is_empty() {
debug!("DB: Not filtering on meta");
Ok(items)
} else {
debug!("DB: Filtering on meta");
let item_ids: Vec<i64> = items.iter().filter_map(|i| i.id).collect();
let meta_map = get_meta_for_items(conn, &item_ids)?;
let filtered_items: Vec<Item> = items
.into_iter()
.filter(|item| {
let item_id = match item.id {
Some(id) => id,
None => return false,
};
let item_meta = match meta_map.get(&item_id) {
Some(m) => m,
None => return false,
};
meta.iter().all(|(k, v)| match v {
Some(val) => item_meta.get(k) == Some(val),
None => item_meta.contains_key(k),
})
})
.collect();
Ok(filtered_items)
}
}
/// Gets a single item matching specified tags and metadata.
///
/// Returns the most recent item matching all tags and metadata.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `tags` - Vector of tag names to match (all must match).
/// * `meta` - HashMap of metadata key-value pairs to match (exact match).
///
/// # Returns
///
/// * `Result<Option<Item>>` - The matching item or None if not found, or an error.
///
/// # Errors
///
/// * Query execution errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use std::collections::HashMap;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let tags = vec!["latest".to_string()];
/// let item = db::get_item_matching(&conn, &tags, &HashMap::new())?;
/// # Ok(())
/// # }
/// ```
pub fn get_item_matching(
conn: &Connection,
tags: &Vec<String>,
meta: &HashMap<String, Option<String>>,
) -> Result<Option<Item>> {
debug!("DB: Get item matching tags: {tags:?}, meta: {meta:?}");
let items = get_items_matching(conn, tags, meta)?;
Ok(items.into_iter().last())
}
/// Gets an item by its ID.
///
/// Simple lookup by primary key.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `item_id` - ID of the item to retrieve.
///
/// # Returns
///
/// * `Result<Option<Item>>` - The item if found, None if not found, or an error.
///
/// # Errors
///
/// * Query execution errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use chrono::Utc;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let item = Item { id: None, ts: Utc::now(), uncompressed_size: None, compressed_size: None, closed: false, compression: "lz4".to_string() };
/// let item_id = db::insert_item(&conn, item)?;
/// let item = db::get_item(&conn, item_id)?;
/// assert!(item.is_some());
/// # Ok(())
/// # }
/// ```
pub fn get_item(conn: &Connection, item_id: i64) -> Result<Option<Item>> {
debug!("DB: Getting item {item_id:?}");
let mut statement = conn
.prepare_cached(
"
SELECT id, ts, uncompressed_size, compressed_size, closed, compression
FROM items
WHERE items.id = ?1",
)
.context("Problem preparing SQL statement")?;
let mut rows = statement.query(params![item_id])?;
match rows.next()? {
Some(row) => Ok(Some(Item {
id: row.get(0)?,
ts: row.get(1)?,
uncompressed_size: row.get(2)?,
compressed_size: row.get(3)?,
closed: row.get(4)?,
compression: row.get(5)?,
})),
None => Ok(None),
}
}
/// Gets the most recently created item.
///
/// Retrieves the item with the highest ID (most recent).
///
/// # Arguments
///
/// * `conn` - Database connection.
///
/// # Returns
///
/// * `Result<Option<Item>>` - The most recent item or None if no items exist, or an error.
///
/// # Errors
///
/// * Query execution errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let latest = db::get_item_last(&conn)?;
/// # Ok(())
/// # }
/// ```
pub fn get_item_last(conn: &Connection) -> Result<Option<Item>> {
debug!("DB: Getting last item");
let mut statement = conn
.prepare_cached(
"
SELECT id, ts, uncompressed_size, compressed_size, closed, compression
FROM items
ORDER BY id DESC
LIMIT 1",
)
.context("Problem preparing SQL statement")?;
let mut rows = statement.query(params![])?;
match rows.next()? {
Some(row) => Ok(Some(Item {
id: row.get(0)?,
ts: row.get(1)?,
uncompressed_size: row.get(2)?,
compressed_size: row.get(3)?,
closed: row.get(4)?,
compression: row.get(5)?,
})),
None => Ok(None),
}
}
/// Gets all tags for a specific item.
///
/// Retrieves all tag associations ordered by name.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `item` - Item to get tags for (ID must be set).
///
/// # Returns
///
/// * `Result<Vec<Tag>>` - The item's tags or an error.
///
/// # Errors
///
/// * Query execution errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use chrono::Utc;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let item = Item { id: Some(1), ts: Utc::now(), uncompressed_size: None, compressed_size: None, closed: false, compression: "lz4".to_string() };
/// let tags = db::get_item_tags(&conn, &item)?;
/// # Ok(())
/// # }
/// ```
pub fn get_item_tags(conn: &Connection, item: &Item) -> Result<Vec<Tag>> {
debug!("DB: Getting tags for item: {item:?}");
let mut statement = conn
.prepare_cached("SELECT id, name FROM tags WHERE id=?1 ORDER BY name ASC")
.context("Problem preparing SQL statement")?;
let mut rows = statement.query(params![item.id])?;
let mut tags = Vec::new();
while let Some(row) = rows.next()? {
tags.push(Tag {
id: row.get(0)?,
name: row.get(1)?,
});
}
Ok(tags)
}
/// Gets all metadata for a specific item.
///
/// Retrieves all metadata entries ordered by name.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `item` - Item to get metadata for (ID must be set).
///
/// # Returns
///
/// * `Result<Vec<Meta>>` - The item's metadata or an error.
///
/// # Errors
///
/// * Query execution errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use chrono::Utc;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let item = Item { id: Some(1), ts: Utc::now(), uncompressed_size: None, compressed_size: None, closed: false, compression: "lz4".to_string() };
/// let meta = db::get_item_meta(&conn, &item)?;
/// # Ok(())
/// # }
/// ```
pub fn get_item_meta(conn: &Connection, item: &Item) -> Result<Vec<Meta>> {
debug!("DB: Getting item meta: {item:?}");
let mut statement = conn
.prepare_cached("SELECT id, name, value FROM metas WHERE id=?1 ORDER BY name ASC")
.context("Problem preparing SQL statement")?;
let mut rows = statement.query(params![item.id])?;
let mut metas = Vec::new();
while let Some(row) = rows.next()? {
metas.push(Meta {
id: row.get(0)?,
name: row.get(1)?,
value: row.get(2)?,
});
}
Ok(metas)
}
/// Gets a specific metadata entry for an item by name.
///
/// Retrieves a single metadata field by name.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `item` - Item to get metadata for (ID must be set).
/// * `name` - Name of the metadata field.
///
/// # Returns
///
/// * `Result<Option<Meta>>` - The metadata entry if found, None if not found, or an error.
///
/// # Errors
///
/// * Query execution errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use chrono::Utc;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let item = Item { id: Some(1), ts: Utc::now(), uncompressed_size: None, compressed_size: None, closed: false, compression: "lz4".to_string() };
/// let meta = db::get_item_meta_name(&conn, &item, "mime_type".to_string())?;
/// # Ok(())
/// # }
/// ```
pub fn get_item_meta_name(conn: &Connection, item: &Item, name: String) -> Result<Option<Meta>> {
debug!("DB: Getting item meta name: {item:?} {name:?}");
let mut statement = conn
.prepare_cached("SELECT id, name, value FROM metas WHERE id=?1 AND name=?2")
.context("Problem preparing SQL statement")?;
let mut rows = statement.query(params![item.id, name])?;
match rows.next()? {
Some(row) => Ok(Some(Meta {
id: row.get(0)?,
name: row.get(1)?,
value: row.get(2)?,
})),
None => Ok(None),
}
}
/// Gets the value of a specific metadata field for an item.
///
/// Retrieves just the value string for a metadata field.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `item` - Item to get metadata for (ID must be set).
/// * `name` - Name of the metadata field.
///
/// # Returns
///
/// * `Result<Option<String>>` - The metadata value if found, None if not found, or an error.
///
/// # Errors
///
/// * Query execution errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use chrono::Utc;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let item = Item { id: Some(1), ts: Utc::now(), uncompressed_size: None, compressed_size: None, closed: false, compression: "lz4".to_string() };
/// let value = db::get_item_meta_value(&conn, &item, "source".to_string())?;
/// # Ok(())
/// # }
/// ```
pub fn get_item_meta_value(conn: &Connection, item: &Item, name: String) -> Result<Option<String>> {
debug!("DB: Getting item meta value: {item:?} {name:?}");
let mut statement = conn
.prepare_cached("SELECT value FROM metas WHERE id=?1 AND name=?2")
.context("Problem preparing SQL statement")?;
let mut rows = statement.query(params![item.id, name])?;
match rows.next()? {
Some(row) => Ok(Some(row.get(0)?)),
None => Ok(None),
}
}
/// Gets tags for multiple items in a single query.
///
/// Efficiently retrieves tags for a batch of items using IN clause.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `item_ids` - Slice of item IDs to get tags for.
///
/// # Returns
///
/// * `Result<HashMap<i64, Vec<Tag>>>` - Mapping of item IDs to their tags, or an error.
///
/// # Errors
///
/// * Query preparation or execution errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let ids = vec![1, 2, 3];
/// let tags_map = db::get_tags_for_items(&conn, &ids)?;
/// # Ok(())
/// # }
/// ```
pub fn get_tags_for_items(
conn: &Connection,
item_ids: &[i64],
) -> Result<std::collections::HashMap<i64, Vec<Tag>>> {
debug!("DB: Getting tags for items: {item_ids:?}");
if item_ids.is_empty() {
return Ok(std::collections::HashMap::new());
}
// Create placeholders for the IN clause
let placeholders: Vec<String> = item_ids.iter().map(|_| "?".to_string()).collect();
let placeholders_str = placeholders.join(",");
let sql = format!(
"SELECT id, name FROM tags WHERE id IN ({placeholders_str}) ORDER BY id ASC, name ASC"
);
let mut statement = conn
.prepare_cached(&sql)
.context("Problem preparing SQL statement")?;
let mut rows = statement.query(rusqlite::params_from_iter(item_ids))?;
let mut tags_map: std::collections::HashMap<i64, Vec<Tag>> = std::collections::HashMap::new();
while let Some(row) = rows.next()? {
let id: i64 = row.get(0)?;
let name: String = row.get(1)?;
tags_map.entry(id).or_default().push(Tag { id, name });
}
Ok(tags_map)
}
/// Gets metadata for multiple items in a single query.
///
/// Efficiently retrieves metadata for a batch of items using IN clause.
///
/// # Arguments
///
/// * `conn` - Database connection.
/// * `item_ids` - Slice of item IDs to get metadata for.
///
/// # Returns
///
/// * `Result<HashMap<i64, HashMap<String, String>>>` - Mapping of item IDs to their metadata, or an error.
///
/// # Errors
///
/// * Query preparation or execution errors.
///
/// # Examples
///
/// ```
/// # use keep::db;
/// # use keep::db::*;
/// # use std::path::PathBuf;
/// # use tempfile;
/// # fn main() -> anyhow::Result<()> {
/// let _tmp = tempfile::tempdir()?;
/// let db_path = _tmp.path().join("keep.db");
/// let conn = db::open(db_path)?;
/// let ids = vec![1, 2, 3];
/// let meta_map = db::get_meta_for_items(&conn, &ids)?;
/// # Ok(())
/// # }
/// ```
pub fn get_meta_for_items(
conn: &Connection,
item_ids: &[i64],
) -> Result<std::collections::HashMap<i64, std::collections::HashMap<String, String>>> {
debug!("DB: Getting meta for items: {item_ids:?}");
if item_ids.is_empty() {
return Ok(std::collections::HashMap::new());
}
// Create placeholders for the IN clause
let placeholders: Vec<String> = item_ids.iter().map(|_| "?".to_string()).collect();
let placeholders_str = placeholders.join(",");
let sql = format!(
"SELECT id, name, value FROM metas WHERE id IN ({placeholders_str}) ORDER BY id ASC, name ASC"
);
let mut statement = conn
.prepare_cached(&sql)
.context("Problem preparing SQL statement")?;
let mut rows = statement.query(rusqlite::params_from_iter(item_ids))?;
let mut meta_map: std::collections::HashMap<i64, std::collections::HashMap<String, String>> =
std::collections::HashMap::new();
while let Some(row) = rows.next()? {
let id: i64 = row.get(0)?;
let name: String = row.get(1)?;
let value: String = row.get(2)?;
meta_map.entry(id).or_default().insert(name, value);
}
Ok(meta_map)
}