mod dataframe;
mod models;
mod schema;
pub use models::{Note, NoteId, Tag, TagId, TagsMap};
pub use polars::prelude as polars_prelude;
use models::{note_from_row, tag_from_row};
use polars::prelude::*;
use rusqlite::{Connection, OpenFlags};
use std::collections::HashSet;
use std::path::PathBuf;
use std::time::Duration;
use dataframe::query_to_dataframe;
#[derive(Debug, Clone)]
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
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortOn {
Modified,
Created,
Title,
}
impl SortOn {
pub fn asc(self) -> SortOrder {
SortOrder::Asc(self)
}
pub fn desc(self) -> SortOrder {
SortOrder::Desc(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortOrder {
Asc(SortOn),
Desc(SortOn),
}
impl Default for SortOrder {
fn default() -> Self {
SortOrder::Desc(SortOn::Modified)
}
}
impl SortOrder {
fn to_sql(&self) -> &'static str {
match self {
SortOrder::Desc(SortOn::Modified) => "modified DESC",
SortOrder::Asc(SortOn::Modified) => "modified ASC",
SortOrder::Desc(SortOn::Created) => "created DESC",
SortOrder::Asc(SortOn::Created) => "created ASC",
SortOrder::Asc(SortOn::Title) => "title ASC",
SortOrder::Desc(SortOn::Title) => "title DESC",
}
}
}
#[derive(Debug, Clone)]
pub struct SearchQuery {
query: String,
search_title: bool,
search_content: bool,
case_sensitive: bool,
limit: Option<u32>,
sort_by: SortOrder,
include_trashed: bool,
include_archived: bool,
}
impl SearchQuery {
pub fn new(query: impl Into<String>) -> Self {
Self {
query: query.into(),
search_title: true,
search_content: true,
case_sensitive: false,
limit: Some(50),
sort_by: SortOrder::default(),
include_trashed: false,
include_archived: false,
}
}
pub fn title_only(mut self) -> Self {
self.search_title = true;
self.search_content = false;
self
}
pub fn content_only(mut self) -> Self {
self.search_title = false;
self.search_content = true;
self
}
pub fn title_and_content(mut self) -> Self {
self.search_title = true;
self.search_content = true;
self
}
pub fn case_sensitive(mut self) -> Self {
self.case_sensitive = true;
self
}
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 sort_by(
mut self,
sort: SortOrder,
) -> Self {
self.sort_by = sort;
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<TagsMap, 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<Tag>> = statement.query_map([], tag_from_row)?.collect();
let tags = results?.into_iter().map(|tag| (tag.id(), tag)).collect();
Ok(TagsMap { tags })
})
}
pub fn note(
&self,
id: &NoteId,
) -> Result<Option<Note>, BearError> {
self.with_connection(|queryable| {
let mut statement = queryable.prepare(
r"
SELECT
id,
core_db_id,
title,
content,
modified,
created,
is_pinned
FROM notes
WHERE id = ?",
)?;
let result = statement.query_row([id.as_str()], note_from_row);
match result {
Ok(note) => Ok(Some(note)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(BearError::SqlError { source: e }),
}
})
}
pub fn notes(
&self,
query: NotesQuery,
) -> Result<Vec<Note>, 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,
core_db_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<Note>> = statement.query_map([], note_from_row)?.collect();
Ok(results?)
})
}
pub fn search(
&self,
search: SearchQuery,
) -> Result<Vec<Note>, BearError> {
self.with_connection(|queryable| {
let mut search_conditions = Vec::new();
let like_operator = if search.case_sensitive {
"GLOB"
} else {
"LIKE"
};
let pattern = if search.case_sensitive {
format!("*{}*", search.query)
} else {
format!("%{}%", search.query)
};
if search.search_title {
search_conditions.push(format!("title {} ?", like_operator));
}
if search.search_content {
search_conditions.push(format!("content {} ?", like_operator));
}
if search_conditions.is_empty() {
return Ok(Vec::new());
}
let search_clause = format!("({})", search_conditions.join(" OR "));
let mut where_clauses = vec![search_clause];
if !search.include_trashed {
where_clauses.push("is_trashed <> 1".to_string());
}
if !search.include_archived {
where_clauses.push("is_archived <> 1".to_string());
}
let where_clause = format!("WHERE {}", where_clauses.join(" AND "));
let limit_clause = search
.limit
.map(|l| format!("LIMIT {}", l))
.unwrap_or_default();
let query_sql = format!(
r"
SELECT
id,
core_db_id,
title,
content,
modified,
created,
is_pinned
FROM notes
{}
ORDER BY {}
{}",
where_clause,
search.sort_by.to_sql(),
limit_clause
);
let mut statement = queryable.prepare(&query_sql)?;
let results: rusqlite::Result<Vec<Note>> = if search.search_title && search.search_content {
statement
.query_map([pattern.as_str(), pattern.as_str()], note_from_row)?
.collect()
} else {
statement
.query_map([pattern.as_str()], note_from_row)?
.collect()
};
Ok(results?)
})
}
pub fn note_links(
&self,
from: &NoteId,
) -> Result<Vec<Note>, BearError> {
self.with_connection(|queryable| {
let mut statement = queryable.prepare(
r"
SELECT
n.id,
n.core_db_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<Note>> = statement
.query_map([from.as_str()], note_from_row)?
.collect();
Ok(results?)
})
}
pub fn note_tags(
&self,
from: &NoteId,
) -> Result<HashSet<TagId>, BearError> {
self.with_connection(|queryable| {
let mut statement = queryable.prepare(
r"
SELECT
nt.tag_id
FROM note_tags nt
WHERE nt.note_id = ?",
)?;
let results: rusqlite::Result<HashSet<TagId>> = statement
.query_map([from.as_str()], |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)
}
}
#[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(), 3);
let notes = db.notes(NotesQuery::default()).unwrap();
assert_eq!(notes.len(), 4);
let all_notes = db
.notes(NotesQuery::new().include_all().no_limit())
.unwrap();
assert_eq!(all_notes.len(), 5);
let df = db
.query("SELECT id, title FROM notes WHERE is_trashed = 0")
.unwrap();
assert_eq!(df.height(), 4); 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, 5),
_ => 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); }
#[test]
fn test_note_with_empty_title() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let notes = db
.notes(NotesQuery::new().no_limit().include_all())
.unwrap();
let note_with_empty_title = notes
.iter()
.find(|n| n.title().is_empty())
.expect("Should have a note with empty title");
assert_eq!(note_with_empty_title.title(), "");
assert!(note_with_empty_title.content().is_some());
assert_eq!(
note_with_empty_title.content().unwrap(),
"Content with empty title"
);
}
#[test]
fn test_note_with_null_content() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let notes = db
.notes(NotesQuery::new().no_limit().include_all())
.unwrap();
let note_with_null_content = notes
.iter()
.find(|n| n.content().is_none())
.expect("Should have a note with NULL content");
assert_eq!(note_with_null_content.title(), "Empty Note");
assert!(note_with_null_content.content().is_none());
}
#[test]
fn test_all_notes_have_unique_id() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let notes = db
.notes(NotesQuery::new().no_limit().include_all())
.unwrap();
for note in notes {
let uuid = note.id();
assert!(!uuid.as_str().is_empty(), "unique_id should never be empty");
}
}
#[test]
fn test_tag_with_null_modified() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let tags = db.tags().unwrap();
let unmodified_tag = tags
.iter()
.find(|t| t.modified().is_none())
.expect("Should have a tag with NULL modified date");
assert_eq!(unmodified_tag.name(), Some("unmodified-tag"));
assert!(unmodified_tag.modified().is_none());
}
#[test]
fn test_tags_count_includes_null_modified() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let tags = db.tags().unwrap();
assert_eq!(tags.count(), 3);
}
#[test]
fn test_query_with_empty_title() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let df = db
.query("SELECT id, title, content FROM notes WHERE title = ''")
.unwrap();
assert_eq!(df.height(), 1); }
#[test]
fn test_query_with_null_content() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let df = db
.query("SELECT id, title, content FROM notes WHERE content IS NULL")
.unwrap();
assert_eq!(df.height(), 1); }
#[test]
fn test_note_tags_names_handles_null() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let tags = db.tags().unwrap();
let all_tag_ids: HashSet<_> = tags.iter().map(|t| t.id()).collect();
let names = tags.names(&all_tag_ids);
assert_eq!(names.len(), 3);
assert!(names.contains("work"));
assert!(names.contains("personal"));
assert!(names.contains("unmodified-tag"));
}
#[test]
fn test_all_notes_have_valid_id() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let notes = db
.notes(NotesQuery::new().no_limit().include_all())
.unwrap();
for note in notes {
let _id = note.id();
let _created = note.created();
let _modified = note.modified();
let _is_pinned = note.is_pinned();
}
}
#[test]
fn test_all_tags_have_valid_id() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let tags = db.tags().unwrap();
for tag in tags.iter() {
let _id = tag.id(); }
}
#[test]
fn test_note_links_with_null_safe_notes() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let notes = db.notes(NotesQuery::new().limit(1)).unwrap();
let first_note = ¬es[0];
let linked_notes = db.note_links(first_note.id()).unwrap();
for linked_note in linked_notes {
let _id = linked_note.id();
let _title = linked_note.title();
let _content = linked_note.content(); }
}
#[test]
fn test_get_note_by_id_existing() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let notes = db.notes(NotesQuery::new().limit(1)).unwrap();
let expected_note = ¬es[0];
let note_id = expected_note.id();
let found_note = db.note(note_id).unwrap();
assert!(found_note.is_some());
let found_note = found_note.unwrap();
assert_eq!(found_note.id(), expected_note.id());
assert_eq!(found_note.title(), expected_note.title());
assert_eq!(found_note.content(), expected_note.content());
}
#[test]
fn test_get_note_by_id_not_found() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let note_id = NoteId::new("nonexistent-uuid".to_string());
let result = db.note(¬e_id).unwrap();
assert!(result.is_none());
}
#[test]
fn test_get_note_by_id_with_null_content() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let notes = db
.notes(NotesQuery::new().no_limit().include_all())
.unwrap();
let null_content_note = notes.iter().find(|n| n.content().is_none()).unwrap();
let note_id = null_content_note.id();
let found_note = db.note(note_id).unwrap();
assert!(found_note.is_some());
let found_note = found_note.unwrap();
assert_eq!(found_note.title(), "Empty Note");
assert!(found_note.content().is_none());
}
#[test]
fn test_note_method() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let notes = db.notes(NotesQuery::new().limit(1)).unwrap();
let expected_note = ¬es[0];
let note_id = expected_note.id();
let found_note = db.note(note_id).unwrap();
assert!(found_note.is_some());
let found_note = found_note.unwrap();
assert_eq!(found_note.id(), expected_note.id());
assert_eq!(found_note.title(), expected_note.title());
assert_eq!(found_note.content(), expected_note.content());
let nonexistent_id = NoteId::new("nonexistent-uuid".to_string());
let result = db.note(&nonexistent_id).unwrap();
assert!(result.is_none());
}
#[test]
fn test_search_basic() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let results = db.search(SearchQuery::new("first")).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].title(), "First Note");
let results = db.search(SearchQuery::new("second")).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].title(), "Second Note");
let results = db.search(SearchQuery::new("Content")).unwrap();
assert!(results.len() >= 2);
}
#[test]
fn test_search_title_only() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let results = db.search(SearchQuery::new("Note").title_only()).unwrap();
assert_eq!(results.len(), 3);
for note in &results {
assert!(note.title().contains("Note"));
}
}
#[test]
fn test_search_content_only() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let results = db
.search(SearchQuery::new("Content").content_only())
.unwrap();
assert!(results.len() >= 2);
for note in &results {
if let Some(content) = note.content() {
assert!(content.contains("Content"));
}
}
}
#[test]
fn test_search_case_sensitive() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let results = db.search(SearchQuery::new("FIRST")).unwrap();
assert_eq!(results.len(), 1);
let results = db
.search(SearchQuery::new("FIRST").case_sensitive())
.unwrap();
assert_eq!(results.len(), 0);
let results = db
.search(SearchQuery::new("First").case_sensitive())
.unwrap();
assert_eq!(results.len(), 1);
}
#[test]
fn test_search_with_limit() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let results = db.search(SearchQuery::new("note").limit(2)).unwrap();
assert!(results.len() <= 2);
let results = db.search(SearchQuery::new("note").no_limit()).unwrap();
assert!(results.len() >= 2);
}
#[test]
fn test_search_with_sorting() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let results = db
.search(
SearchQuery::new("Note")
.title_only()
.sort_by(SortOn::Title.asc()),
)
.unwrap();
assert!(results.len() >= 2);
for i in 0..results.len() - 1 {
assert!(results[i].title() <= results[i + 1].title());
}
let results = db
.search(
SearchQuery::new("Note")
.title_only()
.sort_by(SortOn::Title.desc()),
)
.unwrap();
assert!(results.len() >= 2);
for i in 0..results.len() - 1 {
assert!(results[i].title() >= results[i + 1].title());
}
}
#[test]
fn test_search_excludes_trashed_by_default() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let results = db.search(SearchQuery::new("Trashed")).unwrap();
assert_eq!(results.len(), 0);
let results = db
.search(SearchQuery::new("Trashed").include_trashed())
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].title(), "Trashed Note");
}
#[test]
fn test_search_include_all() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let results = db.search(SearchQuery::new("Note").include_all()).unwrap();
let has_trashed = results.iter().any(|n| n.title() == "Trashed Note");
assert!(has_trashed);
}
#[test]
fn test_search_no_results() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let results = db.search(SearchQuery::new("nonexistent_term_xyz")).unwrap();
assert_eq!(results.len(), 0);
}
#[test]
fn test_search_with_null_content() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let results = db
.search(SearchQuery::new("Empty Note").title_only())
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].title(), "Empty Note");
assert!(results[0].content().is_none());
}
#[test]
fn test_search_with_empty_title() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let results = db
.search(SearchQuery::new("empty title").content_only())
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].title(), "");
}
#[test]
fn test_search_complex_query() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let results = db
.search(
SearchQuery::new("Note")
.title_only()
.sort_by(SortOn::Title.asc())
.limit(2)
.include_all(),
)
.unwrap();
assert!(results.len() <= 2);
if results.len() == 2 {
assert!(results[0].title() <= results[1].title());
}
}
#[test]
fn test_search_query_builder_chaining() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let query = SearchQuery::new("note")
.title_only()
.case_sensitive()
.limit(10)
.sort_by(SortOn::Modified.desc())
.include_archived();
let _results = db.search(query).unwrap();
}
#[test]
fn test_search_all_sort_orders() {
let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
let orders = vec![
SortOn::Modified.desc(),
SortOn::Modified.asc(),
SortOn::Created.desc(),
SortOn::Created.asc(),
SortOn::Title.asc(),
SortOn::Title.desc(),
];
for order in orders {
let _results = db.search(SearchQuery::new("Note").sort_by(order)).unwrap();
}
}
}