v0.7.0: New Connection screen revamp + full MySQL DB viewer (#10)
* docs: correct competitor comparison for DB Pro, Beekeeper, TablePlus Research-verified the 'Why Gridline vs the alternatives' claims against vendor docs, pricing pages, GitHub, and release notes (May 2026): - DB Pro is an Electron app (founder-confirmed), not native; add TablePlus column to the comparison table - Fix wrong cells: DB Pro has query/dashboard folders + table tags and CSV/JSON export on the free tier; object-explorer depth corrected for DB Pro (tables/views/indexes/enums) and Beekeeper (tables/views/routines/triggers) - Reframe differentiators: unlimited-everything framing dropped for Beekeeper (free tier is already unlimited on tabs/connections/queries); keep DB-to-DB sync as the genuinely unique feature - Add a dated 'Competitor reality check' section to AGENTS.md so future edits don't re-assert inaccurate claims * docs: add project roadmap, link it from README and AGENTS New ROADMAP.md is the source of truth for planned work, reflecting the in-flight v0.7.0 connection-screen-revamp spec (new-connection flow, full MySQL DB viewer, capability gating, Supabase/Neon presets, SQLite path mode, tag overflow scroll, styling sweep). Next-up scope: PostgreSQL object management CRUD with companion features (schema CRUD, global object search, copy-as-DDL, object dependencies) and an admin follow-up (users/roles/grants, VACUUM/ANALYZE/REINDEX). MySQL Objects view explicitly deferred. Queue: Redis browsing, MariaDB/TimescaleDB, PlanetScale/Turso, query workbench upgrades (multiple result sets, query cancel, result streaming, visual query builder), schema/data tooling, SQLite .dump, schema diff, more export formats. Planned: BYOK AI, website & docs, rolling UI/UX polish (incl. onboarding tour, settings import/export, SSH key management). README roadmap section now links to ROADMAP.md; AGENTS.md Related Documents + Implementation Status reference it and the v0.7.0 spec. * docs: release notes reference prod as the production branch The repo's production branch is prod (feature branches merge back to prod), not main. Update the release-cut instructions in the README and the trigger comment in release.yml. * docs: add robust bug report issue template Structured .github/ISSUE_TEMPLATE/bug_report.md covering environment (OS, Gridline version, install type, DB type/version, hosted provider, connection method incl. SSH/TLS/socket), steps to reproduce, expected vs actual, screenshots, logs, impact, and workarounds — plus a duplicate checklist and secrets-redaction note. Referenced from the README Contributing section. * docs: drop in-flight branch mention from roadmap; remove unused starter assets - ROADMAP.md no longer references the in-flight feature branch/spec (removed at the end anyway when the branch PRs into prod) - Remove unused Vite/Tauri starter SVGs from public/ (no favicon or asset references anywhere in the app) * test: fix stale README comparison-table regex in docs-coverage (5-col table) * feat: shared INPUT_ROUNDING constant + bump to v0.7.0 (Task 1.1) * fix: map SQLite file path to host field + provider host detection (Task 1.2) * test: bump version expectation to 0.7.0 (Task 1.1 follow-up) * feat: db capability matrix for DB viewer gating (Task 1.3) * feat: provider tab definitions, Supabase/Neon icons + setup guides (Task 1.4) * feat(rust): MySQL SQL builders + identifier quoting (Task 2.1) * chore(rust): sync Cargo.lock to gridline 0.7.0 * feat(rust): MySQL db_connect (SSL + SSH tunnel) + pool variant (Tasks 2.2-2.3) * feat(rust): MySQL execute_query with wrapped pagination + raw fallback (Task 2.4) * feat(rust): MySQL introspection + changes-queue editing + DDL (Task 2.5) * fix(ui): show table toolbar immediately while tab is still loading first data * feat: gate DB viewer sidebar nav by db capabilities (Task 3.1) * feat: guard DB viewer views by capability + Redis unsupported state (Task 3.2) * feat(ui): 2-column provider tab grid (Task 4.1) * feat(ui): collapsible Supabase/Neon setup guide (Task 4.2) * feat(ui): SQLite file-path input with Browse (Task 4.3) * feat(ui): connection metadata row (label + tags/env/folder) (Task 4.4) * feat(ui): rework GeneralTab (URI + OR + manual) + reduce Detailed form tabs (Task 4.5) * feat(ui): NewConnectionScreen two-stage flow; remove SimpleConnectionForm (Task 5.1) * feat(ui): scroll connection-card tag row past 3 tags (Task 5.2) * style: sweep form controls from rounded-full to rounded-lg (Task 5.3) * feat(ui): EditConnectionModal parity + managed-preset SSL hint (Task 5.4) * fix(rust): decode MySQL VARBINARY metadata columns (information_schema/SHOW) as strings * test: full suite green for v0.7.0 connection revamp (Task 5.5) * feat(ui): schema dropdown + tables tree loading state while schema tree fetches * docs: update AGENTS/README/ROADMAP for v0.7.0 (connection revamp, MySQL viewer, gating)
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
//! Pure MySQL SQL builders for the DB viewer. Identifiers are backtick-quoted
|
||||
//! (never string-concatenated); values are bound via `?` placeholders at the
|
||||
//! call site. Mirrors the PG builders in `commands/db_viewer.rs` but with
|
||||
//! MySQL quoting and `LIMIT 1` on single-row UPDATE/DELETE.
|
||||
use crate::models::db_viewer::{FilterRule, SortRule};
|
||||
use sqlx::Row;
|
||||
|
||||
/// `SHOW DATABASES` — the browsing branches filter system DBs client-side
|
||||
/// (see [`MYSQL_SYSTEM_DBS`]).
|
||||
pub fn mysql_databases_query() -> String {
|
||||
"SHOW DATABASES".to_string()
|
||||
}
|
||||
|
||||
/// System databases hidden from the DB viewer's database/schema selector.
|
||||
pub const MYSQL_SYSTEM_DBS: [&str; 4] = ["information_schema", "mysql", "performance_schema", "sys"];
|
||||
|
||||
/// Quote a MySQL identifier with backticks, doubling any embedded backticks.
|
||||
pub fn mysql_quote_ident(name: &str) -> String {
|
||||
format!("`{}`", name.replace('`', "``"))
|
||||
}
|
||||
|
||||
/// Decode a MySQL row cell as a String. `information_schema` / `SHOW`
|
||||
/// metadata columns can surface as VARBINARY (bytes) depending on the
|
||||
/// connection charset, so fall back from String to a UTF-8 lossy decode.
|
||||
pub fn mysql_row_string(row: &sqlx::mysql::MySqlRow, i: usize) -> String {
|
||||
if let Ok(s) = row.try_get::<String, _>(i) {
|
||||
return s;
|
||||
}
|
||||
if let Ok(b) = row.try_get::<Vec<u8>, _>(i) {
|
||||
return String::from_utf8_lossy(&b).into_owned();
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// information_schema.columns query for a table — returns column metadata in
|
||||
/// the column order the grid expects (name, data_type, is_nullable, column_key,
|
||||
/// default, extra). Caller maps these into `ColumnInfo`.
|
||||
pub fn mysql_columns_query(schema: &str, table: &str) -> String {
|
||||
format!(
|
||||
"SELECT column_name, data_type, is_nullable, column_key, column_default, extra \
|
||||
FROM information_schema.columns \
|
||||
WHERE table_schema = '{}' AND table_name = '{}' \
|
||||
ORDER BY ordinal_position",
|
||||
schema.replace('\'', "''"),
|
||||
table.replace('\'', "''")
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a `SELECT ... FROM \`schema\`.\`table\` [WHERE ...] [ORDER BY ...] LIMIT ? OFFSET ?`.
|
||||
/// `filters` produce `?` placeholders (values bound by the caller); `sorts`
|
||||
/// are quoted identifiers. `default_sort` is used when `sorts` is empty.
|
||||
pub fn mysql_select_data_query(
|
||||
schema: &str,
|
||||
table: &str,
|
||||
filters: &[FilterRule],
|
||||
sorts: &[SortRule],
|
||||
default_sort: &str,
|
||||
) -> String {
|
||||
let mut where_parts: Vec<String> = Vec::new();
|
||||
for f in filters {
|
||||
let col = mysql_quote_ident(&f.column);
|
||||
let op = match f.operator.as_str() {
|
||||
"eq" => format!("{} = ?", col),
|
||||
"neq" => format!("{} <> ?", col),
|
||||
"contains" => format!("{} LIKE CONCAT('%', ?, '%')", col),
|
||||
"starts" => format!("{} LIKE CONCAT(?, '%')", col),
|
||||
"ends" => format!("{} LIKE CONCAT('%', ?)", col),
|
||||
"gt" => format!("{} > ?", col),
|
||||
"lt" => format!("{} < ?", col),
|
||||
"null" => format!("{} IS NULL", col),
|
||||
"notnull" => format!("{} IS NOT NULL", col),
|
||||
_ => format!("{} = ?", col),
|
||||
};
|
||||
where_parts.push(op);
|
||||
}
|
||||
let where_clause = if where_parts.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" WHERE {}", where_parts.join(" AND "))
|
||||
};
|
||||
|
||||
let order_cols: Vec<String> = sorts
|
||||
.iter()
|
||||
.map(|s| format!("{} {}", mysql_quote_ident(&s.column), if s.order.eq_ignore_ascii_case("desc") { "DESC" } else { "ASC" }))
|
||||
.collect();
|
||||
let order_clause = if order_cols.is_empty() {
|
||||
if default_sort.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ORDER BY {}", mysql_quote_ident(default_sort))
|
||||
}
|
||||
} else {
|
||||
format!(" ORDER BY {}", order_cols.join(", "))
|
||||
};
|
||||
|
||||
format!(
|
||||
"SELECT * FROM {}{}{} LIMIT ? OFFSET ?",
|
||||
qualified(schema, table),
|
||||
where_clause,
|
||||
order_clause
|
||||
)
|
||||
}
|
||||
|
||||
pub fn mysql_count_query(schema: &str, table: &str) -> String {
|
||||
format!("SELECT COUNT(*) FROM {}", qualified(schema, table))
|
||||
}
|
||||
|
||||
pub fn mysql_ddl_query(schema: &str, table: &str) -> String {
|
||||
format!("SHOW CREATE TABLE {}", qualified(schema, table))
|
||||
}
|
||||
|
||||
/// Foreign-key columns for a table (referenced table/column).
|
||||
pub fn mysql_fk_query(schema: &str, table: &str) -> String {
|
||||
format!(
|
||||
"SELECT column_name, referenced_table_schema, referenced_table_name, referenced_column_name \
|
||||
FROM information_schema.key_column_usage \
|
||||
WHERE table_schema = '{}' AND table_name = '{}' AND referenced_table_name IS NOT NULL",
|
||||
schema.replace('\'', "''"),
|
||||
table.replace('\'', "''")
|
||||
)
|
||||
}
|
||||
|
||||
/// Choose a default sort column: prefer an `id`-like column, else the first.
|
||||
pub fn mysql_default_sort(columns: &[String]) -> &str {
|
||||
columns.iter().find(|c| c.as_str() == "id").map(|s| s.as_str()).unwrap_or_else(|| {
|
||||
columns.first().map(|s| s.as_str()).unwrap_or("")
|
||||
})
|
||||
}
|
||||
|
||||
// ── Change-SQL builders ──────────────────────────────────────────────
|
||||
pub fn mysql_build_update_sql(
|
||||
schema: &str,
|
||||
table: &str,
|
||||
primary_key: &[(String, serde_json::Value)],
|
||||
new_data: &[(String, serde_json::Value)],
|
||||
) -> Result<(String, Vec<serde_json::Value>), String> {
|
||||
if primary_key.is_empty() {
|
||||
return Err("cannot update a row without a primary key (MySQL has no ctid)".to_string());
|
||||
}
|
||||
let mut params: Vec<serde_json::Value> = Vec::new();
|
||||
let set_clause: Vec<String> = new_data
|
||||
.iter()
|
||||
.map(|(col, val)| { params.push(val.clone()); format!("{} = ?", mysql_quote_ident(col)) })
|
||||
.collect();
|
||||
let where_clause: Vec<String> = primary_key
|
||||
.iter()
|
||||
.map(|(col, val)| { params.push(val.clone()); format!("{} = ?", mysql_quote_ident(col)) })
|
||||
.collect();
|
||||
Ok((
|
||||
format!(
|
||||
"UPDATE {} SET {} WHERE {} LIMIT 1",
|
||||
qualified(schema, table),
|
||||
set_clause.join(", "),
|
||||
where_clause.join(" AND ")
|
||||
),
|
||||
params,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn mysql_build_delete_sql(
|
||||
schema: &str,
|
||||
table: &str,
|
||||
primary_key: &[(String, serde_json::Value)],
|
||||
) -> Result<(String, Vec<serde_json::Value>), String> {
|
||||
if primary_key.is_empty() {
|
||||
return Err("cannot delete a row without a primary key (MySQL has no ctid)".to_string());
|
||||
}
|
||||
let mut params: Vec<serde_json::Value> = Vec::new();
|
||||
let where_clause: Vec<String> = primary_key
|
||||
.iter()
|
||||
.map(|(col, val)| { params.push(val.clone()); format!("{} = ?", mysql_quote_ident(col)) })
|
||||
.collect();
|
||||
Ok((
|
||||
format!("DELETE FROM {} WHERE {} LIMIT 1", qualified(schema, table), where_clause.join(" AND ")),
|
||||
params,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn mysql_build_insert_sql(
|
||||
schema: &str,
|
||||
table: &str,
|
||||
pairs: &[(String, serde_json::Value)],
|
||||
) -> (String, Vec<serde_json::Value>) {
|
||||
let cols: Vec<String> = pairs.iter().map(|(c, _)| mysql_quote_ident(c)).collect();
|
||||
let placeholders: Vec<&str> = pairs.iter().map(|_| "?").collect();
|
||||
let params: Vec<serde_json::Value> = pairs.iter().map(|(_, v)| v.clone()).collect();
|
||||
(
|
||||
format!(
|
||||
"INSERT INTO {} ({}) VALUES ({})",
|
||||
qualified(schema, table),
|
||||
cols.join(", "),
|
||||
placeholders.join(", ")
|
||||
),
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn mysql_build_bulk_insert_sql(schema: &str, table: &str, columns: &[String], row_count: usize) -> String {
|
||||
let cols: Vec<String> = columns.iter().map(|c| mysql_quote_ident(c)).collect();
|
||||
let one_row = format!("({})", columns.iter().map(|_| "?").collect::<Vec<_>>().join(", "));
|
||||
let rows = vec![one_row; row_count].join(", ");
|
||||
format!("INSERT INTO {} ({}) VALUES {}", qualified(schema, table), cols.join(", "), rows)
|
||||
}
|
||||
|
||||
pub fn mysql_build_drop_sql(schema: &str, table: &str) -> String {
|
||||
format!("DROP TABLE {}", qualified(schema, table))
|
||||
}
|
||||
|
||||
pub fn mysql_build_empty_sql(schema: &str, table: &str) -> String {
|
||||
format!("DELETE FROM {}", qualified(schema, table))
|
||||
}
|
||||
|
||||
fn qualified(schema: &str, table: &str) -> String {
|
||||
format!("{}.{}", mysql_quote_ident(schema), mysql_quote_ident(table))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::db_viewer::FilterRule;
|
||||
|
||||
#[test]
|
||||
fn quote_ident_backticks_and_escapes_embedded_backticks() {
|
||||
assert_eq!(mysql_quote_ident("name"), "`name`");
|
||||
assert_eq!(mysql_quote_ident("o`d`d"), "`o``d``d`");
|
||||
assert_eq!(mysql_quote_ident("select"), "`select`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_query_targets_information_schema() {
|
||||
let q = mysql_columns_query("shop", "orders");
|
||||
assert!(q.contains("FROM information_schema.columns"));
|
||||
assert!(q.contains("table_schema = 'shop'"));
|
||||
assert!(q.contains("table_name = 'orders'"));
|
||||
assert!(q.contains("ORDER BY ordinal_position"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_data_quotes_schema_table_and_applies_limit_offset() {
|
||||
let q = mysql_select_data_query("shop", "orders", &[], &[], "id");
|
||||
assert!(q.contains("SELECT * FROM `shop`.`orders`"));
|
||||
assert!(q.contains("ORDER BY `id`"));
|
||||
assert!(q.contains("LIMIT ? OFFSET ?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_data_adds_where_for_filters_with_question_placeholders() {
|
||||
let filters = vec![FilterRule { id: "f1".into(), column: "status".into(), operator: "eq".into(), value: "paid".into() }];
|
||||
let q = mysql_select_data_query("shop", "orders", &filters, &[], "id");
|
||||
assert!(q.contains("WHERE `status` = ?"));
|
||||
assert!(q.contains("LIMIT ? OFFSET ?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_query_uses_quoted_table() {
|
||||
let q = mysql_count_query("shop", "orders");
|
||||
assert_eq!(q, "SELECT COUNT(*) FROM `shop`.`orders`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ddl_query_uses_show_create_table() {
|
||||
let q = mysql_ddl_query("shop", "orders");
|
||||
assert_eq!(q, "SHOW CREATE TABLE `shop`.`orders`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fk_query_targets_key_column_usage() {
|
||||
let q = mysql_fk_query("shop", "orders");
|
||||
assert!(q.contains("FROM information_schema.key_column_usage"));
|
||||
assert!(q.contains("referenced_table_name IS NOT NULL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_update_uses_backticks_question_and_limit_one() {
|
||||
let pk = vec![("id".to_string(), serde_json::json!(1))];
|
||||
let data = vec![("status".to_string(), serde_json::json!("paid"))];
|
||||
let (sql, params) = mysql_build_update_sql("shop", "orders", &pk, &data).unwrap();
|
||||
assert_eq!(sql, "UPDATE `shop`.`orders` SET `status` = ? WHERE `id` = ? LIMIT 1");
|
||||
assert_eq!(params, vec![serde_json::json!("paid"), serde_json::json!(1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_update_rejects_empty_primary_key() {
|
||||
let r = mysql_build_update_sql("shop", "orders", &[], &[]);
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_delete_uses_backticks_question_and_limit_one() {
|
||||
let pk = vec![("id".to_string(), serde_json::json!(1))];
|
||||
let (sql, params) = mysql_build_delete_sql("shop", "orders", &pk).unwrap();
|
||||
assert_eq!(sql, "DELETE FROM `shop`.`orders` WHERE `id` = ? LIMIT 1");
|
||||
assert_eq!(params, vec![serde_json::json!(1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_insert_emits_columns_and_question_placeholders() {
|
||||
let pairs = vec![
|
||||
("a".to_string(), serde_json::json!(1)),
|
||||
("b".to_string(), serde_json::json!("x")),
|
||||
];
|
||||
let (sql, params) = mysql_build_insert_sql("shop", "orders", &pairs);
|
||||
assert_eq!(sql, "INSERT INTO `shop`.`orders` (`a`, `b`) VALUES (?, ?)");
|
||||
assert_eq!(params, vec![serde_json::json!(1), serde_json::json!("x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_drop_and_empty_table_quoted() {
|
||||
assert_eq!(mysql_build_drop_sql("shop", "orders"), "DROP TABLE `shop`.`orders`");
|
||||
assert_eq!(mysql_build_empty_sql("shop", "orders"), "DELETE FROM `shop`.`orders`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_bulk_insert_columns_and_placeholders() {
|
||||
let cols = vec!["a".to_string(), "b".to_string()];
|
||||
let sql = mysql_build_bulk_insert_sql("shop", "orders", &cols, 2);
|
||||
assert_eq!(sql, "INSERT INTO `shop`.`orders` (`a`, `b`) VALUES (?, ?), (?, ?)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_sort_picks_id_then_first_column() {
|
||||
assert_eq!(mysql_default_sort(&["updated_at".into(), "id".into()]), "id");
|
||||
assert_eq!(mysql_default_sort(&["name".into()]), "name");
|
||||
assert_eq!(mysql_default_sort(&[]), "");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user