1 Commits

Author SHA1 Message Date
oysteikt 772942eed0 WIP: show-db: limit output to 5 tables/users by default 2026-05-31 05:51:02 +09:00
7 changed files with 231 additions and 127 deletions
+2 -2
View File
@@ -23,7 +23,7 @@ use crate::{
parse_privilege_data_from_editor_content, reduce_privilege_diffs, parse_privilege_data_from_editor_content, reduce_privilege_diffs,
}, },
protocol::{ protocol::{
ClientToServerMessageStream, ListDatabasesError, ListUsersError, ClientToServerMessageStream, ListDatabasesError, ListDatabasesRequest, ListUsersError,
ModifyDatabasePrivilegesError, Request, Response, ModifyDatabasePrivilegesError, Request, Response,
print_modify_database_privileges_output_status, request_validation::ValidationError, print_modify_database_privileges_output_status, request_validation::ValidationError,
}, },
@@ -132,7 +132,7 @@ async fn databases_exist(
.map(|diff| diff.get_database_name().clone()) .map(|diff| diff.get_database_name().clone())
.collect(); .collect();
let message = Request::ListDatabases(Some(database_list)); let message = Request::ListDatabases(ListDatabasesRequest::new(Some(database_list), false));
server_connection.send(message).await?; server_connection.send(message).await?;
let result = match server_connection.next().await { let result = match server_connection.next().await {
+14 -7
View File
@@ -8,8 +8,8 @@ use crate::{
core::{ core::{
completion::mysql_database_completer, completion::mysql_database_completer,
protocol::{ protocol::{
ClientToServerMessageStream, ListDatabasesError, Request, Response, ClientToServerMessageStream, ListDatabasesError, ListDatabasesRequest, Request,
print_list_databases_output_status, print_list_databases_output_status_json, Response, print_list_databases_output_status, print_list_databases_output_status_json,
request_validation::ValidationError, request_validation::ValidationError,
}, },
types::MySQLDatabase, types::MySQLDatabase,
@@ -27,6 +27,10 @@ pub struct ShowDbArgs {
#[arg(short, long)] #[arg(short, long)]
json: bool, json: bool,
/// Show all tables and users for each database
#[arg(short = 'a', long)]
all: bool,
/// Show sizes in bytes instead of human-readable format /// Show sizes in bytes instead of human-readable format
#[arg(short, long)] #[arg(short, long)]
bytes: bool, bytes: bool,
@@ -36,11 +40,14 @@ pub async fn show_databases(
args: ShowDbArgs, args: ShowDbArgs,
mut server_connection: ClientToServerMessageStream, mut server_connection: ClientToServerMessageStream,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let message = if args.name.is_empty() { let message = Request::ListDatabases(ListDatabasesRequest::new(
Request::ListDatabases(None) if args.name.is_empty() {
} else { None
Request::ListDatabases(Some(args.name.clone())) } else {
}; Some(args.name.clone())
},
args.all || args.json,
));
server_connection.send(message).await?; server_connection.send(message).await?;
@@ -22,8 +22,8 @@ use crate::{
completion::{mysql_database_completer, prefix_completer}, completion::{mysql_database_completer, prefix_completer},
database_privileges::DatabasePrivilegeRow, database_privileges::DatabasePrivilegeRow,
protocol::{ protocol::{
ClientToServerMessageStream, ListPrivilegesError, Request, Response, ClientToServerMessageStream, ListDatabasesRequest, ListPrivilegesError, Request,
create_client_to_server_message_stream, Response, create_client_to_server_message_stream,
}, },
types::MySQLDatabase, types::MySQLDatabase,
}, },
@@ -285,7 +285,7 @@ async fn show_databases(
args.name.iter().map(trim_db_name_to_32_chars).collect(); args.name.iter().map(trim_db_name_to_32_chars).collect();
let message = if database_names.is_empty() { let message = if database_names.is_empty() {
let message = Request::ListDatabases(None); let message = Request::ListDatabases(ListDatabasesRequest::new(None, false));
server_connection.send(message).await?; server_connection.send(message).await?;
let response = server_connection.next().await; let response = server_connection.next().await;
let databases = match response { let databases = match response {
+8 -4
View File
@@ -142,7 +142,8 @@ impl Request {
Request::ListDatabases(req) => format!( Request::ListDatabases(req) => format!(
"{}{}", "{}{}",
self.command_name(), self.command_name(),
req.as_ref() req.names
.as_ref()
.map_or("".to_string(), |r| format!("({})", r.len())) .map_or("".to_string(), |r| format!("({})", r.len()))
), ),
Request::ListPrivileges(req) => format!( Request::ListPrivileges(req) => format!(
@@ -206,9 +207,12 @@ impl Request {
Request::CompleteUserName(_) => Default::default(), Request::CompleteUserName(_) => Default::default(),
Request::CreateDatabases(databases) => databases.iter().cloned().collect(), Request::CreateDatabases(databases) => databases.iter().cloned().collect(),
Request::DropDatabases(databases) => databases.iter().cloned().collect(), Request::DropDatabases(databases) => databases.iter().cloned().collect(),
Request::ListDatabases(databases) => { Request::ListDatabases(request) => request
databases.clone().unwrap_or_default().into_iter().collect() .names
} .clone()
.unwrap_or_default()
.into_iter()
.collect(),
Request::ListPrivileges(databases) => { Request::ListPrivileges(databases) => {
databases.clone().unwrap_or_default().into_iter().collect() databases.clone().unwrap_or_default().into_iter().collect()
} }
+30 -2
View File
@@ -14,7 +14,21 @@ use crate::{
server::sql::database_operations::DatabaseRow, server::sql::database_operations::DatabaseRow,
}; };
pub type ListDatabasesRequest = Option<Vec<MySQLDatabase>>; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ListDatabasesRequest {
pub names: Option<Vec<MySQLDatabase>>,
#[serde(default)]
pub include_all_tables_and_users: bool,
}
impl ListDatabasesRequest {
pub fn new(names: Option<Vec<MySQLDatabase>>, include_all_tables_and_users: bool) -> Self {
Self {
names,
include_all_tables_and_users,
}
}
}
pub type ListDatabasesResponse = BTreeMap<MySQLDatabase, Result<DatabaseRow, ListDatabasesError>>; pub type ListDatabasesResponse = BTreeMap<MySQLDatabase, Result<DatabaseRow, ListDatabasesError>>;
@@ -144,7 +158,7 @@ mod tests {
#[test] #[test]
fn test_serialize_deserialize_request() { fn test_serialize_deserialize_request() {
let request = Some(vec!["db1".into(), "db2".into()]); let request = ListDatabasesRequest::new(Some(vec!["db1".into(), "db2".into()]), true);
let json = serde_json::to_string_pretty(&request).unwrap(); let json = serde_json::to_string_pretty(&request).unwrap();
println!("Serialized request:\n{}", json); println!("Serialized request:\n{}", json);
@@ -152,6 +166,20 @@ mod tests {
assert_eq!(request, deserialized); assert_eq!(request, deserialized);
} }
#[test]
fn test_deserialize_request_without_include_all_tables_and_users_defaults_to_false() {
let json = serde_json::json!({
"names": ["db1", "db2"]
})
.to_string();
let deserialized: ListDatabasesRequest = serde_json::from_str(&json).unwrap();
assert_eq!(
deserialized,
ListDatabasesRequest::new(Some(vec!["db1".into(), "db2".into()]), false)
);
}
#[test] #[test]
fn test_serialize_deserialize_response() { fn test_serialize_deserialize_response() {
let response: ListDatabasesResponse = vec![ let response: ListDatabasesResponse = vec![
+4 -2
View File
@@ -332,14 +332,15 @@ async fn handle_request(
.await; .await;
Response::DropDatabases(result) Response::DropDatabases(result)
} }
Request::ListDatabases(ref database_names) => { Request::ListDatabases(ref request) => {
if let Some(database_names) = database_names { if let Some(database_names) = &request.names {
let result = list_databases( let result = list_databases(
database_names, database_names,
unix_user, unix_user,
db_connection, db_connection,
db_is_mariadb, db_is_mariadb,
group_denylist, group_denylist,
request.include_all_tables_and_users,
) )
.await; .await;
Response::ListDatabases(result) Response::ListDatabases(result)
@@ -349,6 +350,7 @@ async fn handle_request(
db_connection, db_connection,
db_is_mariadb, db_is_mariadb,
group_denylist, group_denylist,
request.include_all_tables_and_users,
) )
.await; .await;
Response::ListAllDatabases(result) Response::ListAllDatabases(result)
+170 -107
View File
@@ -24,6 +24,8 @@ use crate::{
server::{common::create_user_group_matching_regex, sql::quote_identifier}, server::{common::create_user_group_matching_regex, sql::quote_identifier},
}; };
const MAX_SHOW_DB_RELATED_ITEMS: usize = 5;
// NOTE: this function is unsafe because it does no input validation. // NOTE: this function is unsafe because it does no input validation.
pub(super) async fn unsafe_database_exists( pub(super) async fn unsafe_database_exists(
database_name: &str, database_name: &str,
@@ -248,12 +250,84 @@ impl FromRow<'_, sqlx::mysql::MySqlRow> for DatabaseRow {
} }
} }
fn list_database_query(include_all_tables_and_users: bool) -> AssertSqlSafe<String> {
let limit_clause = if include_all_tables_and_users {
"".to_string()
} else {
format!(" LIMIT {}", MAX_SHOW_DB_RELATED_ITEMS)
};
AssertSqlSafe(format!(
r"
SELECT
CAST(s.SCHEMA_NAME AS CHAR(64)) AS `database`,
t.tables,
u.users,
s.DEFAULT_COLLATION_NAME AS `collation`,
s.DEFAULT_CHARACTER_SET_NAME AS `character_set`,
CAST(COALESCE(sz.size_bytes, 0) AS UNSIGNED) AS size_bytes
FROM information_schema.SCHEMATA s
LEFT JOIN (
SELECT
x.TABLE_SCHEMA,
GROUP_CONCAT(x.TABLE_NAME ORDER BY x.TABLE_NAME SEPARATOR ',') AS tables
FROM (
SELECT
TABLE_SCHEMA,
TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ?
ORDER BY TABLE_NAME{limit_clause}
) x
GROUP BY x.TABLE_SCHEMA
) t
ON t.TABLE_SCHEMA = s.SCHEMA_NAME
LEFT JOIN (
SELECT
x.DB,
GROUP_CONCAT(DISTINCT x.User ORDER BY x.User SEPARATOR ',') AS users
FROM (
SELECT
DB,
User
FROM mysql.db
WHERE DB = ?
ORDER BY User{limit_clause}
) x
GROUP BY x.DB
) u
ON u.DB = s.SCHEMA_NAME
LEFT JOIN (
SELECT
TABLE_SCHEMA,
SUM(DATA_LENGTH + INDEX_LENGTH) AS size_bytes
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ?
GROUP BY TABLE_SCHEMA
) sz
ON sz.TABLE_SCHEMA = s.SCHEMA_NAME
WHERE s.SCHEMA_NAME REGEXP ?
AND s.SCHEMA_NAME NOT IN (
'information_schema',
'performance_schema',
'mysql',
'sys'
)
"
))
}
pub async fn list_databases( pub async fn list_databases(
database_names: &[MySQLDatabase], database_names: &[MySQLDatabase],
unix_user: &UnixUser, unix_user: &UnixUser,
connection: &mut MySqlConnection, connection: &mut MySqlConnection,
_db_is_mariadb: bool, _db_is_mariadb: bool,
group_denylist: &GroupDenylist, group_denylist: &GroupDenylist,
include_all_tables_and_users: bool,
) -> ListDatabasesResponse { ) -> ListDatabasesResponse {
let mut results = BTreeMap::new(); let mut results = BTreeMap::new();
@@ -269,58 +343,19 @@ pub async fn list_databases(
continue; continue;
} }
let result = sqlx::query_as::<_, DatabaseRow>( let query = list_database_query(include_all_tables_and_users);
r"
SELECT
CAST(s.SCHEMA_NAME AS CHAR(64)) AS `database`,
t.tables,
u.users,
s.DEFAULT_COLLATION_NAME AS `collation`,
s.DEFAULT_CHARACTER_SET_NAME AS `character_set`,
CAST(COALESCE(t.size_bytes, 0) AS UNSIGNED) AS `size_bytes`
FROM information_schema.SCHEMATA s
LEFT JOIN ( let result = sqlx::query_as::<_, DatabaseRow>(query)
SELECT .bind(database_name.to_string())
TABLE_SCHEMA, .bind(database_name.to_string())
GROUP_CONCAT( .bind(database_name.to_string())
DISTINCT CAST(TABLE_NAME AS CHAR(64)) .bind(database_name.to_string())
ORDER BY TABLE_NAME .fetch_optional(&mut *connection)
SEPARATOR ',' .await
) AS tables, .map_err(|err| ListDatabasesError::MySqlError(err.to_string()))
SUM(DATA_LENGTH + INDEX_LENGTH) AS size_bytes .and_then(|database| {
FROM information_schema.TABLES database.map_or_else(|| Err(ListDatabasesError::DatabaseDoesNotExist), Ok)
WHERE TABLE_SCHEMA = ? });
GROUP BY TABLE_SCHEMA
) t
ON t.TABLE_SCHEMA = s.SCHEMA_NAME
LEFT JOIN (
SELECT
DB,
GROUP_CONCAT(
DISTINCT CAST(User AS CHAR(64))
ORDER BY User
SEPARATOR ','
) AS users
FROM mysql.db
WHERE DB = ?
GROUP BY DB
) u
ON u.DB = s.SCHEMA_NAME
WHERE s.SCHEMA_NAME = ?;
",
)
.bind(database_name.to_string())
.bind(database_name.to_string())
.bind(database_name.to_string())
.fetch_optional(&mut *connection)
.await
.map_err(|err| ListDatabasesError::MySqlError(err.to_string()))
.and_then(|database| {
database.map_or_else(|| Err(ListDatabasesError::DatabaseDoesNotExist), Ok)
});
if let Err(err) = &result { if let Err(err) = &result {
tracing::error!("Failed to list database '{}': {:?}", &database_name, err); tracing::error!("Failed to list database '{}': {:?}", &database_name, err);
@@ -334,69 +369,97 @@ pub async fn list_databases(
results results
} }
fn list_all_databases_for_user_query(include_all_tables_and_users: bool) -> AssertSqlSafe<String> {
let limit_clause = if include_all_tables_and_users {
"".to_string()
} else {
format!(" LIMIT {}", MAX_SHOW_DB_RELATED_ITEMS)
};
AssertSqlSafe(format!(
r"
SELECT
CAST(s.SCHEMA_NAME AS CHAR(64)) AS `database`,
t.tables,
u.users,
s.DEFAULT_COLLATION_NAME AS `collation`,
s.DEFAULT_CHARACTER_SET_NAME AS `character_set`,
CAST(COALESCE(sz.size_bytes, 0) AS UNSIGNED) AS size_bytes
FROM information_schema.SCHEMATA s
LEFT JOIN (
SELECT
x.TABLE_SCHEMA,
GROUP_CONCAT(x.TABLE_NAME ORDER BY x.TABLE_NAME SEPARATOR ',') AS tables
FROM (
SELECT
TABLE_SCHEMA,
TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA REGEXP ?
ORDER BY TABLE_NAME{limit_clause}
) x
GROUP BY x.TABLE_SCHEMA
) t
ON t.TABLE_SCHEMA = s.SCHEMA_NAME
LEFT JOIN (
SELECT
x.DB,
GROUP_CONCAT(DISTINCT x.User ORDER BY x.User SEPARATOR ',') AS users
FROM (
SELECT
DB,
User
FROM mysql.db
WHERE DB REGEXP ?
ORDER BY User{limit_clause}
) x
GROUP BY x.DB
) u
ON u.DB = s.SCHEMA_NAME
LEFT JOIN (
SELECT
TABLE_SCHEMA,
SUM(DATA_LENGTH + INDEX_LENGTH) AS size_bytes
FROM information_schema.TABLES
WHERE TABLE_SCHEMA REGEXP ?
GROUP BY TABLE_SCHEMA
) sz
ON sz.TABLE_SCHEMA = s.SCHEMA_NAME
WHERE s.SCHEMA_NAME REGEXP ?
AND s.SCHEMA_NAME NOT IN (
'information_schema',
'performance_schema',
'mysql',
'sys'
)
ORDER BY s.SCHEMA_NAME
"
))
}
pub async fn list_all_databases_for_user( pub async fn list_all_databases_for_user(
unix_user: &UnixUser, unix_user: &UnixUser,
connection: &mut MySqlConnection, connection: &mut MySqlConnection,
_db_is_mariadb: bool, _db_is_mariadb: bool,
group_denylist: &GroupDenylist, group_denylist: &GroupDenylist,
include_all_tables_and_users: bool,
) -> ListAllDatabasesResponse { ) -> ListAllDatabasesResponse {
let result = sqlx::query_as::<_, DatabaseRow>( let query = list_all_databases_for_user_query(include_all_tables_and_users);
r" let user_group_regex = create_user_group_matching_regex(unix_user, group_denylist);
SELECT
CAST(s.SCHEMA_NAME AS CHAR(64)) AS `database`,
t.tables,
u.users,
s.DEFAULT_COLLATION_NAME AS collation,
s.DEFAULT_CHARACTER_SET_NAME AS character_set,
CAST(COALESCE(t.size_bytes, 0) AS UNSIGNED) AS size_bytes
FROM information_schema.SCHEMATA s
LEFT JOIN ( let result = sqlx::query_as::<_, DatabaseRow>(query)
SELECT .bind(&user_group_regex)
TABLE_SCHEMA, .bind(&user_group_regex)
GROUP_CONCAT( .bind(&user_group_regex)
DISTINCT CAST(TABLE_NAME AS CHAR(64)) .bind(&user_group_regex)
ORDER BY TABLE_NAME .fetch_all(connection)
SEPARATOR ',' .await
) AS tables, .map_err(|err| ListAllDatabasesError::MySqlError(err.to_string()));
SUM(DATA_LENGTH + INDEX_LENGTH) AS size_bytes
FROM information_schema.TABLES
WHERE TABLE_SCHEMA REGEXP ?
GROUP BY TABLE_SCHEMA
) t
ON t.TABLE_SCHEMA = s.SCHEMA_NAME
LEFT JOIN (
SELECT
DB,
GROUP_CONCAT(
DISTINCT CAST(User AS CHAR(64))
ORDER BY User
SEPARATOR ','
) AS users
FROM mysql.db
WHERE DB REGEXP ?
GROUP BY DB
) u
ON u.DB = s.SCHEMA_NAME
WHERE s.SCHEMA_NAME REGEXP ?
AND s.SCHEMA_NAME NOT IN (
'information_schema',
'performance_schema',
'mysql',
'sys'
)
ORDER BY s.SCHEMA_NAME
",
)
.bind(create_user_group_matching_regex(unix_user, group_denylist))
.bind(create_user_group_matching_regex(unix_user, group_denylist))
.bind(create_user_group_matching_regex(unix_user, group_denylist))
.fetch_all(connection)
.await
.map_err(|err| ListAllDatabasesError::MySqlError(err.to_string()));
// TODO: should we assert that the users are also owned by the unix_user from the request? // TODO: should we assert that the users are also owned by the unix_user from the request?