use rusqlite::Connection;
use crate::BearError;
#[cfg(test)]
pub fn setup_test_schema(conn: &Connection) -> Result<(), BearError> {
conn
.execute_batch(
r"
CREATE TABLE ZSFNOTE (
Z_PK INTEGER PRIMARY KEY,
ZUNIQUEIDENTIFIER TEXT,
ZTITLE TEXT,
ZTEXT TEXT,
ZMODIFICATIONDATE REAL,
ZCREATIONDATE REAL,
ZPINNED INTEGER,
ZTRASHED INTEGER,
ZARCHIVED INTEGER
);
CREATE TABLE ZSFNOTETAG (
Z_PK INTEGER PRIMARY KEY,
ZTITLE TEXT,
ZMODIFICATIONDATE REAL
);
CREATE TABLE Z_5TAGS (
Z_5NOTES INTEGER,
Z_13TAGS INTEGER
);
CREATE TABLE ZSFNOTEBACKLINK (
ZLINKEDBY INTEGER,
ZLINKINGTO INTEGER
);
-- Insert sample test data
-- Core Data epoch: 2001-01-01, so timestamp 0 = 2001-01-01
INSERT INTO ZSFNOTE (Z_PK, ZUNIQUEIDENTIFIER, ZTITLE, ZTEXT, ZMODIFICATIONDATE, ZCREATIONDATE, ZPINNED, ZTRASHED, ZARCHIVED)
VALUES
(1, 'note-uuid-1', 'First Note', 'Content of first note', 0, 0, 0, 0, 0),
(2, 'note-uuid-2', 'Second Note', 'Content of second note', 31536000, 31536000, 1, 0, 0),
(3, 'note-uuid-3', 'Trashed Note', 'This is trashed', 0, 0, 0, 1, 0),
-- Note with empty title (but not NULL - Bear doesn't allow NULL titles)
(4, 'note-uuid-4', '', 'Content with empty title', 0, 0, 0, 0, 0),
-- Note with NULL content (empty note)
(5, 'note-uuid-5', 'Empty Note', NULL, 0, 0, 0, 0, 0);
INSERT INTO ZSFNOTETAG (Z_PK, ZTITLE, ZMODIFICATIONDATE)
VALUES
(1, 'work', 0),
(2, 'personal', 0),
-- Tag with NULL modified date
(3, 'unmodified-tag', NULL);
INSERT INTO Z_5TAGS (Z_5NOTES, Z_13TAGS)
VALUES
(1, 1),
(2, 2);
INSERT INTO ZSFNOTEBACKLINK (ZLINKEDBY, ZLINKINGTO)
VALUES
(1, 2);
",
)
.map_err(|e| BearError::SqlError { source: e })?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct BearDbMetadata {
pub junction_table_name: String,
pub junction_notes_column: String,
pub junction_tags_column: String,
}
pub fn discover_metadata(conn: &Connection) -> Result<BearDbMetadata, BearError> {
let junction_table_name = find_junction_table(conn)?;
let query = format!("PRAGMA table_info({})", junction_table_name);
let mut stmt = conn.prepare(&query)?;
let columns: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>("name"))?
.collect::<Result<Vec<_>, _>>()?;
let junction_notes_column = columns
.iter()
.find(|name| name.ends_with("NOTES"))
.ok_or_else(|| rusqlite::Error::QueryReturnedNoRows)?
.clone();
let junction_tags_column = columns
.iter()
.find(|name| name.ends_with("TAGS"))
.ok_or_else(|| rusqlite::Error::QueryReturnedNoRows)?
.clone();
Ok(BearDbMetadata {
junction_table_name,
junction_notes_column,
junction_tags_column,
})
}
fn find_junction_table(conn: &Connection) -> Result<String, BearError> {
let mut stmt = conn.prepare(
r"
SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name GLOB 'Z_[0-9]*TAGS'
LIMIT 1
",
)?;
let table_name: String = stmt.query_row([], |row| row.get(0))?;
Ok(table_name)
}
pub fn generate_normalizing_cte(metadata: &BearDbMetadata) -> String {
format!(
r#"
WITH
core_data AS (
SELECT unixepoch('2001-01-01') as epoch
),
notes AS (
SELECT
n.ZUNIQUEIDENTIFIER as id,
n.Z_PK as core_db_id,
n.ZTITLE as title,
n.ZTEXT as content,
datetime(n.ZMODIFICATIONDATE + cd.epoch, 'unixepoch') as modified,
datetime(n.ZCREATIONDATE + cd.epoch, 'unixepoch') as created,
n.ZPINNED as is_pinned,
n.ZTRASHED as is_trashed,
n.ZARCHIVED as is_archived
FROM ZSFNOTE as n, core_data as cd
),
tags AS (
SELECT
t.Z_PK as id,
t.ZTITLE as name,
datetime(t.ZMODIFICATIONDATE + cd.epoch, 'unixepoch') as modified
FROM ZSFNOTETAG as t, core_data as cd
),
note_tags AS (
SELECT
(SELECT n.ZUNIQUEIDENTIFIER FROM ZSFNOTE n WHERE n.Z_PK = nt.{}) as note_id,
nt.{} as tag_id
FROM {} as nt
),
note_links AS (
SELECT
(SELECT n.ZUNIQUEIDENTIFIER FROM ZSFNOTE n WHERE n.Z_PK = nl.ZLINKEDBY) as from_note_id,
(SELECT n.ZUNIQUEIDENTIFIER FROM ZSFNOTE n WHERE n.Z_PK = nl.ZLINKINGTO) as to_note_id
FROM ZSFNOTEBACKLINK as nl
)
"#,
metadata.junction_notes_column, metadata.junction_tags_column, metadata.junction_table_name
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_discover_metadata_with_test_schema() {
let conn = Connection::open_in_memory().unwrap();
conn
.execute_batch(
r"
CREATE TABLE Z_5TAGS (
Z_5NOTES INTEGER,
Z_13TAGS INTEGER
);
",
)
.unwrap();
let metadata = discover_metadata(&conn).unwrap();
assert_eq!(metadata.junction_table_name, "Z_5TAGS");
assert_eq!(metadata.junction_notes_column, "Z_5NOTES");
assert_eq!(metadata.junction_tags_column, "Z_13TAGS");
}
#[test]
fn test_discover_metadata_with_different_numbers() {
let conn = Connection::open_in_memory().unwrap();
conn
.execute_batch(
r"
CREATE TABLE Z_7TAGS (
Z_7NOTES INTEGER,
Z_15TAGS INTEGER
);
",
)
.unwrap();
let metadata = discover_metadata(&conn).unwrap();
assert_eq!(metadata.junction_table_name, "Z_7TAGS");
assert_eq!(metadata.junction_notes_column, "Z_7NOTES");
assert_eq!(metadata.junction_tags_column, "Z_15TAGS");
}
#[test]
fn test_generate_normalizing_cte() {
let metadata = BearDbMetadata {
junction_table_name: "Z_5TAGS".to_string(),
junction_notes_column: "Z_5NOTES".to_string(),
junction_tags_column: "Z_13TAGS".to_string(),
};
let cte = generate_normalizing_cte(&metadata);
assert!(cte.contains("FROM Z_5TAGS as nt"));
assert!(cte.contains("nt.Z_5NOTES")); assert!(cte.contains("nt.Z_13TAGS")); assert!(cte.contains("ZUNIQUEIDENTIFIER as id")); assert!(cte.contains("Z_PK as core_db_id")); }
}