database_replicator/sqlite/
converter.rs

1// ABOUTME: SQLite to JSONB type conversion for PostgreSQL storage
2// ABOUTME: Handles all SQLite types with lossless conversion and BLOB base64 encoding
3
4use anyhow::{Context, Result};
5use rusqlite::Connection;
6use serde_json::Value as JsonValue;
7use std::collections::HashMap;
8
9/// Convert a single SQLite value to JSON
10///
11/// Maps SQLite types to JSON types:
12/// - INTEGER → number (i64)
13/// - REAL → number (f64)
14/// - TEXT → string (UTF-8)
15/// - BLOB → object with base64-encoded data
16/// - NULL → null
17///
18/// # Arguments
19///
20/// * `value` - SQLite value from rusqlite
21///
22/// # Returns
23///
24/// JSON value suitable for JSONB storage
25///
26/// # Examples
27///
28/// ```no_run
29/// # use database_replicator::sqlite::converter::sqlite_value_to_json;
30/// # use rusqlite::types::Value;
31/// let sqlite_int = Value::Integer(42);
32/// let json = sqlite_value_to_json(&sqlite_int).unwrap();
33/// assert_eq!(json, serde_json::json!(42));
34/// ```
35pub fn sqlite_value_to_json(value: &rusqlite::types::Value) -> Result<JsonValue> {
36    match value {
37        rusqlite::types::Value::Null => Ok(JsonValue::Null),
38
39        rusqlite::types::Value::Integer(i) => Ok(JsonValue::Number((*i).into())),
40
41        rusqlite::types::Value::Real(f) => {
42            // Convert f64 to JSON number
43            // Note: JSON can't represent NaN or Infinity, handle edge cases
44            if f.is_finite() {
45                serde_json::Number::from_f64(*f)
46                    .map(JsonValue::Number)
47                    .ok_or_else(|| anyhow::anyhow!("Failed to convert float {} to JSON number", f))
48            } else {
49                // Store non-finite numbers as strings for safety
50                Ok(JsonValue::String(f.to_string()))
51            }
52        }
53
54        rusqlite::types::Value::Text(s) => Ok(JsonValue::String(s.clone())),
55
56        rusqlite::types::Value::Blob(b) => {
57            // Encode BLOB as base64 in a JSON object
58            // Format: {"_type": "blob", "data": "base64..."}
59            // This allows distinguishing BLOBs from regular strings
60            let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, b);
61            Ok(serde_json::json!({
62                "_type": "blob",
63                "data": encoded
64            }))
65        }
66    }
67}
68
69/// Convert a SQLite row (HashMap) to JSON object
70///
71/// Converts all column values to JSON and returns a JSON object
72/// with column names as keys.
73///
74/// # Arguments
75///
76/// * `row` - HashMap of column_name → SQLite value
77///
78/// # Returns
79///
80/// JSON object ready for JSONB storage
81///
82/// # Examples
83///
84/// ```no_run
85/// # use database_replicator::sqlite::converter::sqlite_row_to_json;
86/// # use std::collections::HashMap;
87/// # use rusqlite::types::Value;
88/// let mut row = HashMap::new();
89/// row.insert("id".to_string(), Value::Integer(1));
90/// row.insert("name".to_string(), Value::Text("Alice".to_string()));
91/// let json = sqlite_row_to_json(row).unwrap();
92/// assert_eq!(json["id"], 1);
93/// assert_eq!(json["name"], "Alice");
94/// ```
95pub fn sqlite_row_to_json(row: HashMap<String, rusqlite::types::Value>) -> Result<JsonValue> {
96    let mut json_obj = serde_json::Map::new();
97
98    for (col_name, value) in row {
99        let json_value = sqlite_value_to_json(&value)
100            .with_context(|| format!("Failed to convert column '{}' to JSON", col_name))?;
101        json_obj.insert(col_name, json_value);
102    }
103
104    Ok(JsonValue::Object(json_obj))
105}
106
107/// Convert an entire SQLite table to JSONB format
108///
109/// Reads all rows from a SQLite table and converts them to JSONB.
110/// Returns a vector of (id, json_data) tuples ready for insertion.
111///
112/// # ID Generation Strategy
113///
114/// - If table has a column named "id", "rowid", or "_id", use that as the ID
115/// - Otherwise, use SQLite's rowid (every table has one)
116/// - IDs are converted to strings for consistency
117///
118/// # Arguments
119///
120/// * `conn` - SQLite database connection
121/// * `table` - Table name (must be validated)
122///
123/// # Returns
124///
125/// Vector of (id_string, json_data) tuples for batch insert
126///
127/// # Security
128///
129/// Table name should be validated before calling this function.
130///
131/// # Examples
132///
133/// ```no_run
134/// # use database_replicator::sqlite::{open_sqlite, converter::convert_table_to_jsonb};
135/// # use database_replicator::jsonb::validate_table_name;
136/// # fn example() -> anyhow::Result<()> {
137/// let conn = open_sqlite("database.db")?;
138/// let table = "users";
139/// validate_table_name(table)?;
140/// let rows = convert_table_to_jsonb(&conn, table)?;
141/// println!("Converted {} rows to JSONB", rows.len());
142/// # Ok(())
143/// # }
144/// ```
145pub fn convert_table_to_jsonb(conn: &Connection, table: &str) -> Result<Vec<(String, JsonValue)>> {
146    // Validate table name
147    crate::jsonb::validate_table_name(table).context("Invalid table name for JSONB conversion")?;
148
149    tracing::info!("Converting SQLite table '{}' to JSONB", table);
150
151    // Read all rows using our reader
152    let rows = crate::sqlite::reader::read_table_data(conn, table)
153        .with_context(|| format!("Failed to read data from table '{}'", table))?;
154
155    // Detect ID column
156    let id_column = detect_id_column(conn, table)?;
157
158    let mut result = Vec::with_capacity(rows.len());
159
160    for (row_num, row) in rows.into_iter().enumerate() {
161        // Extract or generate ID
162        let id = if let Some(ref id_col) = id_column {
163            // Use the specified ID column
164            match row.get(id_col) {
165                Some(rusqlite::types::Value::Integer(i)) => i.to_string(),
166                Some(rusqlite::types::Value::Text(s)) => s.clone(),
167                Some(rusqlite::types::Value::Real(f)) => f.to_string(),
168                _ => {
169                    // Fallback to row number if ID is NULL or unsupported type
170                    tracing::warn!(
171                        "Row {} in table '{}' has invalid ID type, using row number",
172                        row_num + 1,
173                        table
174                    );
175                    (row_num + 1).to_string()
176                }
177            }
178        } else {
179            // No ID column found, use row number
180            // SQLite rowid is 1-indexed, so we add 1
181            (row_num + 1).to_string()
182        };
183
184        // Convert row to JSON
185        let json_data = sqlite_row_to_json(row).with_context(|| {
186            format!(
187                "Failed to convert row {} in table '{}' to JSON",
188                row_num + 1,
189                table
190            )
191        })?;
192
193        result.push((id, json_data));
194    }
195
196    tracing::info!(
197        "Converted {} rows from table '{}' to JSONB",
198        result.len(),
199        table
200    );
201
202    Ok(result)
203}
204
205/// Detect the ID column for a table
206///
207/// Checks for common ID column names: "id", "rowid", "_id" (case-insensitive).
208/// If found, returns the column name. Otherwise returns None.
209fn detect_id_column(conn: &Connection, table: &str) -> Result<Option<String>> {
210    // Get column names for the table
211    let query = format!("PRAGMA table_info(\"{}\")", table);
212    let mut stmt = conn
213        .prepare(&query)
214        .with_context(|| format!("Failed to get table info for '{}'", table))?;
215
216    let columns: Vec<String> = stmt
217        .query_map([], |row| row.get::<_, String>(1))
218        .context("Failed to query table columns")?
219        .collect::<Result<Vec<_>, _>>()
220        .context("Failed to collect column names")?;
221
222    // Check for common ID column names (case-insensitive)
223    let id_candidates = ["id", "rowid", "_id"];
224    for candidate in &id_candidates {
225        if let Some(col) = columns.iter().find(|c| c.to_lowercase() == *candidate) {
226            tracing::debug!("Using column '{}' as ID for table '{}'", col, table);
227            return Ok(Some(col.clone()));
228        }
229    }
230
231    tracing::debug!(
232        "No ID column found for table '{}', will use row number",
233        table
234    );
235    Ok(None)
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use rusqlite::types::Value;
242
243    #[test]
244    fn test_convert_integer() {
245        let value = Value::Integer(42);
246        let json = sqlite_value_to_json(&value).unwrap();
247        assert_eq!(json, serde_json::json!(42));
248    }
249
250    #[test]
251    fn test_convert_real() {
252        let value = Value::Real(42.75);
253        let json = sqlite_value_to_json(&value).unwrap();
254        assert_eq!(json, serde_json::json!(42.75));
255    }
256
257    #[test]
258    fn test_convert_text() {
259        let value = Value::Text("Hello, World!".to_string());
260        let json = sqlite_value_to_json(&value).unwrap();
261        assert_eq!(json, serde_json::json!("Hello, World!"));
262    }
263
264    #[test]
265    fn test_convert_null() {
266        let value = Value::Null;
267        let json = sqlite_value_to_json(&value).unwrap();
268        assert_eq!(json, JsonValue::Null);
269    }
270
271    #[test]
272    fn test_convert_blob() {
273        let blob_data = vec![0x48, 0x65, 0x6c, 0x6c, 0x6f]; // "Hello" in bytes
274        let value = Value::Blob(blob_data.clone());
275        let json = sqlite_value_to_json(&value).unwrap();
276
277        // Should be wrapped in an object with _type and data fields
278        assert!(json.is_object());
279        assert_eq!(json["_type"], "blob");
280
281        // Decode and verify
282        let encoded = json["data"].as_str().unwrap();
283        let decoded =
284            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded).unwrap();
285        assert_eq!(decoded, blob_data);
286    }
287
288    #[test]
289    fn test_convert_non_finite_float() {
290        let nan_value = Value::Real(f64::NAN);
291        let json = sqlite_value_to_json(&nan_value).unwrap();
292        // NaN should be converted to string
293        assert!(json.is_string());
294
295        let inf_value = Value::Real(f64::INFINITY);
296        let json = sqlite_value_to_json(&inf_value).unwrap();
297        // Infinity should be converted to string
298        assert!(json.is_string());
299    }
300
301    #[test]
302    fn test_sqlite_row_to_json() {
303        let mut row = HashMap::new();
304        row.insert("id".to_string(), Value::Integer(1));
305        row.insert("name".to_string(), Value::Text("Alice".to_string()));
306        row.insert("age".to_string(), Value::Integer(30));
307        row.insert("balance".to_string(), Value::Real(100.50));
308        row.insert("notes".to_string(), Value::Null);
309
310        let json = sqlite_row_to_json(row).unwrap();
311
312        assert_eq!(json["id"], 1);
313        assert_eq!(json["name"], "Alice");
314        assert_eq!(json["age"], 30);
315        assert_eq!(json["balance"], 100.50);
316        assert_eq!(json["notes"], JsonValue::Null);
317    }
318
319    #[test]
320    fn test_convert_table_to_jsonb() {
321        // Create a test database
322        let conn = Connection::open_in_memory().unwrap();
323
324        // Create test table with ID column
325        conn.execute(
326            "CREATE TABLE users (
327                id INTEGER PRIMARY KEY,
328                name TEXT NOT NULL,
329                email TEXT,
330                age INTEGER
331            )",
332            [],
333        )
334        .unwrap();
335
336        // Insert test data
337        conn.execute(
338            "INSERT INTO users (id, name, email, age) VALUES (1, 'Alice', 'alice@example.com', 30)",
339            [],
340        )
341        .unwrap();
342        conn.execute(
343            "INSERT INTO users (id, name, email, age) VALUES (2, 'Bob', 'bob@example.com', 25)",
344            [],
345        )
346        .unwrap();
347
348        // Convert to JSONB
349        let result = convert_table_to_jsonb(&conn, "users").unwrap();
350
351        assert_eq!(result.len(), 2);
352
353        // Check first row
354        let (id1, json1) = &result[0];
355        assert_eq!(id1, "1");
356        assert_eq!(json1["name"], "Alice");
357        assert_eq!(json1["email"], "alice@example.com");
358        assert_eq!(json1["age"], 30);
359
360        // Check second row
361        let (id2, json2) = &result[1];
362        assert_eq!(id2, "2");
363        assert_eq!(json2["name"], "Bob");
364    }
365
366    #[test]
367    fn test_convert_table_without_id_column() {
368        // Create a test database
369        let conn = Connection::open_in_memory().unwrap();
370
371        // Create table WITHOUT explicit ID column
372        conn.execute(
373            "CREATE TABLE logs (
374                timestamp INTEGER,
375                message TEXT
376            )",
377            [],
378        )
379        .unwrap();
380
381        // Insert test data
382        conn.execute(
383            "INSERT INTO logs (timestamp, message) VALUES (12345, 'Test message')",
384            [],
385        )
386        .unwrap();
387
388        // Convert to JSONB
389        let result = convert_table_to_jsonb(&conn, "logs").unwrap();
390
391        assert_eq!(result.len(), 1);
392
393        // Should use row number as ID (1-indexed)
394        let (id, json) = &result[0];
395        assert_eq!(id, "1");
396        assert_eq!(json["message"], "Test message");
397    }
398
399    #[test]
400    fn test_convert_table_handles_null_values() {
401        let conn = Connection::open_in_memory().unwrap();
402
403        conn.execute(
404            "CREATE TABLE users (
405                id INTEGER PRIMARY KEY,
406                name TEXT,
407                email TEXT
408            )",
409            [],
410        )
411        .unwrap();
412
413        // Insert row with NULL values
414        conn.execute(
415            "INSERT INTO users (id, name, email) VALUES (1, 'Alice', NULL)",
416            [],
417        )
418        .unwrap();
419
420        let result = convert_table_to_jsonb(&conn, "users").unwrap();
421
422        assert_eq!(result.len(), 1);
423        let (_, json) = &result[0];
424        assert_eq!(json["name"], "Alice");
425        assert_eq!(json["email"], JsonValue::Null);
426    }
427
428    #[test]
429    fn test_convert_table_with_blob() {
430        let conn = Connection::open_in_memory().unwrap();
431
432        conn.execute(
433            "CREATE TABLE files (
434                id INTEGER PRIMARY KEY,
435                name TEXT,
436                data BLOB
437            )",
438            [],
439        )
440        .unwrap();
441
442        // Insert row with BLOB (must be Vec<u8>, not Vec<i32>)
443        let blob_data: Vec<u8> = vec![0x01, 0x02, 0x03, 0x04];
444        conn.execute(
445            "INSERT INTO files (id, name, data) VALUES (?1, ?2, ?3)",
446            rusqlite::params![1, "test.bin", &blob_data],
447        )
448        .unwrap();
449
450        let result = convert_table_to_jsonb(&conn, "files").unwrap();
451
452        assert_eq!(result.len(), 1);
453        let (_, json) = &result[0];
454        assert_eq!(json["name"], "test.bin");
455
456        // BLOB should be base64-encoded
457        assert!(json["data"].is_object());
458        assert_eq!(json["data"]["_type"], "blob");
459        assert!(json["data"]["data"].is_string());
460    }
461
462    #[test]
463    fn test_detect_id_column_case_insensitive() {
464        let conn = Connection::open_in_memory().unwrap();
465
466        // Create table with uppercase ID column
467        conn.execute("CREATE TABLE test (ID INTEGER PRIMARY KEY, value TEXT)", [])
468            .unwrap();
469
470        let id_col = detect_id_column(&conn, "test").unwrap();
471        assert!(id_col.is_some());
472        assert_eq!(id_col.unwrap().to_lowercase(), "id");
473    }
474
475    #[test]
476    fn test_convert_empty_table() {
477        let conn = Connection::open_in_memory().unwrap();
478
479        conn.execute("CREATE TABLE empty (id INTEGER PRIMARY KEY)", [])
480            .unwrap();
481
482        let result = convert_table_to_jsonb(&conn, "empty").unwrap();
483        assert_eq!(result.len(), 0);
484    }
485}