notion_into_sqlite/
sqlite.rs

1use std::{collections::HashMap, fs, path::Path};
2
3use crate::{
4    notion_database::{NotionDatabaseSchema, NotionPropertyType},
5    notion_pages::{NotionPage, NotionPropertyValue},
6};
7use anyhow::{anyhow, Result};
8use rusqlite::{params, params_from_iter, Connection};
9
10pub static PAGE_METADATA_TABLE: &str = "page_metadata";
11pub static PAGE_PROPERTIES_TABLE: &str = "pages";
12pub static PAGE_ID_COLUMN: &str = "page_id";
13
14/// Resolve SQLite's column name from Notion's property name
15struct ColumnNames {
16    hash: HashMap<String, String>,
17}
18impl ColumnNames {
19    fn new(schema: &NotionDatabaseSchema) -> ColumnNames {
20        let mut hash = HashMap::new();
21        for property in schema.properties.values() {
22            let column = property.name.replace('"', "\"\"");
23            hash.insert(property.name.to_string(), column);
24        }
25        ColumnNames { hash }
26    }
27
28    /// Resolve SQLite's column name
29    fn resolve(&self, notion_property_name: &str) -> &str {
30        self.hash.get(notion_property_name).unwrap()
31    }
32}
33
34pub struct Sqlite<'a> {
35    pub conn: Connection,
36    pub schema: &'a NotionDatabaseSchema,
37    column_names: ColumnNames,
38}
39impl Sqlite<'_> {
40    pub fn new<'a>(path: &str, schema: &'a NotionDatabaseSchema) -> Result<Sqlite<'a>> {
41        let conn = Connection::open(path)?;
42        let column_names = ColumnNames::new(schema);
43        Ok(Sqlite {
44            conn,
45            schema,
46            column_names,
47        })
48    }
49
50    /// Check if database file can be created
51    pub fn validate_database_path(path: &str) -> Result<()> {
52        if Path::new(path).exists() {
53            return Err(anyhow!("{} already exists", path));
54        }
55
56        let conn = Connection::open(path)?;
57        match conn.close() {
58            Ok(_) => {
59                // Delete file created by the connection because Connection::open() is just used for validation
60                fs::remove_file(path).ok();
61                Ok(())
62            }
63            Err((_, err)) => Err(anyhow!(err.to_string())),
64        }
65    }
66
67    pub fn create_tables(&self) -> Result<()> {
68        // Create page properties table
69        let table_definition = self.table_definitin_from();
70        let sql = format!(
71            "CREATE TABLE {table_name} (
72                {id_column} TEXT PRIMARY KEY,
73                {definition}
74            )",
75            table_name = PAGE_PROPERTIES_TABLE,
76            id_column = PAGE_ID_COLUMN,
77            definition = table_definition,
78        );
79        debug!("{}", sql);
80        self.conn.execute(&sql, [])?;
81
82        // Create page metadata table
83        let sql = format!(
84            "CREATE TABLE {table_name} (
85                id TEXT PRIMARY KEY,
86                url TEXT,
87                created_time TEXT,
88                created_by JSON,
89                last_edited_time TEXT,
90                last_edited_by JSON,
91                archived BOOLEAN
92            )",
93            table_name = PAGE_METADATA_TABLE,
94        );
95        debug!("{}", sql);
96        self.conn.execute(&sql, [])?;
97        Ok(())
98    }
99
100    pub fn insert(&self, page: &NotionPage) -> Result<()> {
101        // Insert properties of page
102        let mut property_names = vec![PAGE_ID_COLUMN];
103        for name in page.properties.keys() {
104            property_names.push(name);
105        }
106        let sql = self.create_insert_sql_for(&property_names);
107        debug!("{}", sql);
108        let page_id = NotionPropertyValue::Text(page.id.clone());
109        let sql_params = params_from_iter(property_names.iter().map(|&column| {
110            if column == PAGE_ID_COLUMN {
111                &page_id
112            } else {
113                page.properties.get(column).unwrap()
114            }
115        }));
116        debug!("Parameters: {:?}", sql_params);
117        self.conn.execute(&sql, sql_params)?;
118
119        // Insert page metadata
120        let sql = format!(
121            "INSERT INTO {table_name} (
122                id,
123                url,
124                created_time,
125                created_by,
126                last_edited_time,
127                last_edited_by,
128                archived
129            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
130            table_name = PAGE_METADATA_TABLE,
131        );
132        let sql_params = params![
133            page.id,
134            page.url,
135            page.created_time,
136            page.created_by.to_string(),
137            page.last_edited_time,
138            page.last_edited_by.to_string(),
139            page.archived
140        ];
141        self.conn.execute(&sql, sql_params)?;
142
143        Ok(())
144    }
145
146    /// Get table definistion string from the schema object.
147    /// It's a part of SQL query specified in {{}}:
148    /// CREATE TABLE notion (page_id TEXT PRIMARY KEY, {{"Animal" TEXT, "Age" REAL, "Name" TEXT}})
149    fn table_definitin_from(&self) -> String {
150        self.schema
151            .properties
152            .iter()
153            .map(|(_, property)| {
154                let column = self.column_names.resolve(&property.name);
155                let data_type = match property.property_type {
156                    NotionPropertyType::Title => "TEXT",
157                    NotionPropertyType::Number => "REAL",
158                    NotionPropertyType::Select => "TEXT",
159                    NotionPropertyType::Checkbox => "BOOLEAN",
160                    NotionPropertyType::Other => "TEXT",
161                    _ => "TEXT",
162                };
163                format!(r#""{column}" {data_type}"#)
164            })
165            .collect::<Vec<_>>()
166            .join(", ")
167    }
168
169    /// Create sql like "INSERT INTO {} (id, title) values (?1, ?2)"
170    fn create_insert_sql_for(&self, properties: &[&str]) -> String {
171        let columns_formatted = properties
172            .iter()
173            .map(|&property_name| {
174                if property_name == PAGE_ID_COLUMN {
175                    property_name.to_string()
176                } else {
177                    let column = self.column_names.resolve(property_name);
178                    format!(r#""{column}""#)
179                }
180            })
181            .collect::<Vec<_>>();
182        let placeholders = (1..(columns_formatted.len() + 1))
183            .map(|index| format!("?{}", index))
184            .collect::<Vec<_>>();
185
186        format!(
187            "INSERT INTO {table_name} ({columns}) VALUES ({values})",
188            table_name = &PAGE_PROPERTIES_TABLE,
189            columns = columns_formatted.join(", "),
190            values = placeholders.join(", ")
191        )
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use std::fs;
199
200    #[test]
201    fn validate_database_path() {
202        fs::create_dir("tmp").ok();
203
204        let valid_path = "./tmp/a.db";
205        let result = Sqlite::validate_database_path(valid_path);
206        assert!(result.is_ok());
207        let invalid_path = "tmp/foo/bar/a.db";
208        let result = Sqlite::validate_database_path(invalid_path);
209        assert!(result.is_err());
210    }
211}