bear_query/
lib.rs

1//! # bear-query
2//!
3//! A completely read-only, minimal-contention library for querying Bear app's SQLite database.
4//!
5//! ## Safety Guarantees
6//!
7//! This library implements multiple layers of protection to ensure minimal interference with Bear:
8//!
9//! 1. **Read-Only File Access**: Opens with `SQLITE_OPEN_READ_ONLY`
10//! 2. **No Internal Locks**: Uses `SQLITE_OPEN_NO_MUTEX` to minimize lock contention
11//! 3. **Query-Only Mode**: Enforces `PRAGMA query_only = ON` at SQLite level
12//! 4. **Short-Lived Connections**: Connections are only open during each query
13//! 5. **Busy Timeout**: 5000ms timeout handles database contention gracefully
14//!
15//! ## How It Works
16//!
17//! Bear does **not** use SQLite's WAL (Write-Ahead Logging) mode by default. To minimize
18//! interference, this library uses short-lived connections that are opened only when needed
19//! and closed immediately after use.
20//!
21//! Each method call:
22//! - Opens a read-only connection with a 5000ms busy timeout
23//! - Executes the query
24//! - Automatically closes the connection
25//!
26//! This approach ensures minimal lock contention with Bear's write operations.
27//!
28//! ## Normalized Schema
29//!
30//! This library automatically normalizes Bear's Core Data schema through Common Table Expressions (CTEs).
31//! All queries (both typed methods and the generic `query()` API) have access to these normalized views:
32//!
33//! ### `notes` Table
34//!
35//! The normalized view of all notes in Bear.
36//!
37//! | Column | Type | Description |
38//! |--------|------|-------------|
39//! | `id` | TEXT | Bear's UUID for the note (primary identifier) |
40//! | `core_db_id` | INTEGER | Internal Core Data primary key (for joins) |
41//! | `title` | TEXT | Note title |
42//! | `content` | TEXT | Full note content (Markdown) |
43//! | `modified` | DATETIME | Last modification timestamp (converted from Core Data epoch) |
44//! | `created` | DATETIME | Creation timestamp (converted from Core Data epoch) |
45//! | `is_pinned` | INTEGER | 1 if pinned, 0 otherwise |
46//! | `is_trashed` | INTEGER | 1 if in trash, 0 otherwise |
47//! | `is_archived` | INTEGER | 1 if archived, 0 otherwise |
48//!
49//! ### `tags` Table
50//!
51//! The normalized view of all tags.
52//!
53//! | Column | Type | Description |
54//! |--------|------|-------------|
55//! | `id` | INTEGER | Tag's primary key |
56//! | `name` | TEXT | Tag name (e.g., "work/projects") |
57//! | `modified` | DATETIME | Last modification timestamp |
58//!
59//! ### `note_tags` Table
60//!
61//! Junction table linking notes to their tags (many-to-many relationship).
62//!
63//! | Column | Type | Description |
64//! |--------|------|-------------|
65//! | `note_id` | TEXT | Note UUID (references notes.id) |
66//! | `tag_id` | INTEGER | Tag ID (references tags.id) |
67//!
68//! ### `note_links` Table
69//!
70//! Links between notes (bidirectional wiki-style links).
71//!
72//! | Column | Type | Description |
73//! |--------|------|-------------|
74//! | `from_note_id` | TEXT | Source note UUID (references notes.id) |
75//! | `to_note_id` | TEXT | Target note UUID (references notes.id) |
76//!
77//! ### Core Data Epoch Conversion
78//!
79//! Bear uses Apple's Core Data timestamp format (seconds since 2001-01-01 00:00:00 UTC).
80//! This library automatically converts all timestamps to standard SQLite datetime format.
81//!
82//! The conversion is done via a CTE: `unixepoch('2001-01-01')`
83//!
84//! ### Schema Discovery
85//!
86//! The library discovers variable schema elements at initialization:
87//! - Junction table column names (e.g., `Z_5NOTES`, `Z_13TAGS`)
88//! - These numbers may vary across Bear versions
89//!
90//! For full schema details, see the `SCHEMA.md` documentation file.
91//!
92//! ## Example
93//!
94//! ```no_run
95//! use bear_query::{BearDb, NotesQuery, SearchQuery, SortOn};
96//!
97//! # fn main() -> Result<(), bear_query::BearError> {
98//! // Create a handle (no connection opened yet)
99//! let db = BearDb::new()?;
100//!
101//! // Each method opens a connection, queries, and closes
102//! let all_tags = db.tags()?;
103//! let recent_notes = db.notes(NotesQuery::default())?;
104//!
105//! for note in recent_notes {
106//!     let title = note.title();
107//!     if title.is_empty() {
108//!         println!("[Untitled]");
109//!     } else {
110//!         println!("{}", title);
111//!     }
112//! }
113//!
114//! // Search notes by title and/or content
115//! let search_results = db.search(SearchQuery::new("rust"))?;
116//!
117//! // Advanced search with filters
118//! let filtered_results = db.search(
119//!     SearchQuery::new("project")
120//!         .title_only()
121//!         .sort_by(SortOn::Title.asc())
122//!         .limit(20)
123//! )?;
124//! # Ok(())
125//! # }
126//! ```
127
128mod dataframe;
129mod models;
130mod schema;
131
132pub use models::{Note, NoteId, Tag, TagId, TagsMap};
133pub use polars::prelude as polars_prelude;
134
135use models::{note_from_row, tag_from_row};
136use polars::prelude::*;
137use rusqlite::{Connection, OpenFlags};
138use std::collections::HashSet;
139use std::path::PathBuf;
140use std::time::Duration;
141
142use dataframe::query_to_dataframe;
143
144/// Specifies the database location for BearDb.
145///
146/// For production code, use RealPath to connect to Bear's database.
147/// For tests, use InMemory to create an isolated test database.
148#[derive(Debug, Clone)]
149enum DatabasePath {
150  /// Path to Bear's actual database file
151  RealPath(PathBuf),
152  /// In-memory database for testing (only available with cfg(test))
153  #[cfg(test)]
154  InMemory,
155}
156
157impl DatabasePath {
158  /// Opens a connection based on the database path type.
159  /// For RealPath: opens with read-only flags and safety pragmas
160  /// For InMemory: creates an in-memory database with test schema
161  fn open_connection(&self) -> Result<Connection, BearError> {
162    match self {
163      DatabasePath::RealPath(path) => {
164        // Open with maximum read-only protection:
165        // - SQLITE_OPEN_READ_ONLY: Opens in read-only mode
166        // - SQLITE_OPEN_NO_MUTEX: Disables internal mutexes (safe for single-threaded read-only)
167        let conn = Connection::open_with_flags(
168          path,
169          OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
170        )?;
171
172        // Set busy timeout to handle database contention
173        conn.busy_timeout(Duration::from_millis(5000))?;
174
175        // Enable query_only mode as additional safety
176        conn.pragma_update(None, "query_only", "ON")?;
177
178        Ok(conn)
179      }
180      #[cfg(test)]
181      DatabasePath::InMemory => {
182        let conn = Connection::open_in_memory()?;
183        schema::setup_test_schema(&conn)?;
184        Ok(conn)
185      }
186    }
187  }
188}
189
190#[derive(Debug, thiserror::Error)]
191pub enum BearError {
192  #[error("Unable to load users home directory")]
193  NoHomeDirectory,
194  #[error("SQL Error: {source}")]
195  SqlError {
196    #[from]
197    source: rusqlite::Error,
198  },
199  #[error("Polars Error: {source}")]
200  PolarsError {
201    #[from]
202    source: PolarsError,
203  },
204}
205
206/// Query options for filtering notes.
207///
208/// Use this builder to configure how notes are retrieved from Bear.
209/// By default, returns the 10 most recently modified notes, excluding
210/// trashed and archived notes.
211///
212/// # Examples
213///
214/// ```no_run
215/// # use bear_query::{BearDb, NotesQuery};
216/// # fn main() -> Result<(), bear_query::BearError> {
217/// let db = BearDb::new()?;
218///
219/// // Default: 10 most recent notes, exclude trashed/archived
220/// let notes = db.notes(NotesQuery::default())?;
221///
222/// // Get 20 notes
223/// let notes = db.notes(NotesQuery::new().limit(20))?;
224///
225/// // Get all notes including trashed and archived
226/// let notes = db.notes(NotesQuery::new().no_limit().include_all())?;
227///
228/// // Get all non-trashed notes (including archived)
229/// let notes = db.notes(NotesQuery::new().no_limit().include_archived())?;
230/// # Ok(())
231/// # }
232/// ```
233#[derive(Debug, Clone)]
234pub struct NotesQuery {
235  limit: Option<u32>,
236  include_trashed: bool,
237  include_archived: bool,
238}
239
240impl Default for NotesQuery {
241  fn default() -> Self {
242    Self {
243      limit: Some(10),
244      include_trashed: false,
245      include_archived: false,
246    }
247  }
248}
249
250impl NotesQuery {
251  /// Create a new NotesQuery with default settings (limit: 10, exclude trashed and archived)
252  pub fn new() -> Self {
253    Self::default()
254  }
255
256  /// Set a limit on the number of notes to return
257  pub fn limit(
258    mut self,
259    limit: u32,
260  ) -> Self {
261    self.limit = Some(limit);
262    self
263  }
264
265  /// Remove the limit and return all matching notes
266  pub fn no_limit(mut self) -> Self {
267    self.limit = None;
268    self
269  }
270
271  /// Include trashed notes in results
272  pub fn include_trashed(mut self) -> Self {
273    self.include_trashed = true;
274    self
275  }
276
277  /// Include archived notes in results
278  pub fn include_archived(mut self) -> Self {
279    self.include_archived = true;
280    self
281  }
282
283  /// Include both trashed and archived notes in results
284  pub fn include_all(mut self) -> Self {
285    self.include_trashed = true;
286    self.include_archived = true;
287    self
288  }
289}
290
291/// What field to sort by.
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293pub enum SortOn {
294  /// Sort by modification timestamp
295  Modified,
296  /// Sort by creation timestamp
297  Created,
298  /// Sort by note title
299  Title,
300}
301
302impl SortOn {
303  /// Sort in ascending order (oldest/A-Z first)
304  pub fn asc(self) -> SortOrder {
305    SortOrder::Asc(self)
306  }
307
308  /// Sort in descending order (newest/Z-A first)
309  pub fn desc(self) -> SortOrder {
310    SortOrder::Desc(self)
311  }
312}
313
314/// Sort order for search results.
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum SortOrder {
317  /// Ascending order (oldest/A-Z first)
318  Asc(SortOn),
319  /// Descending order (newest/Z-A first)
320  Desc(SortOn),
321}
322
323impl Default for SortOrder {
324  fn default() -> Self {
325    SortOrder::Desc(SortOn::Modified)
326  }
327}
328
329impl SortOrder {
330  fn to_sql(&self) -> &'static str {
331    match self {
332      SortOrder::Desc(SortOn::Modified) => "modified DESC",
333      SortOrder::Asc(SortOn::Modified) => "modified ASC",
334      SortOrder::Desc(SortOn::Created) => "created DESC",
335      SortOrder::Asc(SortOn::Created) => "created ASC",
336      SortOrder::Asc(SortOn::Title) => "title ASC",
337      SortOrder::Desc(SortOn::Title) => "title DESC",
338    }
339  }
340}
341
342/// Query builder for searching notes.
343///
344/// Use this builder to configure note search with flexible filtering, sorting, and limits.
345/// By default, searches both title and content, returns up to 50 results sorted by most
346/// recently modified, and excludes trashed and archived notes.
347///
348/// # Examples
349///
350/// ```no_run
351/// # use bear_query::{BearDb, SearchQuery, SortOn};
352/// # fn main() -> Result<(), bear_query::BearError> {
353/// let db = BearDb::new()?;
354///
355/// // Search in both title and content (default)
356/// let notes = db.search(SearchQuery::new("rust"))?;
357///
358/// // Search only in titles
359/// let notes = db.search(
360///     SearchQuery::new("rust")
361///         .title_only()
362/// )?;
363///
364/// // Search only in content
365/// let notes = db.search(
366///     SearchQuery::new("rust")
367///         .content_only()
368/// )?;
369///
370/// // Complex search with custom options
371/// let notes = db.search(
372///     SearchQuery::new("programming")
373///         .title_only()
374///         .limit(20)
375///         .sort_by(SortOn::Title.asc())
376///         .include_archived()
377/// )?;
378///
379/// // Case-sensitive search
380/// let notes = db.search(
381///     SearchQuery::new("Rust")
382///         .case_sensitive()
383/// )?;
384/// # Ok(())
385/// # }
386/// ```
387#[derive(Debug, Clone)]
388pub struct SearchQuery {
389  query: String,
390  search_title: bool,
391  search_content: bool,
392  case_sensitive: bool,
393  limit: Option<u32>,
394  sort_by: SortOrder,
395  include_trashed: bool,
396  include_archived: bool,
397}
398
399impl SearchQuery {
400  /// Create a new search with the given query string.
401  ///
402  /// By default:
403  /// - Searches both title and content
404  /// - Case-insensitive search
405  /// - Limit: 50 results
406  /// - Sort: Most recently modified first
407  /// - Excludes trashed and archived notes
408  pub fn new(query: impl Into<String>) -> Self {
409    Self {
410      query: query.into(),
411      search_title: true,
412      search_content: true,
413      case_sensitive: false,
414      limit: Some(50),
415      sort_by: SortOrder::default(),
416      include_trashed: false,
417      include_archived: false,
418    }
419  }
420
421  /// Search only in note titles (excludes content)
422  pub fn title_only(mut self) -> Self {
423    self.search_title = true;
424    self.search_content = false;
425    self
426  }
427
428  /// Search only in note content (excludes titles)
429  pub fn content_only(mut self) -> Self {
430    self.search_title = false;
431    self.search_content = true;
432    self
433  }
434
435  /// Search in both title and content (default)
436  pub fn title_and_content(mut self) -> Self {
437    self.search_title = true;
438    self.search_content = true;
439    self
440  }
441
442  /// Enable case-sensitive search (default is case-insensitive)
443  pub fn case_sensitive(mut self) -> Self {
444    self.case_sensitive = true;
445    self
446  }
447
448  /// Set the maximum number of results to return
449  pub fn limit(
450    mut self,
451    limit: u32,
452  ) -> Self {
453    self.limit = Some(limit);
454    self
455  }
456
457  /// Remove the limit and return all matching notes
458  pub fn no_limit(mut self) -> Self {
459    self.limit = None;
460    self
461  }
462
463  /// Set the sort order for results
464  pub fn sort_by(
465    mut self,
466    sort: SortOrder,
467  ) -> Self {
468    self.sort_by = sort;
469    self
470  }
471
472  /// Include trashed notes in results
473  pub fn include_trashed(mut self) -> Self {
474    self.include_trashed = true;
475    self
476  }
477
478  /// Include archived notes in results
479  pub fn include_archived(mut self) -> Self {
480    self.include_archived = true;
481    self
482  }
483
484  /// Include both trashed and archived notes in results
485  pub fn include_all(mut self) -> Self {
486    self.include_trashed = true;
487    self.include_archived = true;
488    self
489  }
490}
491
492/// Handle to Bear's database. All operations use short-lived connections internally.
493pub struct BearDb {
494  db_path: DatabasePath,
495  _metadata: schema::BearDbMetadata,
496  normalizing_cte: String,
497}
498
499impl BearDb {
500  /// Create a new BearDb handle. Opens a temporary connection to discover schema metadata,
501  /// generates normalizing CTEs, then closes the connection.
502  pub fn new() -> Result<Self, BearError> {
503    let home_dir = dirs::home_dir().ok_or(BearError::NoHomeDirectory)?;
504
505    let db_path = home_dir.join(
506      "Library/Group Containers/9K33E3U3T4.net.shinyfrog.bear/Application Data/database.sqlite",
507    );
508
509    Self::new_with_path(DatabasePath::RealPath(db_path))
510  }
511
512  /// Create a new BearDb handle with a specific database path.
513  /// This is primarily for testing with in-memory databases.
514  pub(crate) fn new_with_path(db_path: DatabasePath) -> Result<Self, BearError> {
515    // Open temporary connection to discover metadata
516    let connection = db_path.open_connection()?;
517
518    // Discover schema metadata
519    let metadata = schema::discover_metadata(&connection)?;
520
521    // Generate normalizing CTE based on discovered metadata
522    let normalizing_cte = schema::generate_normalizing_cte(&metadata);
523
524    // Connection is dropped here, closing it
525    drop(connection);
526
527    Ok(BearDb {
528      db_path,
529      _metadata: metadata,
530      normalizing_cte,
531    })
532  }
533
534  /// Opens a short-lived connection, wraps it in a Queryable with normalizing CTEs,
535  /// executes the closure, and closes the connection.
536  fn with_connection<F, R>(
537    &self,
538    f: F,
539  ) -> Result<R, BearError>
540  where
541    F: FnOnce(&Queryable) -> Result<R, BearError>,
542  {
543    // Open connection using DatabasePath's connection handler
544    let connection = self.db_path.open_connection()?;
545
546    // Create Queryable wrapper with normalizing CTE
547    let queryable = Queryable::new(&connection, &self.normalizing_cte);
548
549    // Execute the closure with the queryable
550    // Connection will be automatically closed when it goes out of scope
551    f(&queryable)
552  }
553
554  /// Retrieves all tags from Bear
555  pub fn tags(&self) -> Result<TagsMap, BearError> {
556    self.with_connection(|queryable| {
557      let mut statement = queryable.prepare(
558        r"
559      SELECT
560        id,
561        name,
562        modified
563      FROM tags
564      ORDER BY name ASC",
565      )?;
566
567      let results: rusqlite::Result<Vec<Tag>> = statement.query_map([], tag_from_row)?.collect();
568
569      let tags = results?.into_iter().map(|tag| (tag.id(), tag)).collect();
570
571      Ok(TagsMap { tags })
572    })
573  }
574
575  /// Retrieves a specific note by its ID.
576  ///
577  /// Returns `None` if no note with the given ID exists.
578  ///
579  /// # Examples
580  ///
581  /// ```no_run
582  /// # use bear_query::{BearDb, NoteId};
583  /// # fn main() -> Result<(), bear_query::BearError> {
584  /// let db = BearDb::new()?;
585  ///
586  /// // Look up a note by its UUID
587  /// let note_id = NoteId::new("ABC123-DEF456-...".to_string());
588  /// if let Some(note) = db.note(&note_id)? {
589  ///     println!("Found note: {}", note.title());
590  /// } else {
591  ///     println!("Note not found");
592  /// }
593  /// # Ok(())
594  /// # }
595  /// ```
596  pub fn note(
597    &self,
598    id: &NoteId,
599  ) -> Result<Option<Note>, BearError> {
600    self.with_connection(|queryable| {
601      let mut statement = queryable.prepare(
602        r"
603      SELECT
604        id,
605        core_db_id,
606        title,
607        content,
608        modified,
609        created,
610        is_pinned
611      FROM notes
612      WHERE id = ?",
613      )?;
614
615      let result = statement.query_row([id.as_str()], note_from_row);
616
617      match result {
618        Ok(note) => Ok(Some(note)),
619        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
620        Err(e) => Err(BearError::SqlError { source: e }),
621      }
622    })
623  }
624
625  /// Retrieves notes from Bear, ordered by most recently modified.
626  ///
627  /// # Examples
628  /// ```no_run
629  /// # use bear_query::{BearDb, NotesQuery};
630  /// # fn main() -> Result<(), bear_query::BearError> {
631  /// let db = BearDb::new()?;
632  ///
633  /// // Get 10 most recent notes (default)
634  /// let notes = db.notes(NotesQuery::default())?;
635  ///
636  /// // Get 20 most recent notes
637  /// let notes = db.notes(NotesQuery::new().limit(20))?;
638  ///
639  /// // Get all notes including trashed and archived
640  /// let notes = db.notes(NotesQuery::new().no_limit().include_all())?;
641  /// # Ok(())
642  /// # }
643  /// ```
644  pub fn notes(
645    &self,
646    query: NotesQuery,
647  ) -> Result<Vec<Note>, BearError> {
648    self.with_connection(|queryable| {
649      // Build WHERE clause based on query options
650      let mut where_clauses = Vec::new();
651      if !query.include_trashed {
652        where_clauses.push("is_trashed <> 1");
653      }
654      if !query.include_archived {
655        where_clauses.push("is_archived <> 1");
656      }
657
658      let where_clause = if where_clauses.is_empty() {
659        String::new()
660      } else {
661        format!("WHERE {}", where_clauses.join(" AND "))
662      };
663
664      let limit_clause = query
665        .limit
666        .map(|l| format!("LIMIT {}", l))
667        .unwrap_or_default();
668
669      let query = format!(
670        r"
671      SELECT
672        id,
673        core_db_id,
674        title,
675        content,
676        modified,
677        created,
678        is_pinned
679      FROM notes
680      {}
681      ORDER BY modified DESC
682      {}",
683        where_clause, limit_clause
684      );
685
686      let mut statement = queryable.prepare(&query)?;
687
688      let results: rusqlite::Result<Vec<Note>> = statement.query_map([], note_from_row)?.collect();
689
690      Ok(results?)
691    })
692  }
693
694  /// Searches notes by title and/or content.
695  ///
696  /// Use `SearchQuery` to configure search options including which fields to search,
697  /// sort order, limits, and inclusion of trashed/archived notes.
698  ///
699  /// # Examples
700  /// ```no_run
701  /// # use bear_query::{BearDb, SearchQuery, SortOn};
702  /// # fn main() -> Result<(), bear_query::BearError> {
703  /// let db = BearDb::new()?;
704  ///
705  /// // Simple search in both title and content
706  /// let notes = db.search(SearchQuery::new("rust"))?;
707  ///
708  /// // Search only in titles, sorted alphabetically
709  /// let notes = db.search(
710  ///     SearchQuery::new("project")
711  ///         .title_only()
712  ///         .sort_by(SortOn::Title.asc())
713  /// )?;
714  ///
715  /// // Case-sensitive search in content with custom limit
716  /// let notes = db.search(
717  ///     SearchQuery::new("TODO")
718  ///         .content_only()
719  ///         .case_sensitive()
720  ///         .limit(100)
721  /// )?;
722  /// # Ok(())
723  /// # }
724  /// ```
725  pub fn search(
726    &self,
727    search: SearchQuery,
728  ) -> Result<Vec<Note>, BearError> {
729    self.with_connection(|queryable| {
730      // Build search conditions
731      let mut search_conditions = Vec::new();
732
733      let like_operator = if search.case_sensitive {
734        "GLOB"
735      } else {
736        "LIKE"
737      };
738      let pattern = if search.case_sensitive {
739        format!("*{}*", search.query)
740      } else {
741        format!("%{}%", search.query)
742      };
743
744      if search.search_title {
745        search_conditions.push(format!("title {} ?", like_operator));
746      }
747      if search.search_content {
748        search_conditions.push(format!("content {} ?", like_operator));
749      }
750
751      // If neither title nor content is selected, search nothing (return empty)
752      if search_conditions.is_empty() {
753        return Ok(Vec::new());
754      }
755
756      let search_clause = format!("({})", search_conditions.join(" OR "));
757
758      // Build WHERE clause with filters
759      let mut where_clauses = vec![search_clause];
760
761      if !search.include_trashed {
762        where_clauses.push("is_trashed <> 1".to_string());
763      }
764      if !search.include_archived {
765        where_clauses.push("is_archived <> 1".to_string());
766      }
767
768      let where_clause = format!("WHERE {}", where_clauses.join(" AND "));
769
770      let limit_clause = search
771        .limit
772        .map(|l| format!("LIMIT {}", l))
773        .unwrap_or_default();
774
775      let query_sql = format!(
776        r"
777      SELECT
778        id,
779        core_db_id,
780        title,
781        content,
782        modified,
783        created,
784        is_pinned
785      FROM notes
786      {}
787      ORDER BY {}
788      {}",
789        where_clause,
790        search.sort_by.to_sql(),
791        limit_clause
792      );
793
794      let mut statement = queryable.prepare(&query_sql)?;
795
796      // Bind the pattern for each search condition
797      let results: rusqlite::Result<Vec<Note>> = if search.search_title && search.search_content {
798        // Both title and content: bind pattern twice
799        statement
800          .query_map([pattern.as_str(), pattern.as_str()], note_from_row)?
801          .collect()
802      } else {
803        // Only one field: bind pattern once
804        statement
805          .query_map([pattern.as_str()], note_from_row)?
806          .collect()
807      };
808
809      Ok(results?)
810    })
811  }
812
813  /// Retrieves all notes linked from the specified note
814  pub fn note_links(
815    &self,
816    from: &NoteId,
817  ) -> Result<Vec<Note>, BearError> {
818    self.with_connection(|queryable| {
819      let mut statement = queryable.prepare(
820        r"
821      SELECT
822        n.id,
823        n.core_db_id,
824        n.title,
825        n.content,
826        n.modified,
827        n.created,
828        n.is_pinned
829      FROM notes as n
830      INNER JOIN note_links as nl ON nl.to_note_id = n.id
831      WHERE n.is_trashed <> 1 AND n.is_archived <> 1 AND nl.from_note_id = ?
832      ORDER BY n.modified DESC",
833      )?;
834
835      let results: rusqlite::Result<Vec<Note>> = statement
836        .query_map([from.as_str()], note_from_row)?
837        .collect();
838
839      Ok(results?)
840    })
841  }
842
843  /// Retrieves all tag IDs associated with the specified note
844  pub fn note_tags(
845    &self,
846    from: &NoteId,
847  ) -> Result<HashSet<TagId>, BearError> {
848    self.with_connection(|queryable| {
849      let mut statement = queryable.prepare(
850        r"
851      SELECT
852        nt.tag_id
853      FROM note_tags nt
854      WHERE nt.note_id = ?",
855      )?;
856
857      let results: rusqlite::Result<HashSet<TagId>> = statement
858        .query_map([from.as_str()], |row| row.get("tag_id"))?
859        .collect();
860
861      Ok(results?)
862    })
863  }
864
865  /// Execute a generic SQL SELECT query and return results as a Polars DataFrame.
866  ///
867  /// The query automatically has the normalizing CTEs prepended, so you can query
868  /// against clean table names: `notes`, `tags`, `note_tags`, `note_links`.
869  ///
870  /// # Safety
871  /// This method trusts the read-only connection flags to prevent writes. Only SELECT
872  /// queries should be used, though this is not enforced by the library.
873  ///
874  /// # Examples
875  /// ```no_run
876  /// # use bear_query::BearDb;
877  /// # fn main() -> Result<(), bear_query::BearError> {
878  /// let db = BearDb::new()?;
879  ///
880  /// // Query normalized tables
881  /// let df = db.query("SELECT title, modified FROM notes LIMIT 5")?;
882  ///
883  /// // Join tables
884  /// let df = db.query(r"
885  ///   SELECT n.title, t.name as tag_name
886  ///   FROM notes n
887  ///   JOIN note_tags nt ON n.id = nt.note_id
888  ///   JOIN tags t ON nt.tag_id = t.id
889  ///   WHERE n.is_trashed = 0
890  ///   LIMIT 10
891  /// ")?;
892  ///
893  /// println!("{}", df);
894  /// # Ok(())
895  /// # }
896  /// ```
897  pub fn query(
898    &self,
899    sql: &str,
900  ) -> Result<DataFrame, BearError> {
901    self.with_connection(|queryable| query_to_dataframe(queryable, sql))
902  }
903}
904
905/// A wrapper around a database connection that automatically applies normalizing CTEs to queries.
906/// This abstracts away Bear's Core Data quirks (Z_ prefixes, numbered columns, epoch timestamps).
907pub struct Queryable<'a> {
908  conn: &'a Connection,
909  normalizing_cte: &'a str,
910}
911
912impl<'a> Queryable<'a> {
913  /// Creates a new Queryable from a connection and pre-generated CTE string
914  fn new(
915    conn: &'a Connection,
916    normalizing_cte: &'a str,
917  ) -> Self {
918    Self {
919      conn,
920      normalizing_cte,
921    }
922  }
923
924  /// Test-only constructor for creating Queryable in tests
925  ///
926  /// This is pub(crate) so tests in other modules can create Queryables
927  #[cfg(test)]
928  pub(crate) fn new_for_test(
929    conn: &'a Connection,
930    normalizing_cte: &'a str,
931  ) -> Self {
932    Self::new(conn, normalizing_cte)
933  }
934
935  /// Prepares a statement with the normalizing CTE automatically prepended.
936  /// The user's SQL should query against normalized table names (notes, tags, note_tags, note_links).
937  pub fn prepare(
938    &self,
939    user_sql: &str,
940  ) -> rusqlite::Result<rusqlite::Statement<'a>> {
941    let full_sql = format!("{}\n{}", self.normalizing_cte, user_sql);
942    self.conn.prepare(&full_sql)
943  }
944}
945
946#[cfg(test)]
947mod tests {
948  use super::*;
949
950  /// Integration test demonstrating BearDb with in-memory database
951  #[test]
952  fn test_beardb_with_inmemory() {
953    // Create a BearDb with an in-memory database (automatically sets up schema)
954    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
955
956    // Test the typed API
957    let tags = db.tags().unwrap();
958    assert_eq!(tags.count(), 3); // Should have 3 tags from test data (including unmodified-tag)
959
960    let notes = db.notes(NotesQuery::default()).unwrap();
961    assert_eq!(notes.len(), 4); // default() excludes trashed, so 4 notes (not 5 - one is trashed)
962
963    // Test filtering - include all notes
964    let all_notes = db
965      .notes(NotesQuery::new().include_all().no_limit())
966      .unwrap();
967    assert_eq!(all_notes.len(), 5); // 5 notes total including trashed
968
969    // Test the generic SQL query API
970    let df = db
971      .query("SELECT id, title FROM notes WHERE is_trashed = 0")
972      .unwrap();
973    assert_eq!(df.height(), 4); // 4 non-trashed notes
974    assert_eq!(df.width(), 2); // 2 columns (id, title)
975
976    // Test aggregation
977    let df = db.query("SELECT COUNT(*) as count FROM notes").unwrap();
978    assert_eq!(df.height(), 1);
979    assert_eq!(df.width(), 1);
980
981    // Verify the count column is an integer (not string)
982    let series = df.column("count").unwrap();
983    let value = series.get(0).unwrap();
984    match value {
985      AnyValue::Int64(n) => assert_eq!(n, 5),
986      _ => panic!("Expected Int64, got: {:?}", value),
987    }
988
989    // Test join query
990    let df = db
991      .query(
992        r"
993      SELECT n.title, t.name as tag_name
994      FROM notes n
995      JOIN note_tags nt ON n.id = nt.note_id
996      JOIN tags t ON nt.tag_id = t.id
997    ",
998      )
999      .unwrap();
1000    assert_eq!(df.height(), 2); // 2 note-tag relationships
1001  }
1002
1003  /// Test handling of notes with empty title (not NULL, but empty string)
1004  #[test]
1005  fn test_note_with_empty_title() {
1006    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1007
1008    // Get all notes including the one with empty title (id=4)
1009    let notes = db
1010      .notes(NotesQuery::new().no_limit().include_all())
1011      .unwrap();
1012
1013    // Find the note with empty title
1014    let note_with_empty_title = notes
1015      .iter()
1016      .find(|n| n.title().is_empty())
1017      .expect("Should have a note with empty title");
1018
1019    // Verify the note exists and has empty title
1020    assert_eq!(note_with_empty_title.title(), "");
1021
1022    // Verify other fields are still accessible
1023    assert!(note_with_empty_title.content().is_some());
1024    assert_eq!(
1025      note_with_empty_title.content().unwrap(),
1026      "Content with empty title"
1027    );
1028  }
1029
1030  /// Test handling of notes with NULL content
1031  #[test]
1032  fn test_note_with_null_content() {
1033    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1034
1035    let notes = db
1036      .notes(NotesQuery::new().no_limit().include_all())
1037      .unwrap();
1038
1039    // Find the note with NULL content (id=5)
1040    let note_with_null_content = notes
1041      .iter()
1042      .find(|n| n.content().is_none())
1043      .expect("Should have a note with NULL content");
1044
1045    // Verify the note has a title but no content
1046    assert_eq!(note_with_null_content.title(), "Empty Note");
1047    assert!(note_with_null_content.content().is_none());
1048  }
1049
1050  /// Test that all notes have unique_id
1051  #[test]
1052  fn test_all_notes_have_unique_id() {
1053    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1054
1055    let notes = db
1056      .notes(NotesQuery::new().no_limit().include_all())
1057      .unwrap();
1058
1059    // All notes should have unique_id (never NULL)
1060    for note in notes {
1061      let uuid = note.id();
1062      assert!(!uuid.as_str().is_empty(), "unique_id should never be empty");
1063    }
1064  }
1065
1066  /// Test handling of tags with NULL modified date
1067  #[test]
1068  fn test_tag_with_null_modified() {
1069    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1070
1071    let tags = db.tags().unwrap();
1072
1073    // Find tag with NULL modified date (id=3, name="unmodified-tag")
1074    let unmodified_tag = tags
1075      .iter()
1076      .find(|t| t.modified().is_none())
1077      .expect("Should have a tag with NULL modified date");
1078
1079    // Verify the tag has a name but no modified date
1080    assert_eq!(unmodified_tag.name(), Some("unmodified-tag"));
1081    assert!(unmodified_tag.modified().is_none());
1082  }
1083
1084  /// Test that tag count includes tags with NULL modified dates
1085  #[test]
1086  fn test_tags_count_includes_null_modified() {
1087    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1088
1089    let tags = db.tags().unwrap();
1090
1091    // Should have 3 tags total (work, personal, unmodified-tag)
1092    assert_eq!(tags.count(), 3);
1093  }
1094
1095  /// Test querying for notes with empty title using generic query API
1096  #[test]
1097  fn test_query_with_empty_title() {
1098    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1099
1100    // Query for notes with empty title using generic query API
1101    let df = db
1102      .query("SELECT id, title, content FROM notes WHERE title = ''")
1103      .unwrap();
1104
1105    assert_eq!(df.height(), 1); // Should find 1 note with empty title
1106  }
1107
1108  /// Test querying for notes with NULL content
1109  #[test]
1110  fn test_query_with_null_content() {
1111    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1112
1113    // Query for notes with NULL content
1114    let df = db
1115      .query("SELECT id, title, content FROM notes WHERE content IS NULL")
1116      .unwrap();
1117
1118    assert_eq!(df.height(), 1); // Should find 1 note with NULL content
1119  }
1120
1121  /// Test that Tags::names handles NULL tag names gracefully
1122  #[test]
1123  fn test_note_tags_names_handles_null() {
1124    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1125
1126    let tags = db.tags().unwrap();
1127
1128    // Get all tag IDs
1129    let all_tag_ids: HashSet<_> = tags.iter().map(|t| t.id()).collect();
1130
1131    // Get names - should handle tags with NULL names gracefully
1132    let names = tags.names(&all_tag_ids);
1133
1134    // Should have 3 valid names (all our test tags have names)
1135    assert_eq!(names.len(), 3);
1136    assert!(names.contains("work"));
1137    assert!(names.contains("personal"));
1138    assert!(names.contains("unmodified-tag"));
1139  }
1140
1141  /// Test that all notes have valid IDs and required fields are always present
1142  #[test]
1143  fn test_all_notes_have_valid_id() {
1144    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1145
1146    let notes = db
1147      .notes(NotesQuery::new().no_limit().include_all())
1148      .unwrap();
1149
1150    // Every note should have a valid id (primary key is never NULL)
1151    for note in notes {
1152      let _id = note.id(); // This should never panic
1153
1154      // Timestamps should always be present
1155      let _created = note.created();
1156      let _modified = note.modified();
1157
1158      // Boolean should always be present
1159      let _is_pinned = note.is_pinned();
1160    }
1161  }
1162
1163  /// Test that all tags have valid IDs
1164  #[test]
1165  fn test_all_tags_have_valid_id() {
1166    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1167
1168    let tags = db.tags().unwrap();
1169
1170    // Every tag should have a valid id (primary key is never NULL)
1171    for tag in tags.iter() {
1172      let _id = tag.id(); // This should never panic
1173    }
1174  }
1175
1176  /// Test that note_links handles notes with NULL fields gracefully
1177  #[test]
1178  fn test_note_links_with_null_safe_notes() {
1179    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1180
1181    // Get the first note's ID
1182    let notes = db.notes(NotesQuery::new().limit(1)).unwrap();
1183    let first_note = &notes[0];
1184
1185    // Query note links - should handle notes with NULL fields gracefully
1186    let linked_notes = db.note_links(first_note.id()).unwrap();
1187
1188    // All linked notes should be queryable even if they have NULL fields
1189    for linked_note in linked_notes {
1190      let _id = linked_note.id();
1191      let _title = linked_note.title();
1192      let _content = linked_note.content(); // May be None, but shouldn't error
1193    }
1194  }
1195
1196  /// Test note() with existing note
1197  #[test]
1198  fn test_get_note_by_id_existing() {
1199    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1200
1201    // Get a note using the notes() API
1202    let notes = db.notes(NotesQuery::new().limit(1)).unwrap();
1203    let expected_note = &notes[0];
1204    let note_id = expected_note.id();
1205
1206    // Look it up by ID
1207    let found_note = db.note(note_id).unwrap();
1208
1209    assert!(found_note.is_some());
1210    let found_note = found_note.unwrap();
1211
1212    // Verify it's the same note
1213    assert_eq!(found_note.id(), expected_note.id());
1214    assert_eq!(found_note.title(), expected_note.title());
1215    assert_eq!(found_note.content(), expected_note.content());
1216  }
1217
1218  /// Test note() with non-existent note
1219  #[test]
1220  fn test_get_note_by_id_not_found() {
1221    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1222
1223    // Try to get a note with an ID that doesn't exist
1224    let note_id = NoteId::new("nonexistent-uuid".to_string());
1225    let result = db.note(&note_id).unwrap();
1226
1227    assert!(result.is_none());
1228  }
1229
1230  /// Test note() with note that has NULL content
1231  #[test]
1232  fn test_get_note_by_id_with_null_content() {
1233    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1234
1235    // Find a note with NULL content
1236    let notes = db
1237      .notes(NotesQuery::new().no_limit().include_all())
1238      .unwrap();
1239    let null_content_note = notes.iter().find(|n| n.content().is_none()).unwrap();
1240    let note_id = null_content_note.id();
1241
1242    // Look it up by ID
1243    let found_note = db.note(note_id).unwrap();
1244
1245    assert!(found_note.is_some());
1246    let found_note = found_note.unwrap();
1247
1248    // Verify it has a title but no content
1249    assert_eq!(found_note.title(), "Empty Note");
1250    assert!(found_note.content().is_none());
1251  }
1252
1253  /// Test note() method
1254  #[test]
1255  fn test_note_method() {
1256    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1257
1258    // Get a note using the notes() API
1259    let notes = db.notes(NotesQuery::new().limit(1)).unwrap();
1260    let expected_note = &notes[0];
1261    let note_id = expected_note.id();
1262
1263    // Look it up using the short form note() method
1264    let found_note = db.note(note_id).unwrap();
1265
1266    assert!(found_note.is_some());
1267    let found_note = found_note.unwrap();
1268
1269    // Verify it's the same note
1270    assert_eq!(found_note.id(), expected_note.id());
1271    assert_eq!(found_note.title(), expected_note.title());
1272    assert_eq!(found_note.content(), expected_note.content());
1273
1274    // Test with non-existent ID
1275    let nonexistent_id = NoteId::new("nonexistent-uuid".to_string());
1276    let result = db.note(&nonexistent_id).unwrap();
1277    assert!(result.is_none());
1278  }
1279
1280  /// Test basic search in both title and content
1281  #[test]
1282  fn test_search_basic() {
1283    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1284
1285    // Search for "first" which appears in title
1286    let results = db.search(SearchQuery::new("first")).unwrap();
1287    assert_eq!(results.len(), 1);
1288    assert_eq!(results[0].title(), "First Note");
1289
1290    // Search for "second" which appears in title and content
1291    let results = db.search(SearchQuery::new("second")).unwrap();
1292    assert_eq!(results.len(), 1);
1293    assert_eq!(results[0].title(), "Second Note");
1294
1295    // Search for "Content" which appears in multiple notes
1296    let results = db.search(SearchQuery::new("Content")).unwrap();
1297    assert!(results.len() >= 2);
1298  }
1299
1300  /// Test search with title_only filter
1301  #[test]
1302  fn test_search_title_only() {
1303    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1304
1305    // Search for "Note" in titles only
1306    let results = db.search(SearchQuery::new("Note").title_only()).unwrap();
1307
1308    // Should find "First Note", "Second Note", and "Empty Note" (not "Trashed Note" as it's excluded by default)
1309    assert_eq!(results.len(), 3);
1310
1311    for note in &results {
1312      assert!(note.title().contains("Note"));
1313    }
1314  }
1315
1316  /// Test search with content_only filter
1317  #[test]
1318  fn test_search_content_only() {
1319    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1320
1321    // Search for "Content" in content only
1322    let results = db
1323      .search(SearchQuery::new("Content").content_only())
1324      .unwrap();
1325
1326    // Should find notes with "Content" in their content field
1327    assert!(results.len() >= 2);
1328
1329    for note in &results {
1330      if let Some(content) = note.content() {
1331        assert!(content.contains("Content"));
1332      }
1333    }
1334  }
1335
1336  /// Test search with case sensitivity
1337  #[test]
1338  fn test_search_case_sensitive() {
1339    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1340
1341    // Case-insensitive search (default) for "FIRST"
1342    let results = db.search(SearchQuery::new("FIRST")).unwrap();
1343    assert_eq!(results.len(), 1);
1344
1345    // Case-sensitive search for "FIRST" should find nothing
1346    let results = db
1347      .search(SearchQuery::new("FIRST").case_sensitive())
1348      .unwrap();
1349    assert_eq!(results.len(), 0);
1350
1351    // Case-sensitive search for "First" should find the note
1352    let results = db
1353      .search(SearchQuery::new("First").case_sensitive())
1354      .unwrap();
1355    assert_eq!(results.len(), 1);
1356  }
1357
1358  /// Test search with limits
1359  #[test]
1360  fn test_search_with_limit() {
1361    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1362
1363    // Search for common term with limit
1364    let results = db.search(SearchQuery::new("note").limit(2)).unwrap();
1365    assert!(results.len() <= 2);
1366
1367    // Search with no limit
1368    let results = db.search(SearchQuery::new("note").no_limit()).unwrap();
1369    assert!(results.len() >= 2);
1370  }
1371
1372  /// Test search with sorting
1373  #[test]
1374  fn test_search_with_sorting() {
1375    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1376
1377    // Search sorted by title ascending
1378    let results = db
1379      .search(
1380        SearchQuery::new("Note")
1381          .title_only()
1382          .sort_by(SortOn::Title.asc()),
1383      )
1384      .unwrap();
1385
1386    assert!(results.len() >= 2);
1387
1388    // Verify alphabetical order
1389    for i in 0..results.len() - 1 {
1390      assert!(results[i].title() <= results[i + 1].title());
1391    }
1392
1393    // Search sorted by title descending
1394    let results = db
1395      .search(
1396        SearchQuery::new("Note")
1397          .title_only()
1398          .sort_by(SortOn::Title.desc()),
1399      )
1400      .unwrap();
1401
1402    assert!(results.len() >= 2);
1403
1404    // Verify reverse alphabetical order
1405    for i in 0..results.len() - 1 {
1406      assert!(results[i].title() >= results[i + 1].title());
1407    }
1408  }
1409
1410  /// Test search excluding trashed notes (default behavior)
1411  #[test]
1412  fn test_search_excludes_trashed_by_default() {
1413    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1414
1415    // Default search should exclude trashed
1416    let results = db.search(SearchQuery::new("Trashed")).unwrap();
1417    assert_eq!(results.len(), 0);
1418
1419    // Including trashed should find it
1420    let results = db
1421      .search(SearchQuery::new("Trashed").include_trashed())
1422      .unwrap();
1423    assert_eq!(results.len(), 1);
1424    assert_eq!(results[0].title(), "Trashed Note");
1425  }
1426
1427  /// Test search with include_all
1428  #[test]
1429  fn test_search_include_all() {
1430    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1431
1432    // Search with all notes included
1433    let results = db.search(SearchQuery::new("Note").include_all()).unwrap();
1434
1435    // Should include trashed notes too
1436    let has_trashed = results.iter().any(|n| n.title() == "Trashed Note");
1437    assert!(has_trashed);
1438  }
1439
1440  /// Test search with empty results
1441  #[test]
1442  fn test_search_no_results() {
1443    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1444
1445    let results = db.search(SearchQuery::new("nonexistent_term_xyz")).unwrap();
1446    assert_eq!(results.len(), 0);
1447  }
1448
1449  /// Test search handles notes with NULL content gracefully
1450  #[test]
1451  fn test_search_with_null_content() {
1452    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1453
1454    // Search for "Empty Note" in title only to find the note with NULL content
1455    let results = db
1456      .search(SearchQuery::new("Empty Note").title_only())
1457      .unwrap();
1458    assert_eq!(results.len(), 1);
1459    assert_eq!(results[0].title(), "Empty Note");
1460    assert!(results[0].content().is_none());
1461  }
1462
1463  /// Test search handles notes with empty title gracefully
1464  #[test]
1465  fn test_search_with_empty_title() {
1466    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1467
1468    // Search for content in a note with empty title
1469    let results = db
1470      .search(SearchQuery::new("empty title").content_only())
1471      .unwrap();
1472    assert_eq!(results.len(), 1);
1473    assert_eq!(results[0].title(), "");
1474  }
1475
1476  /// Test complex search query with multiple filters
1477  #[test]
1478  fn test_search_complex_query() {
1479    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1480
1481    // Complex search: title only, sorted alphabetically, limited, include all
1482    let results = db
1483      .search(
1484        SearchQuery::new("Note")
1485          .title_only()
1486          .sort_by(SortOn::Title.asc())
1487          .limit(2)
1488          .include_all(),
1489      )
1490      .unwrap();
1491
1492    assert!(results.len() <= 2);
1493
1494    // Verify results are sorted
1495    if results.len() == 2 {
1496      assert!(results[0].title() <= results[1].title());
1497    }
1498  }
1499
1500  /// Test that SearchQuery builder methods are chainable
1501  #[test]
1502  fn test_search_query_builder_chaining() {
1503    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1504
1505    // Build a complex query with method chaining
1506    let query = SearchQuery::new("note")
1507      .title_only()
1508      .case_sensitive()
1509      .limit(10)
1510      .sort_by(SortOn::Modified.desc())
1511      .include_archived();
1512
1513    // Just verify it compiles and runs
1514    let _results = db.search(query).unwrap();
1515  }
1516
1517  /// Test search with different SortOrder variants
1518  #[test]
1519  fn test_search_all_sort_orders() {
1520    let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1521
1522    // Test all sort order variants compile and run
1523    let orders = vec![
1524      SortOn::Modified.desc(),
1525      SortOn::Modified.asc(),
1526      SortOn::Created.desc(),
1527      SortOn::Created.asc(),
1528      SortOn::Title.asc(),
1529      SortOn::Title.desc(),
1530    ];
1531
1532    for order in orders {
1533      let _results = db.search(SearchQuery::new("Note").sort_by(order)).unwrap();
1534    }
1535  }
1536}