mod dataframe;
mod schema;
pub use polars::prelude as polars_prelude;
use polars::prelude::*;
use rusqlite::types::{FromSql, FromSqlResult, ToSqlOutput, ValueRef};
use rusqlite::{Connection, OpenFlags, Row, ToSql};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::time::Duration;
use time::OffsetDateTime;
use dataframe::query_to_dataframe;
#[derive(Debug, Clone)]
pub enum DatabasePath {
RealPath(PathBuf),
#[cfg(test)]
InMemory,
}
impl DatabasePath {
fn open_connection(&self) -> Result<Connection, BearError> {
match self {
DatabasePath::RealPath(path) => {
let conn = Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
conn.busy_timeout(Duration::from_millis(5000))?;
conn.pragma_update(None, "query_only", "ON")?;
Ok(conn)
}
#[cfg(test)]
DatabasePath::InMemory => {
let conn = Connection::open_in_memory()?;
schema::setup_test_schema(&conn)?;
Ok(conn)
}
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum BearError {
#[error("Unable to load users home directory")]
NoHomeDirectory,
#[error("SQL Error: {source}")]
SqlError {
#[from]
source: rusqlite::Error,
},
#[error("Polars Error: {source}")]
PolarsError {
#[from]
source: PolarsError,
},
}
#[derive(Debug, Clone)]
pub struct NotesQuery {
limit: Option<u32>,
include_trashed: bool,
include_archived: bool,
}
impl Default for NotesQuery {
fn default() -> Self {
Self {
limit: Some(10),
include_trashed: false,
include_archived: false,
}
}
}
impl NotesQuery {
pub fn new() -> Self {
Self::default()
}
pub fn limit(
mut self,
limit: u32,
) -> Self {
self.limit = Some(limit);
self
}
pub fn no_limit(mut self) -> Self {
self.limit = None;
self
}
pub fn include_trashed(mut self) -> Self {
self.include_trashed = true;
self
}
pub fn include_archived(mut self) -> Self {
self.include_archived = true;
self
}
pub fn include_all(mut self) -> Self {
self.include_trashed = true;
self.include_archived = true;
self
}
}
pub struct BearDb {
db_path: DatabasePath,
_metadata: schema::BearDbMetadata,
normalizing_cte: String,
}
impl BearDb {
pub fn new() -> Result<Self, BearError> {
let home_dir = dirs::home_dir().ok_or(BearError::NoHomeDirectory)?;
let db_path = home_dir.join(
"Library/Group Containers/9K33E3U3T4.net.shinyfrog.bear/Application Data/database.sqlite",
);
Self::new_with_path(DatabasePath::RealPath(db_path))
}
pub(crate) fn new_with_path(db_path: DatabasePath) -> Result<Self, BearError> {
let connection = db_path.open_connection()?;
let metadata = schema::discover_metadata(&connection)?;
let normalizing_cte = schema::generate_normalizing_cte(&metadata);
drop(connection);
Ok(BearDb {
db_path,
_metadata: metadata,
normalizing_cte,
})
}
fn with_connection<F, R>(
&self,
f: F,
) -> Result<R, BearError>
where
F: FnOnce(&Queryable) -> Result<R, BearError>,
{
let connection = self.db_path.open_connection()?;
let queryable = Queryable::new(&connection, &self.normalizing_cte);
f(&queryable)
}
pub fn tags(&self) -> Result<BearTags, BearError> {
self.with_connection(|queryable| {
let mut statement = queryable.prepare(
r"
SELECT
id,
name,
modified
FROM tags
ORDER BY name ASC",
)?;
let results: rusqlite::Result<Vec<BearTag>> = statement
.query_map([], |row| {
Ok(BearTag {
id: row.get("id")?,
name: row.get("name")?,
modified: row.get("modified")?,
})
})?
.collect();
let tags = results?.into_iter().map(|tag| (tag.id, tag)).collect();
Ok(BearTags { tags })
})
}
pub fn notes(
&self,
query: NotesQuery,
) -> Result<Vec<BearNote>, BearError> {
self.with_connection(|queryable| {
let mut where_clauses = Vec::new();
if !query.include_trashed {
where_clauses.push("is_trashed <> 1");
}
if !query.include_archived {
where_clauses.push("is_archived <> 1");
}
let where_clause = if where_clauses.is_empty() {
String::new()
} else {
format!("WHERE {}", where_clauses.join(" AND "))
};
let limit_clause = query
.limit
.map(|l| format!("LIMIT {}", l))
.unwrap_or_default();
let query = format!(
r"
SELECT
id,
unique_id,
title,
content,
modified,
created,
is_pinned
FROM notes
{}
ORDER BY modified DESC
{}",
where_clause, limit_clause
);
let mut statement = queryable.prepare(&query)?;
let results: rusqlite::Result<Vec<BearNote>> =
statement.query_map([], note_from_row)?.collect();
Ok(results?)
})
}
pub fn note_links(
&self,
from: BearNoteId,
) -> Result<Vec<BearNote>, BearError> {
self.with_connection(|queryable| {
let mut statement = queryable.prepare(
r"
SELECT
n.id,
n.unique_id,
n.title,
n.content,
n.modified,
n.created,
n.is_pinned
FROM notes as n
INNER JOIN note_links as nl ON nl.to_note_id = n.id
WHERE n.is_trashed <> 1 AND n.is_archived <> 1 AND nl.from_note_id = ?
ORDER BY n.modified DESC",
)?;
let results: rusqlite::Result<Vec<BearNote>> =
statement.query_map([from], note_from_row)?.collect();
Ok(results?)
})
}
pub fn note_tags(
&self,
from: BearNoteId,
) -> Result<HashSet<BearTagId>, BearError> {
self.with_connection(|queryable| {
let mut statement = queryable.prepare(
r"
SELECT
tag_id
FROM note_tags
WHERE note_id = ?",
)?;
let results: rusqlite::Result<HashSet<BearTagId>> = statement
.query_map([from], |row| row.get("tag_id"))?
.collect();
Ok(results?)
})
}
pub fn query(
&self,
sql: &str,
) -> Result<DataFrame, BearError> {
self.with_connection(|queryable| query_to_dataframe(queryable, sql))
}
}
pub struct Queryable<'a> {
conn: &'a Connection,
normalizing_cte: &'a str,
}
impl<'a> Queryable<'a> {
fn new(
conn: &'a Connection,
normalizing_cte: &'a str,
) -> Self {
Self {
conn,
normalizing_cte,
}
}
#[cfg(test)]
pub(crate) fn new_for_test(
conn: &'a Connection,
normalizing_cte: &'a str,
) -> Self {
Self::new(conn, normalizing_cte)
}
pub fn prepare(
&self,
user_sql: &str,
) -> rusqlite::Result<rusqlite::Statement<'a>> {
let full_sql = format!("{}\n{}", self.normalizing_cte, user_sql);
self.conn.prepare(&full_sql)
}
}
#[derive(Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Hash)]
pub struct DbId(i64);
#[derive(Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Hash)]
pub struct BearNoteId(DbId);
#[derive(Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Hash)]
pub struct BearTagId(DbId);
impl FromSql for DbId {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
Ok(Self(value.as_i64()?))
}
}
impl FromSql for BearNoteId {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
Ok(Self(FromSql::column_result(value)?))
}
}
impl FromSql for BearTagId {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
Ok(Self(FromSql::column_result(value)?))
}
}
impl ToSql for DbId {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
self.0.to_sql()
}
}
impl ToSql for BearNoteId {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
self.0.to_sql()
}
}
#[derive(Debug, Clone)]
pub struct BearTag {
id: BearTagId,
name: String,
modified: Option<OffsetDateTime>,
}
impl BearTag {
pub fn id(&self) -> BearTagId {
self.id
}
pub fn name(&self) -> &str {
&self.name
}
pub fn modified(&self) -> Option<OffsetDateTime> {
self.modified
}
}
#[derive(Debug)]
pub struct BearTags {
tags: HashMap<BearTagId, BearTag>,
}
impl BearTags {
pub fn get(
&self,
tag_id: &BearTagId,
) -> Option<&BearTag> {
self.tags.get(tag_id)
}
pub fn count(&self) -> usize {
self.tags.len()
}
pub fn iter(&self) -> impl Iterator<Item = &BearTag> {
self.tags.values()
}
pub fn names(
&self,
tag_ids: &HashSet<BearTagId>,
) -> HashSet<String> {
tag_ids
.iter()
.filter_map(|id| self.get(id).map(|t| t.name.clone()))
.collect()
}
}
#[derive(Debug)]
pub struct BearNote {
id: BearNoteId,
unique_id: String,
title: String,
content: String,
modified: OffsetDateTime,
created: OffsetDateTime,
is_pinned: bool,
}
impl BearNote {
pub fn id(&self) -> BearNoteId {
self.id
}
pub fn unique_id(&self) -> &str {
&self.unique_id
}
pub fn title(&self) -> &str {
&self.title
}
pub fn content(&self) -> &str {
&self.content
}
pub fn modified(&self) -> OffsetDateTime {
self.modified
}
pub fn created(&self) -> OffsetDateTime {
self.created
}
pub fn is_pinned(&self) -> bool {
self.is_pinned
}
}
fn note_from_row(row: &Row) -> rusqlite::Result<BearNote> {
Ok(BearNote {
id: row.get("id")?,
unique_id: row.get("unique_id")?,
title: row.get("title")?,
content: row.get("content")?,
created: row.get("created")?,
modified: row.get("modified")?,
is_pinned: row.get("is_pinned")?,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_beardb_with_inmemory() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let tags = db.tags().unwrap();
assert_eq!(tags.count(), 2);
let notes = db.notes(NotesQuery::default()).unwrap();
assert_eq!(notes.len(), 2);
let all_notes = db
.notes(NotesQuery::new().include_all().no_limit())
.unwrap();
assert_eq!(all_notes.len(), 3);
let df = db
.query("SELECT id, title FROM notes WHERE is_trashed = 0")
.unwrap();
assert_eq!(df.height(), 2); assert_eq!(df.width(), 2);
let df = db.query("SELECT COUNT(*) as count FROM notes").unwrap();
assert_eq!(df.height(), 1);
assert_eq!(df.width(), 1);
let series = df.column("count").unwrap();
let value = series.get(0).unwrap();
match value {
AnyValue::Int64(n) => assert_eq!(n, 3),
_ => panic!("Expected Int64, got: {:?}", value),
}
let df = db
.query(
r"
SELECT n.title, t.name as tag_name
FROM notes n
JOIN note_tags nt ON n.id = nt.note_id
JOIN tags t ON nt.tag_id = t.id
",
)
.unwrap();
assert_eq!(df.height(), 2); }
}