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
)