diff --git a/Cargo.lock b/Cargo.lock index 4c440ae..2fa1191 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -788,6 +788,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7d73b3f436185384286bd8098d17ec07c9a7d2388a6599f824d8502b529702a" +[[package]] +name = "libsqlite3-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d31059f22935e6c31830db5249ba2b7ecd54fd73a9909286f0a67aa55c2fbd" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "lock_api" version = "0.4.4" @@ -1144,6 +1155,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c" + [[package]] name = "ppv-lite86" version = "0.2.10" @@ -1547,6 +1564,7 @@ dependencies = [ "hmac", "itoa", "libc", + "libsqlite3-sys", "log", "md-5", "memchr", @@ -1900,6 +1918,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vec_map" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index ae9ec24..10f7ef7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ tui = { version = "0.14.0", features = ["crossterm"], default-features = false } crossterm = "0.19" anyhow = "1.0.38" unicode-width = "0.1" -sqlx = { version = "0.4.1", features = ["mysql", "postgres", "chrono", "runtime-tokio-rustls", "decimal", "json"] } +sqlx = { version = "0.4.1", features = ["mysql", "postgres", "sqlite", "chrono", "runtime-tokio-rustls", "decimal", "json"] } chrono = "0.4" tokio = { version = "0.2.22", features = ["full"] } futures = "0.3.5" diff --git a/src/database/mod.rs b/src/database/mod.rs index 5bdd866..18014a1 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,8 +1,10 @@ pub mod mysql; pub mod postgres; +pub mod sqlite; pub use mysql::MySqlPool; pub use postgres::PostgresPool; +pub use sqlite::SqlitePool; use async_trait::async_trait; use database_tree::{Child, Database, Table}; diff --git a/src/database/mysql.rs b/src/database/mysql.rs index c9f91f3..29556b8 100644 --- a/src/database/mysql.rs +++ b/src/database/mysql.rs @@ -1,6 +1,6 @@ use super::{Pool, TableRow, RECORDS_LIMIT_PER_PAGE}; use async_trait::async_trait; -use chrono::NaiveDate; +use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; use database_tree::{Child, Database, Table}; use futures::TryStreamExt; use sqlx::mysql::{MySqlColumn, MySqlPool as MPool, MySqlRow}; @@ -353,6 +353,10 @@ fn convert_column_value_to_string(row: &MySqlRow, column: &MySqlColumn) -> anyho let value: Option = value; return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); } + if let Ok(value) = row.try_get(column_name) { + let value: Option = value; + return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); + } if let Ok(value) = row.try_get(column_name) { let value: Option = value; return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); @@ -366,7 +370,7 @@ fn convert_column_value_to_string(row: &MySqlRow, column: &MySqlColumn) -> anyho return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); } if let Ok(value) = row.try_get(column_name) { - let value: Option = value; + let value: Option = value; return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); } if let Ok(value) = row.try_get(column_name) { @@ -385,14 +389,30 @@ fn convert_column_value_to_string(row: &MySqlRow, column: &MySqlColumn) -> anyho let value: Option = value; return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); } + if let Ok(value) = row.try_get(column_name) { + let value: Option = value; + return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); + } if let Ok(value) = row.try_get(column_name) { let value: Option = value; return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); } + if let Ok(value) = row.try_get(column_name) { + let value: Option = value; + return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); + } + if let Ok(value) = row.try_get(column_name) { + let value: Option = value; + return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); + } if let Ok(value) = row.try_get(column_name) { let value: Option> = value; return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); } + if let Ok(value) = row.try_get(column_name) { + let value: Option = value; + return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); + } if let Ok(value) = row.try_get(column_name) { let value: Option = value; return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); diff --git a/src/database/postgres.rs b/src/database/postgres.rs index dbf4767..537b55a 100644 --- a/src/database/postgres.rs +++ b/src/database/postgres.rs @@ -1,6 +1,6 @@ use super::{Pool, TableRow, RECORDS_LIMIT_PER_PAGE}; use async_trait::async_trait; -use chrono::NaiveDate; +use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; use database_tree::{Child, Database, Schema, Table}; use futures::TryStreamExt; use itertools::Itertools; @@ -494,7 +494,23 @@ fn convert_column_value_to_string(row: &PgRow, column: &PgColumn) -> anyhow::Res return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); } if let Ok(value) = row.try_get(column_name) { - let value: Option = value; + let value: Option> = value; + return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); + } + if let Ok(value) = row.try_get(column_name) { + let value: Option = value; + return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); + } + if let Ok(value) = row.try_get(column_name) { + let value: Option = value; + return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); + } + if let Ok(value) = row.try_get(column_name) { + let value: Option = value; + return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); + } + if let Ok(value) = row.try_get(column_name) { + let value: Option = value; return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); } if let Ok(value) = row.try_get::, _>(column_name) { diff --git a/src/database/sqlite.rs b/src/database/sqlite.rs new file mode 100644 index 0000000..a98ea93 --- /dev/null +++ b/src/database/sqlite.rs @@ -0,0 +1,396 @@ +use super::{Pool, TableRow, RECORDS_LIMIT_PER_PAGE}; +use async_trait::async_trait; +use chrono::NaiveDateTime; +use database_tree::{Child, Database, Table}; +use futures::TryStreamExt; +use sqlx::sqlite::{SqliteColumn, SqlitePool as SPool, SqliteRow}; +use sqlx::{Column as _, Row as _, TypeInfo as _}; + +pub struct SqlitePool { + pool: SPool, +} + +impl SqlitePool { + pub async fn new(database_url: &str) -> anyhow::Result { + Ok(Self { + pool: SPool::connect(database_url).await?, + }) + } +} + +pub struct Constraint { + name: String, + column_name: String, +} + +impl TableRow for Constraint { + fn fields(&self) -> Vec { + vec!["name".to_string(), "column_name".to_string()] + } + + fn columns(&self) -> Vec { + vec![self.name.to_string(), self.column_name.to_string()] + } +} + +pub struct Column { + name: Option, + r#type: Option, + null: Option, + default: Option, + comment: Option, +} + +impl TableRow for Column { + fn fields(&self) -> Vec { + vec![ + "name".to_string(), + "type".to_string(), + "null".to_string(), + "default".to_string(), + "comment".to_string(), + ] + } + + fn columns(&self) -> Vec { + vec![ + self.name + .as_ref() + .map_or(String::new(), |name| name.to_string()), + self.r#type + .as_ref() + .map_or(String::new(), |r#type| r#type.to_string()), + self.null + .as_ref() + .map_or(String::new(), |null| null.to_string()), + self.default + .as_ref() + .map_or(String::new(), |default| default.to_string()), + self.comment + .as_ref() + .map_or(String::new(), |comment| comment.to_string()), + ] + } +} + +pub struct ForeignKey { + name: Option, + column_name: Option, + ref_table: Option, + ref_column: Option, +} + +impl TableRow for ForeignKey { + fn fields(&self) -> Vec { + vec![ + "name".to_string(), + "column_name".to_string(), + "ref_table".to_string(), + "ref_column".to_string(), + ] + } + + fn columns(&self) -> Vec { + vec![ + self.name + .as_ref() + .map_or(String::new(), |name| name.to_string()), + self.column_name + .as_ref() + .map_or(String::new(), |r#type| r#type.to_string()), + self.ref_table + .as_ref() + .map_or(String::new(), |r#type| r#type.to_string()), + self.ref_column + .as_ref() + .map_or(String::new(), |r#type| r#type.to_string()), + ] + } +} + +pub struct Index { + name: Option, + column_name: Option, + r#type: Option, +} + +impl TableRow for Index { + fn fields(&self) -> Vec { + vec![ + "name".to_string(), + "column_name".to_string(), + "type".to_string(), + ] + } + + fn columns(&self) -> Vec { + vec![ + self.name + .as_ref() + .map_or(String::new(), |name| name.to_string()), + self.column_name + .as_ref() + .map_or(String::new(), |column_name| column_name.to_string()), + self.r#type + .as_ref() + .map_or(String::new(), |r#type| r#type.to_string()), + ] + } +} + +#[async_trait] +impl Pool for SqlitePool { + async fn get_databases(&self) -> anyhow::Result> { + let databases = sqlx::query("SHOW DATABASES") + .fetch_all(&self.pool) + .await? + .iter() + .map(|table| table.get(0)) + .collect::>(); + let mut list = vec![]; + for db in databases { + list.push(Database::new( + db.clone(), + self.get_tables(db.clone()).await?, + )) + } + Ok(list) + } + + async fn get_tables(&self, database: String) -> anyhow::Result> { + let tables = + sqlx::query_as::<_, Table>(format!("SHOW TABLE STATUS FROM `{}`", database).as_str()) + .fetch_all(&self.pool) + .await?; + Ok(tables.into_iter().map(|table| table.into()).collect()) + } + + async fn get_records( + &self, + database: &Database, + table: &Table, + page: u16, + filter: Option, + ) -> anyhow::Result<(Vec, Vec>)> { + let query = if let Some(filter) = filter { + format!( + "SELECT * FROM `{database}`.`{table}` WHERE {filter} LIMIT {page}, {limit}", + database = database.name, + table = table.name, + filter = filter, + page = page, + limit = RECORDS_LIMIT_PER_PAGE + ) + } else { + format!( + "SELECT * FROM `{}`.`{}` limit {page}, {limit}", + database.name, + table.name, + page = page, + limit = RECORDS_LIMIT_PER_PAGE + ) + }; + let mut rows = sqlx::query(query.as_str()).fetch(&self.pool); + let mut headers = vec![]; + let mut records = vec![]; + while let Some(row) = rows.try_next().await? { + headers = row + .columns() + .iter() + .map(|column| column.name().to_string()) + .collect(); + let mut new_row = vec![]; + for column in row.columns() { + new_row.push(convert_column_value_to_string(&row, column)?) + } + records.push(new_row) + } + Ok((headers, records)) + } + + async fn get_columns( + &self, + database: &Database, + table: &Table, + ) -> anyhow::Result>> { + let query = format!( + "SHOW FULL COLUMNS FROM `{}`.`{}`", + database.name, table.name + ); + let mut rows = sqlx::query(query.as_str()).fetch(&self.pool); + let mut columns: Vec> = vec![]; + while let Some(row) = rows.try_next().await? { + columns.push(Box::new(Column { + name: row.try_get("Field")?, + r#type: row.try_get("Type")?, + null: row.try_get("Null")?, + default: row.try_get("Default")?, + comment: row.try_get("Comment")?, + })) + } + Ok(columns) + } + + async fn get_constraints( + &self, + database: &Database, + table: &Table, + ) -> anyhow::Result>> { + let mut rows = sqlx::query( + " + SELECT + COLUMN_NAME, + CONSTRAINT_NAME + FROM + information_schema.KEY_COLUMN_USAGE + WHERE + REFERENCED_TABLE_SCHEMA IS NULL + AND REFERENCED_TABLE_NAME IS NULL + AND TABLE_SCHEMA = ? + AND TABLE_NAME = ? + ", + ) + .bind(&database.name) + .bind(&table.name) + .fetch(&self.pool); + let mut constraints: Vec> = vec![]; + while let Some(row) = rows.try_next().await? { + constraints.push(Box::new(Constraint { + name: row.try_get("CONSTRAINT_NAME")?, + column_name: row.try_get("COLUMN_NAME")?, + })) + } + Ok(constraints) + } + + async fn get_foreign_keys( + &self, + database: &Database, + table: &Table, + ) -> anyhow::Result>> { + let mut rows = sqlx::query( + " + SELECT + TABLE_NAME, + COLUMN_NAME, + CONSTRAINT_NAME, + REFERENCED_TABLE_SCHEMA, + REFERENCED_TABLE_NAME, + REFERENCED_COLUMN_NAME + FROM + INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE + REFERENCED_TABLE_SCHEMA IS NOT NULL + AND REFERENCED_TABLE_NAME IS NOT NULL + AND TABLE_SCHEMA = ? + AND TABLE_NAME = ? + ", + ) + .bind(&database.name) + .bind(&table.name) + .fetch(&self.pool); + let mut foreign_keys: Vec> = vec![]; + while let Some(row) = rows.try_next().await? { + foreign_keys.push(Box::new(ForeignKey { + name: row.try_get("CONSTRAINT_NAME")?, + column_name: row.try_get("COLUMN_NAME")?, + ref_table: row.try_get("REFERENCED_TABLE_NAME")?, + ref_column: row.try_get("REFERENCED_COLUMN_NAME")?, + })) + } + Ok(foreign_keys) + } + + async fn get_indexes( + &self, + database: &Database, + table: &Table, + ) -> anyhow::Result>> { + let mut rows = sqlx::query( + " + SELECT + DISTINCT TABLE_NAME, + INDEX_NAME, + INDEX_TYPE, + COLUMN_NAME + FROM + INFORMATION_SCHEMA.STATISTICS + WHERE + TABLE_SCHEMA = ? + AND TABLE_NAME = ? + ", + ) + .bind(&database.name) + .bind(&table.name) + .fetch(&self.pool); + let mut foreign_keys: Vec> = vec![]; + while let Some(row) = rows.try_next().await? { + foreign_keys.push(Box::new(Index { + name: row.try_get("INDEX_NAME")?, + column_name: row.try_get("COLUMN_NAME")?, + r#type: row.try_get("INDEX_TYPE")?, + })) + } + Ok(foreign_keys) + } + + async fn close(&self) { + self.pool.close().await; + } +} + +fn convert_column_value_to_string( + row: &SqliteRow, + column: &SqliteColumn, +) -> anyhow::Result { + let column_name = column.name(); + if let Ok(value) = row.try_get(column_name) { + let value: Option = value; + return Ok(value.unwrap_or_else(|| "NULL".to_string())); + } + if let Ok(value) = row.try_get(column_name) { + let value: Option<&str> = value; + return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); + } + if let Ok(value) = row.try_get(column_name) { + let value: Option = value; + return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); + } + if let Ok(value) = row.try_get(column_name) { + let value: Option = value; + return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); + } + if let Ok(value) = row.try_get(column_name) { + let value: Option = value; + return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); + } + if let Ok(value) = row.try_get(column_name) { + let value: Option = value; + return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); + } + if let Ok(value) = row.try_get(column_name) { + let value: Option = value; + return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); + } + if let Ok(value) = row.try_get(column_name) { + let value: Option> = value; + return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); + } + if let Ok(value) = row.try_get(column_name) { + let value: Option> = value; + return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); + } + if let Ok(value) = row.try_get(column_name) { + let value: Option = value; + return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); + } + if let Ok(value) = row.try_get(column_name) { + let value: Option = value; + return Ok(value.map_or("NULL".to_string(), |v| v.to_string())); + } + Err(anyhow::anyhow!( + "column type not implemented: `{}` {}", + column_name, + column.type_info().clone().name() + )) +} diff --git a/src/main.rs b/src/main.rs index 5cd8ef2..241f73d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,7 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, ExecutableCommand, }; -use std::{io, panic}; +use std::io; use tui::{backend::CrosstermBackend, Terminal}; #[tokio::main] @@ -65,14 +65,6 @@ fn setup_terminal() -> Result<()> { Ok(()) } -fn set_panic_handlers() -> Result<()> { - panic::set_hook(Box::new(|e| { - eprintln!("panic: {:?}", e); - shutdown_terminal(); - })); - Ok(()) -} - fn shutdown_terminal() { let leave_screen = io::stdout().execute(LeaveAlternateScreen).map(|_f| ());