diff --git a/README.md b/README.md index 3debafe..6ec83fe 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ If you want to add connections, you need to edit your config file. For more info | h, j, k, l | Scroll left/down/up/right | | Ctrl + u, Ctrl + d | Scroll up/down multiple lines | | g , G | Scroll to top/bottom | +| s | Sort by selected column | | H, J, K, L | Extend selection by one cell left/down/up/right | | y | Copy a cell value | | , | Move focus to left/right | diff --git a/src/app.rs b/src/app.rs index db41011..02a4628 100644 --- a/src/app.rs +++ b/src/app.rs @@ -163,7 +163,12 @@ impl App { Ok(()) } - async fn update_record_table(&mut self) -> anyhow::Result<()> { + async fn update_record_table( + &mut self, + orders: Option, + header_icons: Option>, + hold_cursor_position: bool, + ) -> anyhow::Result<()> { if let Some((database, table)) = self.databases.tree().selected_table() { let (headers, records) = self .pool @@ -178,10 +183,16 @@ impl App { } else { Some(self.record_table.filter.input_str()) }, + orders, ) .await?; - self.record_table - .update(records, headers, database.clone(), table.clone()); + self.record_table.update( + records, + self.concat_headers(headers, header_icons), + database.clone(), + table.clone(), + hold_cursor_position, + ); } Ok(()) } @@ -231,10 +242,15 @@ impl App { .pool .as_ref() .unwrap() - .get_records(&database, &table, 0, None) + .get_records(&database, &table, 0, None, None) .await?; - self.record_table - .update(records, headers, database.clone(), table.clone()); + self.record_table.update( + records, + headers, + database.clone(), + table.clone(), + false, + ); self.properties .update(database.clone(), table.clone(), self.pool.as_ref().unwrap()) .await?; @@ -250,6 +266,17 @@ impl App { return Ok(EventState::Consumed); }; + if key == self.config.key_config.sort_by_column + && !self.record_table.table.headers.is_empty() + { + self.record_table.table.add_order(); + let order_query = self.record_table.table.generate_order_query(); + let header_icons = self.record_table.table.generate_header_icons(); + self.update_record_table(order_query, Some(header_icons), true) + .await?; + return Ok(EventState::Consumed); + }; + if key == self.config.key_config.copy { if let Some(text) = self.record_table.table.selected_cells() { copy_to_clipboard(text.as_str())? @@ -259,7 +286,10 @@ impl App { if key == self.config.key_config.enter && self.record_table.filter_focused() { self.record_table.focus = crate::components::record_table::Focus::Table; - self.update_record_table().await?; + let order_query = self.record_table.table.generate_order_query(); + let header_icons = self.record_table.table.generate_header_icons(); + self.update_record_table(order_query, Some(header_icons), false) + .await?; } if self.record_table.table.eod { @@ -284,6 +314,7 @@ impl App { } else { Some(self.record_table.filter.input_str()) }, + None, ) .await?; if !records.is_empty() { @@ -374,6 +405,24 @@ impl App { } Ok(EventState::NotConsumed) } + + fn concat_headers( + &self, + headers: Vec, + header_icons: Option>, + ) -> Vec { + if let Some(header_icons) = &header_icons { + let mut new_headers = vec![String::new(); headers.len()]; + for (index, header) in headers.iter().enumerate() { + new_headers[index] = format!("{} {}", header, header_icons[index]) + .trim() + .to_string(); + } + return new_headers; + } + + headers + } } #[cfg(test)] @@ -409,4 +458,25 @@ mod test { ); assert_eq!(app.left_main_chunk_percentage, 15); } + + #[test] + fn test_concat_headers() { + let app = App::new(Config::default()); + let headers = vec![ + "ID".to_string(), + "NAME".to_string(), + "TIMESTAMP".to_string(), + ]; + let header_icons = vec!["".to_string(), "↑1".to_string(), "↓2".to_string()]; + let concat_headers: Vec = app.concat_headers(headers, Some(header_icons)); + + assert_eq!( + concat_headers, + vec![ + "ID".to_string(), + "NAME ↑1".to_string(), + "TIMESTAMP ↓2".to_string() + ] + ) + } } diff --git a/src/components/command.rs b/src/components/command.rs index e569309..499505e 100644 --- a/src/components/command.rs +++ b/src/components/command.rs @@ -62,6 +62,13 @@ pub fn scroll_to_top_bottom(key: &KeyConfig) -> CommandText { ) } +pub fn sort_by_column(key: &KeyConfig) -> CommandText { + CommandText::new( + format!("Sort by column [{}]", key.sort_by_column), + CMD_GROUP_TABLE, + ) +} + pub fn expand_collapse(key: &KeyConfig) -> CommandText { CommandText::new( format!("Expand/Collapse [{},{}]", key.scroll_right, key.scroll_left,), diff --git a/src/components/properties.rs b/src/components/properties.rs index acd6ee4..4e42c18 100644 --- a/src/components/properties.rs +++ b/src/components/properties.rs @@ -77,6 +77,7 @@ impl PropertiesComponent { columns.get(0).unwrap().fields(), database.clone(), table.clone(), + false, ); } self.constraint_table.reset(); @@ -90,6 +91,7 @@ impl PropertiesComponent { constraints.get(0).unwrap().fields(), database.clone(), table.clone(), + false, ); } self.foreign_key_table.reset(); @@ -103,6 +105,7 @@ impl PropertiesComponent { foreign_keys.get(0).unwrap().fields(), database.clone(), table.clone(), + false, ); } self.index_table.reset(); @@ -116,6 +119,7 @@ impl PropertiesComponent { indexes.get(0).unwrap().fields(), database.clone(), table.clone(), + false, ); } Ok(()) diff --git a/src/components/record_table.rs b/src/components/record_table.rs index d4857aa..2f5c84f 100644 --- a/src/components/record_table.rs +++ b/src/components/record_table.rs @@ -39,8 +39,10 @@ impl RecordTableComponent { headers: Vec, database: Database, table: DTable, + hold_cusor_position: bool, ) { - self.table.update(rows, headers, database, table.clone()); + self.table + .update(rows, headers, database, table.clone(), hold_cusor_position); self.filter.table = Some(table); } diff --git a/src/components/sql_editor.rs b/src/components/sql_editor.rs index 50e53cf..b8ad596 100644 --- a/src/components/sql_editor.rs +++ b/src/components/sql_editor.rs @@ -269,7 +269,7 @@ impl Component for SqlEditorComponent { database, table, } => { - self.table.update(rows, headers, database, table); + self.table.update(rows, headers, database, table, false); self.focus = Focus::Table; self.query_result = None; } diff --git a/src/components/table.rs b/src/components/table.rs index 0c5da62..09b5595 100644 --- a/src/components/table.rs +++ b/src/components/table.rs @@ -17,11 +17,92 @@ use tui::{ }; use unicode_width::UnicodeWidthStr; +#[derive(Debug, PartialEq)] +struct Order { + pub column_number: usize, + pub is_asc: bool, +} + +impl Order { + pub fn new(column_number: usize, is_asc: bool) -> Self { + Self { + column_number, + is_asc, + } + } + + fn query(&self) -> String { + let order = if self.is_asc { "ASC" } else { "DESC" }; + + return format!( + "{column} {order}", + column = self.column_number, + order = order + ); + } +} + +#[derive(PartialEq)] +struct OrderManager { + orders: Vec, +} + +impl OrderManager { + fn new() -> Self { + Self { orders: vec![] } + } + + fn generate_order_query(&mut self) -> Option { + let order_query = self + .orders + .iter() + .map(|order| order.query()) + .collect::>(); + + if !order_query.is_empty() { + return Some("ORDER BY ".to_string() + &order_query.join(", ")); + } + + None + } + + fn generate_header_icons(&mut self, header_length: usize) -> Vec { + let mut header_icons = vec![String::new(); header_length]; + + for (index, order) in self.orders.iter().enumerate() { + let arrow = if order.is_asc { "↑" } else { "↓" }; + header_icons[order.column_number - 1] = + format!("{arrow}{number}", arrow = arrow, number = index + 1); + } + + header_icons + } + + fn add_order(&mut self, selected_column: usize) { + let selected_column_number = selected_column + 1; + if let Some(position) = self + .orders + .iter() + .position(|order| order.column_number == selected_column_number) + { + if self.orders[position].is_asc { + self.orders[position].is_asc = false; + } else { + self.orders.remove(position); + } + } else { + let order = Order::new(selected_column_number, true); + self.orders.push(order); + } + } +} + pub struct TableComponent { pub headers: Vec, pub rows: Vec>, pub eod: bool, pub selected_row: TableState, + orders: OrderManager, table: Option<(Database, DTable)>, selected_column: usize, selection_area_corner: Option<(usize, usize)>, @@ -36,6 +117,7 @@ impl TableComponent { selected_row: TableState::default(), headers: vec![], rows: vec![], + orders: OrderManager::new(), table: None, selected_column: 0, selection_area_corner: None, @@ -58,6 +140,7 @@ impl TableComponent { headers: Vec, database: Database, table: DTable, + hold_cusor_position: bool, ) { self.selected_row.select(None); if !rows.is_empty() { @@ -65,7 +148,11 @@ impl TableComponent { } self.headers = headers; self.rows = rows; - self.selected_column = 0; + self.selected_column = if hold_cusor_position { + self.selected_column + } else { + 0 + }; self.selection_area_corner = None; self.column_page_start = std::cell::Cell::new(0); self.scroll = VerticalScroll::new(false, false); @@ -77,6 +164,7 @@ impl TableComponent { self.selected_row.select(None); self.headers = Vec::new(); self.rows = Vec::new(); + self.orders = OrderManager::new(); self.selected_column = 0; self.selection_area_corner = None; self.column_page_start = std::cell::Cell::new(0); @@ -89,6 +177,18 @@ impl TableComponent { self.selection_area_corner = None; } + pub fn add_order(&mut self) { + self.orders.add_order(self.selected_column) + } + + pub fn generate_order_query(&mut self) -> Option { + self.orders.generate_order_query() + } + + pub fn generate_header_icons(&mut self) -> Vec { + self.orders.generate_header_icons(self.headers.len()) + } + pub fn end(&mut self) { self.eod = true; } @@ -522,6 +622,7 @@ impl Component for TableComponent { out.push(CommandInfo::new(command::extend_selection_by_one_cell( &self.key_config, ))); + out.push(CommandInfo::new(command::sort_by_column(&self.key_config))); } fn event(&mut self, key: Key) -> Result { @@ -568,7 +669,7 @@ impl Component for TableComponent { #[cfg(test)] mod test { - use super::{KeyConfig, TableComponent}; + use super::{KeyConfig, Order, OrderManager, TableComponent}; use tui::layout::Constraint; #[test] @@ -588,6 +689,78 @@ mod test { assert_eq!(component.rows(1, 2), vec![vec!["1", "b"], vec!["2", "e"]],) } + #[test] + fn test_query() { + let asc_order = Order::new(1, true); + let desc_order = Order::new(2, false); + + assert_eq!(asc_order.query(), "1 ASC".to_string()); + assert_eq!(desc_order.query(), "2 DESC".to_string()); + } + + #[test] + fn test_generate_order_query() { + let mut order_manager = OrderManager::new(); + + // If orders is empty, it should return None. + assert_eq!(order_manager.generate_order_query(), None); + + order_manager.add_order(1); + order_manager.add_order(1); + order_manager.add_order(2); + assert_eq!( + order_manager.generate_order_query(), + Some("ORDER BY 2 DESC, 3 ASC".to_string()) + ) + } + + #[test] + fn test_generate_header_icons() { + let mut order_manager = OrderManager::new(); + assert_eq!(order_manager.generate_header_icons(1), vec![String::new()]); + + order_manager.add_order(1); + order_manager.add_order(1); + order_manager.add_order(2); + assert_eq!( + order_manager.generate_header_icons(3), + vec![String::new(), "↓1".to_string(), "↑2".to_string()] + ); + assert_eq!( + order_manager.generate_header_icons(4), + vec![ + String::new(), + "↓1".to_string(), + "↑2".to_string(), + String::new() + ] + ); + } + + #[test] + fn test_add_order() { + let mut order_manager = OrderManager::new(); + + // press first time, condition is asc. + order_manager.add_order(1); + assert_eq!(order_manager.orders, vec![Order::new(2, true)]); + + // press twice times, condition is desc. + order_manager.add_order(1); + assert_eq!(order_manager.orders, vec![Order::new(2, false)]); + + // press another column, this column is second order. + order_manager.add_order(2); + assert_eq!( + order_manager.orders, + vec![Order::new(2, false), Order::new(3, true)] + ); + + // press three times, removed. + order_manager.add_order(1); + assert_eq!(order_manager.orders, vec![Order::new(3, true)]); + } + #[test] fn test_expand_selected_area_x_left() { // before diff --git a/src/config.rs b/src/config.rs index 3d41831..fad288f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -101,6 +101,7 @@ pub struct KeyConfig { pub scroll_up_multiple_lines: Key, pub scroll_to_top: Key, pub scroll_to_bottom: Key, + pub sort_by_column: Key, pub extend_selection_by_one_cell_left: Key, pub extend_selection_by_one_cell_right: Key, pub extend_selection_by_one_cell_up: Key, @@ -140,6 +141,7 @@ impl Default for KeyConfig { scroll_up_multiple_lines: Key::Ctrl('u'), scroll_to_top: Key::Char('g'), scroll_to_bottom: Key::Char('G'), + sort_by_column: Key::Char('s'), extend_selection_by_one_cell_left: Key::Char('H'), extend_selection_by_one_cell_right: Key::Char('L'), extend_selection_by_one_cell_down: Key::Char('J'), diff --git a/src/database/mod.rs b/src/database/mod.rs index 46a771a..76a874a 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -22,6 +22,7 @@ pub trait Pool: Send + Sync { table: &Table, page: u16, filter: Option, + orders: Option, ) -> anyhow::Result<(Vec, Vec>)>; async fn get_columns( &self, diff --git a/src/database/mysql.rs b/src/database/mysql.rs index 160306f..1f70f73 100644 --- a/src/database/mysql.rs +++ b/src/database/mysql.rs @@ -228,21 +228,41 @@ impl Pool for MySqlPool { table: &Table, page: u16, filter: Option, + orders: Option, ) -> anyhow::Result<(Vec, Vec>)> { - let query = if let Some(filter) = filter { + let query = if let (Some(filter), Some(orders)) = (&filter, &orders) { + format!( + "SELECT * FROM `{database}`.`{table}` WHERE {filter} {orders} LIMIT {page}, {limit}", + database = database.name, + table = table.name, + filter = filter, + page = page, + limit = RECORDS_LIMIT_PER_PAGE, + orders = orders + ) + } else 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 + limit = RECORDS_LIMIT_PER_PAGE, + ) + } else if let Some(orders) = orders { + format!( + "SELECT * FROM `{database}`.`{table}` {orders} LIMIT {page}, {limit}", + database = database.name, + table = table.name, + orders = orders, + page = page, + limit = RECORDS_LIMIT_PER_PAGE, ) } else { format!( - "SELECT * FROM `{}`.`{}` LIMIT {page}, {limit}", - database.name, - table.name, + "SELECT * FROM `{database}`.`{table}` LIMIT {page}, {limit}", + database = database.name, + table = table.name, page = page, limit = RECORDS_LIMIT_PER_PAGE ) diff --git a/src/database/postgres.rs b/src/database/postgres.rs index ab954b0..f7493a0 100644 --- a/src/database/postgres.rs +++ b/src/database/postgres.rs @@ -245,8 +245,20 @@ impl Pool for PostgresPool { table: &Table, page: u16, filter: Option, + orders: Option, ) -> anyhow::Result<(Vec, Vec>)> { - let query = if let Some(filter) = filter.as_ref() { + let query = if let (Some(filter), Some(orders)) = (&filter, &orders) { + format!( + r#"SELECT * FROM "{database}"."{table_schema}"."{table}" WHERE {filter} {orders} LIMIT {limit} OFFSET {page}"#, + database = database.name, + table = table.name, + filter = filter, + orders = orders, + table_schema = table.schema.clone().unwrap_or_else(|| "public".to_string()), + page = page, + limit = RECORDS_LIMIT_PER_PAGE + ) + } else if let Some(filter) = &filter { format!( r#"SELECT * FROM "{database}"."{table_schema}"."{table}" WHERE {filter} LIMIT {limit} OFFSET {page}"#, database = database.name, @@ -256,6 +268,16 @@ impl Pool for PostgresPool { page = page, limit = RECORDS_LIMIT_PER_PAGE ) + } else if let Some(orders) = &orders { + format!( + r#"SELECT * FROM "{database}"."{table_schema}"."{table}" {orders} LIMIT {limit} OFFSET {page}"#, + database = database.name, + table = table.name, + orders = orders, + table_schema = table.schema.clone().unwrap_or_else(|| "public".to_string()), + page = page, + limit = RECORDS_LIMIT_PER_PAGE + ) } else { format!( r#"SELECT * FROM "{database}"."{table_schema}"."{table}" LIMIT {limit} OFFSET {page}"#, @@ -283,8 +305,14 @@ impl Pool for PostgresPool { Err(_) => { if json_records.is_none() { json_records = Some( - self.get_json_records(database, table, page, filter.clone()) - .await?, + self.get_json_records( + database, + table, + page, + filter.clone(), + orders.clone(), + ) + .await?, ); } if let Some(json_records) = &json_records { @@ -479,8 +507,20 @@ impl PostgresPool { table: &Table, page: u16, filter: Option, + orders: Option, ) -> anyhow::Result> { - let query = if let Some(filter) = filter { + let query = if let (Some(filter), Some(orders)) = (&filter, &orders) { + format!( + r#"SELECT to_json("{table}".*) FROM "{database}"."{table_schema}"."{table}" WHERE {filter} {orders} LIMIT {limit} OFFSET {page}"#, + database = database.name, + table = table.name, + filter = filter, + orders = orders, + table_schema = table.schema.clone().unwrap_or_else(|| "public".to_string()), + page = page, + limit = RECORDS_LIMIT_PER_PAGE + ) + } else if let Some(filter) = filter { format!( r#"SELECT to_json("{table}".*) FROM "{database}"."{table_schema}"."{table}" WHERE {filter} LIMIT {limit} OFFSET {page}"#, database = database.name, @@ -490,6 +530,16 @@ impl PostgresPool { page = page, limit = RECORDS_LIMIT_PER_PAGE ) + } else if let Some(orders) = orders { + format!( + r#"SELECT to_json("{table}".*) FROM "{database}"."{table_schema}"."{table}" {orders} LIMIT {limit} OFFSET {page}"#, + database = database.name, + table = table.name, + orders = orders, + table_schema = table.schema.clone().unwrap_or_else(|| "public".to_string()), + page = page, + limit = RECORDS_LIMIT_PER_PAGE + ) } else { format!( r#"SELECT to_json("{table}".*) FROM "{database}"."{table_schema}"."{table}" LIMIT {limit} OFFSET {page}"#, diff --git a/src/database/sqlite.rs b/src/database/sqlite.rs index 83063c7..5a0d5f4 100644 --- a/src/database/sqlite.rs +++ b/src/database/sqlite.rs @@ -230,19 +230,37 @@ impl Pool for SqlitePool { table: &Table, page: u16, filter: Option, + orders: Option, ) -> anyhow::Result<(Vec, Vec>)> { - let query = if let Some(filter) = filter { + let query = if let (Some(filter), Some(orders)) = (&filter, &orders) { + format!( + "SELECT * FROM `{table}` WHERE {filter} {orders} LIMIT {page}, {limit}", + table = table.name, + filter = filter, + page = page, + limit = RECORDS_LIMIT_PER_PAGE, + orders = orders + ) + } else if let Some(filter) = filter { format!( "SELECT * FROM `{table}` WHERE {filter} LIMIT {page}, {limit}", table = table.name, filter = filter, page = page, + limit = RECORDS_LIMIT_PER_PAGE, + ) + } else if let Some(orders) = orders { + format!( + "SELECT * FROM `{table}`{orders} LIMIT {page}, {limit}", + table = table.name, + orders = orders, + page = page, limit = RECORDS_LIMIT_PER_PAGE ) } else { format!( - "SELECT * FROM `{}` LIMIT {page}, {limit}", - table.name, + "SELECT * FROM `{table}` LIMIT {page}, {limit}", + table = table.name, page = page, limit = RECORDS_LIMIT_PER_PAGE )