{
+ let is_inserted = self.state.is_inserted_row(row_ix);
+ let is_deleted = self.state.is_deleted_row(row_ix);
+ let is_stripe = row_ix % 2 != 0;
+
+ div()
+ .id(("row", row_ix))
+ .relative()
+ .when(is_deleted, |this| {
+ this.child(div().absolute().inset_0().bg(cx.theme().danger))
+ .line_through()
+ .text_color(cx.theme().danger_foreground)
+ })
+ .when(is_inserted, |this| {
+ this.child(div().absolute().inset_0().bg(cx.theme().success))
+ })
+ .when(is_stripe, |this| this.bg(cx.theme().table_even))
+ }
+
fn render_td(
&mut self,
row_ix: usize,
col_ix: usize,
_window: &mut Window,
- _cx: &mut Context
>,
+ cx: &mut Context>,
) -> impl IntoElement {
if let Some(EditingState {
row,
@@ -83,42 +108,45 @@ impl TableDelegate for GridDelegate {
.into_any_element();
}
+ let cell_val = self.state.cell_value(row_ix, col_ix);
+ let is_null = cell_val.is_none();
let is_edited = self.state.pending_edits.contains_key(&(row_ix, col_ix));
- let is_deleted = self.state.pending_deletes.contains(&row_ix);
-
- let text: SharedString =
- if let Some(new_val) = self.state.pending_edits.get(&(row_ix, col_ix)) {
- new_val.clone().into()
- } else {
- self.state
- .original_rows
- .get(row_ix)
- .and_then(|row| row.get(col_ix))
- .cloned()
- .unwrap_or_else(|| "".into())
- };
-
- if is_deleted {
- div()
- .p_neg_2()
- .w_full()
- .h_full()
- .flex()
- .items_center()
- .child(div().line_through().text_color(gpui::red()).child(text))
- .into_any_element()
- } else if is_edited {
- div()
- .p_neg_2()
- .w_full()
- .h_full()
- .flex()
- .items_center()
- .child(div().size_full().p_2().bg(gpui::rgb(0xfff085)).child(text))
- .into_any_element()
- } else {
- text.into_any_element()
+
+ let outer = div().p_neg_2().w_full().h_full().flex().items_center();
+
+ let mut inner = div()
+ .p_2()
+ .size_full()
+ .border_r_1()
+ .border_color(cx.theme().border);
+
+ match (is_edited, is_null) {
+ (true, true) => {
+ // Edited → NULL: yellow bg + italic "NULL"
+ inner = inner
+ .bg(cx.theme().warning)
+ .text_color(cx.theme().muted_foreground)
+ .italic()
+ .child("NULL".to_string());
+ }
+ (true, false) => {
+ // Edited → value: yellow bg + value
+ inner = inner.bg(cx.theme().warning).child(cell_val.unwrap());
+ }
+ (false, true) => {
+ // Original NULL: italic "NULL" muted
+ inner = inner
+ .text_color(cx.theme().muted_foreground)
+ .italic()
+ .child("NULL".to_string());
+ }
+ (false, false) => {
+ // Original value: normal text
+ inner = inner.child(cell_val.unwrap());
+ }
}
+
+ outer.child(inner).into_any_element()
}
fn render_empty(
@@ -129,16 +157,14 @@ impl TableDelegate for GridDelegate {
div().into_any_element()
}
+ /// Lấy text cho clipboard. NULL → "NULL" string.
+ ///
+ /// Không khuyến khích sử dụng hàm này vì nó không xử lý giá trị NULL theo đúng cách, thay vào
+ /// đó hãy sử dụng [`GridState.cell_value`]
fn cell_text(&self, row_ix: usize, col_ix: usize, _: &App) -> String {
- if let Some(new_val) = self.state.pending_edits.get(&(row_ix, col_ix)) {
- return new_val.clone();
+ match self.state.cell_value(row_ix, col_ix) {
+ Some(val) => val.to_string(),
+ None => "NULL".to_string(),
}
-
- self.state
- .original_rows
- .get(row_ix)
- .and_then(|row| row.get(col_ix))
- .map(|val| val.to_string())
- .unwrap_or_default()
}
}
diff --git a/desktop/src/shared/smart_data_grid/grid_error.rs b/desktop/src/shared/smart_data_grid/grid_error.rs
index 0c0772b..ea08756 100644
--- a/desktop/src/shared/smart_data_grid/grid_error.rs
+++ b/desktop/src/shared/smart_data_grid/grid_error.rs
@@ -1,3 +1,4 @@
+use gpui::SharedString;
use thiserror::Error;
#[derive(Error, Debug)]
@@ -7,4 +8,29 @@ pub enum StageError {
#[error("Không có phiên chỉnh sửa nào đang hoạt động")]
NoActiveEdit,
-}
\ No newline at end of file
+}
+
+#[derive(Clone, Debug)]
+pub enum GridError {
+ /// Lỗi nghiêm trọng (không load được bảng), thường sử dụng khi bảng không thể load được lúc
+ /// khởi tạo
+ Fatal(SharedString),
+ /// Lỗi xảy ra khi refresh không thành công
+ Refresh(SharedString),
+ /// Lỗi commit changes
+ Commit(SharedString),
+}
+
+impl GridError {
+ /// Kiểm tra lỗi hiện tại có phải lỗi nghiêm trọng không (không thể load được bảng)
+ pub fn is_fatal(&self) -> bool {
+ matches!(self, GridError::Fatal(_))
+ }
+
+ /// Lấy message từ GridError. Trả về chuỗi rỗng nếu None.
+ pub fn message(&self) -> SharedString {
+ match self {
+ GridError::Fatal(msg) | GridError::Refresh(msg) | GridError::Commit(msg) => msg.clone(),
+ }
+ }
+}
diff --git a/desktop/src/shared/smart_data_grid/grid_fetch_state.rs b/desktop/src/shared/smart_data_grid/grid_fetch_state.rs
new file mode 100644
index 0000000..db8c26c
--- /dev/null
+++ b/desktop/src/shared/smart_data_grid/grid_fetch_state.rs
@@ -0,0 +1,53 @@
+use super::GridError;
+
+#[derive(Debug, Clone)]
+pub enum GridFetchState {
+ Idle,
+ Loading,
+ Loaded,
+ Error(GridError),
+}
+
+impl GridFetchState {
+ pub fn is_loading(&self) -> bool {
+ matches!(self, GridFetchState::Loading)
+ }
+
+ pub fn is_idle(&self) -> bool {
+ matches!(self, GridFetchState::Idle)
+ }
+
+ pub fn is_loaded(&self) -> bool {
+ matches!(self, GridFetchState::Loaded)
+ }
+
+ pub fn is_error(&self) -> bool {
+ matches!(self, GridFetchState::Error(_))
+ }
+
+ pub fn is_fatal(&self) -> bool {
+ matches!(self, GridFetchState::Error(GridError::Fatal(_)))
+ }
+
+ /// Kiểm tra đã có dữ liệu fetch hay chưa (đã từng hoặc đang có), dùng để xác định có thể hiển
+ /// thị data từ GridState không?
+ ///
+ /// - `Loaded`: data vừa fetch xong, hiển thị bình thường
+ /// - `Error(Refresh)`: data cũ vẫn còn, hiển thị kèm thông báo lỗi
+ /// - `Error(Commit)`: data hợp lệ, chỉ commit thất bại
+ /// - Các state khác: không có data tin cậy để hiển thị
+ pub fn has_displayable_data(&self) -> bool {
+ matches!(
+ self,
+ Self::Loaded | Self::Error(GridError::Refresh(_)) | Self::Error(GridError::Commit(_))
+ )
+ }
+
+ /// Trả về lỗi nếu có, None nếu không có
+ pub fn as_error(&self) -> Option<&GridError> {
+ match self {
+ GridFetchState::Error(e) => Some(e),
+ _ => None,
+ }
+ }
+}
diff --git a/desktop/src/shared/smart_data_grid/grid_state.rs b/desktop/src/shared/smart_data_grid/grid_state.rs
index 1ed5429..16dad8b 100644
--- a/desktop/src/shared/smart_data_grid/grid_state.rs
+++ b/desktop/src/shared/smart_data_grid/grid_state.rs
@@ -1,59 +1,163 @@
+use super::GridFetchState;
use engine::Column;
use gpui::SharedString;
use std::collections::{HashMap, HashSet};
-#[derive(Clone)]
-pub struct EditingState {
- pub row: usize,
- pub col: usize,
- pub has_error: bool,
-}
-
#[derive(Clone)]
pub struct GridState {
pub columns: Vec,
- pub original_rows: Vec>,
+ pub original_rows: Vec>>,
- pub source_table: Option,
+ pub data_source: GridDataSource,
pub primary_keys: Vec,
- pub pending_edits: HashMap<(usize, usize), String>,
+ pub pending_edits: HashMap<(usize, usize), Option>,
pub pending_deletes: HashSet,
- pub pending_inserts: Vec>,
+ pub pending_inserts: Vec>>,
- pub limit: usize,
- pub offset: usize,
pub total_rows: Option,
- pub is_loading: bool,
+
+ /// Trạng thái fetch data, dùng để xác định có thể hiển thị data từ GridState không?
+ ///
+ /// - [`GridFetchState::Idle`]: Không có data đang được fetch
+ /// - [`GridFetchState::Loading`]: Đang fetch data
+ /// - [`GridFetchState::Loaded`]: Fetch data thành công
+ /// - [`GridFetchState::Error`]: Fetch data thất bại
+ pub fetch_state: GridFetchState,
pub editing_state: Option,
}
impl GridState {
- pub fn new() -> Self {
+ pub fn new(data_source: GridDataSource) -> Self {
Self {
columns: Vec::new(),
original_rows: Vec::new(),
- source_table: None,
+ data_source,
primary_keys: Vec::new(),
pending_edits: HashMap::new(),
pending_deletes: HashSet::new(),
pending_inserts: Vec::new(),
- limit: 1000,
- offset: 0,
total_rows: None,
- is_loading: false,
+ fetch_state: GridFetchState::Idle,
editing_state: None,
}
}
- pub fn is_editable(&self) -> bool {
- self.source_table.is_some() && !self.primary_keys.is_empty()
+ /// Số lượng original rows (không tính pending_inserts)
+ pub fn original_len(&self) -> usize {
+ self.original_rows.len()
+ }
+
+ /// Tổng số rows hiển thị trên grid
+ pub fn total_len(&self) -> usize {
+ self.original_rows.len() + self.pending_inserts.len()
+ }
+
+ /// Đổi row index toàn cục → index trong pending_inserts
+ pub fn insert_index(&self, row_ix: usize) -> usize {
+ row_ix - self.original_len()
+ }
+
+ /// Lấy tên bảng nguồn của grid (nếu có)
+ pub fn source_table(&self) -> Option {
+ match &self.data_source {
+ GridDataSource::Table { source_table, .. } => Some(source_table.clone()),
+ GridDataSource::Query { source_table, .. } => source_table.clone(),
+ }
+ }
+
+ /// Kiểm tra xem có thể phân trang hay không
+ pub fn can_paginate(&self) -> bool {
+ matches!(self.data_source, GridDataSource::Table { .. })
+ }
+
+ /// Kiểm tra xem có thể thêm dòng mới vào grid hay không
+ ///
+ /// TODO: Query hoàn toàn có thể insert theo một trường hợp nào đó
+ pub fn can_insert(&self) -> bool {
+ matches!(self.data_source, GridDataSource::Table { .. })
+ }
+
+ /// Kiểm tra xem grid có thể chỉnh sửa không (có source_table và primary_keys)
+ pub fn can_edit(&self) -> bool {
+ match &self.data_source {
+ GridDataSource::Table { .. } => !self.primary_keys.is_empty(),
+ GridDataSource::Query {
+ source_table: Some(_),
+ ..
+ } => !self.primary_keys.is_empty(),
+ GridDataSource::Query {
+ source_table: None, ..
+ } => false,
+ }
}
+ /// Row này có bị đánh dấu xóa không?
+ pub fn is_deleted_row(&self, row_ix: usize) -> bool {
+ row_ix < self.original_rows.len() && self.pending_deletes.contains(&row_ix)
+ }
+
+ /// Row này có phải là pending insert không?
+ pub fn is_inserted_row(&self, row_ix: usize) -> bool {
+ row_ix >= self.original_rows.len()
+ }
+
+ /// Kiểm tra xem sự thay đổi vào database không
pub fn has_pending_changes(&self) -> bool {
!self.pending_edits.is_empty()
|| !self.pending_deletes.is_empty()
|| !self.pending_inserts.is_empty()
}
+
+ /// Lấy text của một cell (ưu tiên pending_edits > pending_inserts > original)
+ pub fn cell_value(&self, row_ix: usize, col_ix: usize) -> Option {
+ // Pending edit (cả original lẫn inserted đều có thể có)
+ if let Some(editing_value) = self.pending_edits.get(&(row_ix, col_ix)) {
+ return editing_value.clone();
+ }
+
+ if self.is_inserted_row(row_ix) {
+ let ix = row_ix - self.original_len();
+ return self
+ .pending_inserts
+ .get(ix)
+ .and_then(|row| row.get(col_ix))
+ .cloned()
+ .flatten();
+ }
+
+ self.cell_original_value(row_ix, col_ix)
+ }
+
+ /// Lấy giá trị gốc của một cell (không bao gồm pending edits)
+ pub fn cell_original_value(&self, row_ix: usize, col_ix: usize) -> Option {
+ self.original_rows
+ .get(row_ix)
+ .and_then(|row| row.get(col_ix))
+ .cloned()
+ .flatten()
+ }
+}
+
+#[derive(Clone)]
+pub struct EditingState {
+ pub row: usize,
+ pub col: usize,
+ pub has_error: bool,
+}
+
+#[derive(Clone, Debug)]
+pub enum GridDataSource {
+ /// TableViewer: data từ 1 bảng, có phân trang, thêm dòng
+ Table {
+ source_table: SharedString,
+ limit: usize,
+ offset: usize,
+ },
+ /// SQL Editor: data từ query tùy ý, không phân trang
+ Query {
+ source_table: Option,
+ query: SharedString,
+ },
}
diff --git a/desktop/src/shared/smart_data_grid/mod.rs b/desktop/src/shared/smart_data_grid/mod.rs
index 8b51feb..caf6fdf 100644
--- a/desktop/src/shared/smart_data_grid/mod.rs
+++ b/desktop/src/shared/smart_data_grid/mod.rs
@@ -1,11 +1,13 @@
mod data_changeset_builder;
mod grid_delegate;
mod grid_error;
+mod grid_fetch_state;
mod grid_state;
mod smart_data_grid;
pub use data_changeset_builder::*;
pub use grid_delegate::*;
pub use grid_error::*;
+pub use grid_fetch_state::*;
pub use grid_state::*;
-pub use smart_data_grid::*;
\ No newline at end of file
+pub use smart_data_grid::*;
diff --git a/desktop/src/shared/smart_data_grid/smart_data_grid.rs b/desktop/src/shared/smart_data_grid/smart_data_grid.rs
index a015640..8d8a27c 100644
--- a/desktop/src/shared/smart_data_grid/smart_data_grid.rs
+++ b/desktop/src/shared/smart_data_grid/smart_data_grid.rs
@@ -1,104 +1,196 @@
-use super::{DataChangesetBuilder, EditingState, GridDelegate, GridState, StageError};
+use super::{
+ DataChangesetBuilder, EditingState, GridDataSource, GridDelegate, GridError, GridFetchState,
+ GridState, StageError,
+};
use assets::AppIcon;
-use engine::{Column, Row, SqlClient};
+use engine::{Column, QueryResult, Row, SqlClient};
+use gpui::prelude::FluentBuilder;
use gpui::*;
use gpui_component::button::{Button, ButtonCustomVariant, ButtonVariants};
use gpui_component::input::InputState;
use gpui_component::table::{DataTable, TableDelegate, TableEvent, TableState};
-use gpui_component::{ActiveTheme, Disableable, Icon, h_flex, v_flex};
-
-/// TODO: Nên chuyển cái hàm này sang chỗ khác
-fn validate_sql_type(text: &str, data_type: &str) -> bool {
- let data_type = data_type.to_uppercase();
- if data_type.contains("INT") || data_type.contains("BOOL") {
- text.parse::().is_ok()
- } else if data_type.contains("REAL")
- || data_type.contains("FLOAT")
- || data_type.contains("DOUBLE")
- || data_type.contains("DECIMAL")
- || data_type.contains("NUMERIC")
- {
- text.parse::().is_ok()
- } else {
- true
- }
-}
+use gpui_component::{ActiveTheme, Disableable, Icon, Sizable, h_flex, v_flex};
/// View độc lập quản lý hiển thị và tương tác dữ liệu dạng bảng.
pub struct SmartDataGrid {
pub client: SqlClient,
pub table: Entity>,
pub cell_editor: Entity,
+ focus_handle: FocusHandle,
_blur_subscription: gpui::Subscription,
}
impl SmartDataGrid {
- pub fn new(client: SqlClient, window: &mut Window, cx: &mut Context) -> Self {
+ pub fn new(
+ client: SqlClient,
+ data_source: GridDataSource,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Self {
let cell_editor = cx.new(|cx| InputState::new(window, cx));
- let delegate = GridDelegate::new(GridState::new(), cell_editor.clone());
+ let delegate = GridDelegate::new(GridState::new(data_source), cell_editor.clone());
let table = cx.new(|cx| {
TableState::new(delegate, window, cx)
+ .row_header(false)
.cell_selectable(true)
.row_selectable(true)
});
- let focus_handle = cell_editor.read(cx).focus_handle(cx);
- let blur_sub = cx.on_blur(&focus_handle, window, |this: &mut Self, window, cx| {
- match this.stage_cell_edit(window, cx) {
- Ok((row_ix, col_ix)) => {
- this.table.update(cx, |table, cx| {
- table.set_selected_cell(row_ix, col_ix, cx);
- });
- }
- Err(error) => eprintln!("Blur error: {error}"),
- };
- });
+ let cell_editor_focus_handle = cell_editor.read(cx).focus_handle(cx);
+ let blur_sub = cx.on_blur(
+ &cell_editor_focus_handle,
+ window,
+ |this: &mut Self, window, cx| {
+ match this.stage_cell_edit(window, cx) {
+ Ok((row_ix, col_ix)) => {
+ this.table.update(cx, |table, cx| {
+ table.set_selected_cell(row_ix, col_ix, cx);
+ });
+ }
+ Err(error) => eprintln!("Blur error: {error}"),
+ };
+ },
+ );
+
+ let focus_handle = cx.focus_handle();
- // Bắt sự kiện từ Table (ví dụ: Double Click để Edit)
cx.subscribe_in(&table, window, Self::on_table_event)
.detach();
- Self {
+ let mut this = Self {
client,
table,
cell_editor,
+ focus_handle,
_blur_subscription: blur_sub,
- }
+ };
+
+ this.load(cx); // Initialize data
+
+ this
}
- fn on_table_event(
- &mut self,
- table: &Entity>,
- event: &TableEvent,
- window: &mut Window,
- cx: &mut Context,
- ) {
- match event {
- TableEvent::DoubleClickedCell(row_ix, col_ix) => {
- self.activate_editor(*row_ix, *col_ix, window, cx);
+ /// Load data từ Database, nó sẽ thực thi truy vấn dựa trên
+ /// `&self.table.read(cx).delegate().state.data_source` và cập nhật lại `table`.
+ ///
+ /// - [`GridDataSource::Table`]: Truy vấn dữ liệu từ bảng cơ sở dữ liệu.
+ /// - [`GridDataSource::Query`]: Truy vấn dữ liệu từ câu lệnh SQL tùy chỉnh.
+ pub fn load(&mut self, cx: &mut Context) {
+ self.set_fetch_state(GridFetchState::Loading, cx);
+
+ let client = self.client.clone();
+ let data_source = &self.table.read(cx).delegate().state.data_source;
+ let had_data = self
+ .table
+ .read(cx)
+ .delegate()
+ .state
+ .fetch_state
+ .has_displayable_data();
+
+ let (query, table_name): (String, Option) = match data_source {
+ GridDataSource::Table {
+ source_table,
+ limit,
+ offset,
+ } => {
+ let table_name = source_table.clone();
+ let query = format!(
+ "SELECT * FROM \"{}\" LIMIT {} OFFSET {}",
+ table_name, limit, offset
+ );
+ (query, Some(table_name))
}
- TableEvent::SelectCell(row_ix, col_ix) => {
- // Focus lại editing cell khi giá trị edit hiện tại không hợp lệ
- if let Some(EditingState {
- row,
- col,
- has_error,
- }) = self.table.read(cx).delegate().state.editing_state
- {
- table.update(cx, |table, cx| {
- table.clear_selection(cx);
- });
+ GridDataSource::Query { query, .. } => (query.to_string(), None),
+ };
- if row == *row_ix && col == *col_ix && !has_error {
- self.cell_editor.update(cx, |input, cx| {
- input.focus(window, cx);
+ cx.spawn(async move |this, cx| {
+ let result = client.execute(&query).await;
+ let table_info = match &table_name {
+ Some(table_name) => Some(client.get_table_info_cached(&table_name).await?),
+ None => None,
+ };
+
+ this.update(cx, |grid, cx| {
+ match result {
+ Ok(QueryResult::Query { columns, rows }) => {
+ grid.set_data(columns, rows, cx);
+
+ table_info.inspect(|table_info| {
+ grid.set_primary_keys(table_info.primary_key.columns.clone(), cx)
});
+
+ grid.set_fetch_state(GridFetchState::Loaded, cx);
}
- }
- }
- _ => {}
- }
+ Ok(QueryResult::Execution { .. }) => {
+ panic!("Trường hợp này không thể xảy ra >:(")
+ }
+ Err(e) => {
+ match had_data {
+ true => grid.set_fetch_state(
+ GridFetchState::Error(GridError::Refresh(e.to_string().into())),
+ cx,
+ ),
+ false => grid.set_fetch_state(
+ GridFetchState::Error(GridError::Fatal(e.to_string().into())),
+ cx,
+ ),
+ }
+ eprintln!("{}", e);
+ }
+ };
+ })
+ })
+ .detach();
+ }
+
+ /// Cập nhật dữ liệu gốc cho Grid
+ pub fn set_data(&mut self, columns: Vec, rows: Vec, cx: &mut Context) {
+ let cached_rows: Vec>> = rows
+ .into_iter()
+ .map(|row| {
+ row.values
+ .into_iter()
+ .map(|val| match val {
+ Some(v) => Some(v.to_string().into()),
+ None => None,
+ })
+ .collect()
+ })
+ .collect();
+
+ self.table.update(cx, |table, cx| {
+ let delegate = table.delegate_mut();
+ delegate.state.original_rows = cached_rows;
+ delegate.state.columns = columns;
+ delegate.state.pending_edits.clear();
+ delegate.state.pending_deletes.clear();
+ delegate.state.pending_inserts.clear();
+
+ // Cập nhật lại cached_columns trong Delegate
+ *delegate = GridDelegate::new(delegate.state.clone(), self.cell_editor.clone());
+ table.refresh(cx);
+ });
+
+ cx.notify();
+ }
+
+ /// Cấu hình primary keys cho Grid
+ pub fn set_primary_keys(&mut self, primary_keys: Vec, cx: &mut Context) {
+ self.table.update(cx, |table, _| {
+ let delegate = table.delegate_mut();
+ delegate.state.primary_keys = primary_keys;
+ });
+ cx.notify();
+ }
+
+ /// Đặt trạng thái fetch cho Grid (Idle, Loading, Loaded, Error)
+ pub fn set_fetch_state(&mut self, state: GridFetchState, cx: &mut Context) {
+ self.table.update(cx, |table, _| {
+ table.delegate_mut().state.fetch_state = state;
+ });
+ cx.notify();
}
/// Kích hoạt chế độ chỉnh sửa cho một ô cụ thể
@@ -112,7 +204,8 @@ impl SmartDataGrid {
let delegate = self.table.read(cx).delegate();
let state = &delegate.state;
- if !state.is_editable()
+ if state.fetch_state.is_loading()
+ || !state.can_edit()
|| state.editing_state.as_ref().is_some_and(
|EditingState {
row,
@@ -124,7 +217,11 @@ impl SmartDataGrid {
return;
}
- let current_value = delegate.cell_text(row_ix, col_ix, cx);
+ let current_value = delegate.state.cell_value(row_ix, col_ix);
+ let current_value = match current_value {
+ Some(val) => val.to_string(),
+ None => String::new(),
+ };
self.cell_editor.update(cx, |input, cx| {
input.set_value(current_value, window, cx);
@@ -139,6 +236,7 @@ impl SmartDataGrid {
has_error: false,
});
});
+ cx.notify();
}
/// Đánh dấu cell hiện tại trong trạng thái chuẩn bị thay đổi, trước khi được lưu chính thức
@@ -150,95 +248,96 @@ impl SmartDataGrid {
) -> Result<(usize, usize), StageError> {
self.table.update(cx, |table, cx| {
let delegate = table.delegate_mut();
- let (r, c) = match &delegate.state.editing_state {
+ let state = &mut delegate.state;
+
+ let (r, c) = match &state.editing_state {
Some(s) => (s.row, s.col),
None => return Err(StageError::NoActiveEdit),
};
- let value = self.cell_editor.read(cx).value().to_string();
- let col_type = delegate.state.columns[c]
- .declared_type
- .clone()
- .unwrap_or_default();
-
- let original_value = delegate
- .state
- .original_rows
- .get(r)
- .and_then(|row| row.get(c))
- .map(|s| s.to_string())
- .unwrap_or_default();
-
- if value == original_value {
- delegate.state.pending_edits.remove(&(r, c));
- delegate.state.editing_state = None;
- cx.notify();
- return Ok((r, c));
- }
+ let text = self.cell_editor.read(cx).value().to_string();
+ let col_type = state.columns[c].declared_type.clone().unwrap_or_default();
+ let data_type_category = self.client.data_type_categorize(&col_type);
+
+ let new_value: Option = match text.is_empty() {
+ true => data_type_category
+ .allows_empty_string()
+ .then_some(SharedString::from("")),
+ false => {
+ if !data_type_category.validate(&text) {
+ state.editing_state.as_mut().unwrap().has_error = true;
+ self.cell_editor.update(cx, |ed, cx| ed.focus(window, cx));
+ cx.notify();
+ return Err(StageError::InvalidData(format!(
+ "Giá trị '{}' không đúng định dạng {}",
+ text, col_type
+ )));
+ }
+ Some(SharedString::from(text))
+ }
+ };
- if validate_sql_type(&value, &col_type) {
- delegate.state.pending_edits.insert((r, c), value);
- delegate.state.editing_state = None;
- cx.notify();
- Ok((r, c))
- } else {
- delegate.state.editing_state.as_mut().unwrap().has_error = true;
- self.cell_editor.update(cx, |ed, cx| ed.focus(window, cx));
- cx.notify();
- Err(StageError::InvalidData(format!(
- "Giá trị '{}' không đúng định dạng {}",
- value, col_type
- )))
+ match state.is_inserted_row(r) {
+ true => {
+ let insert_index = state.insert_index(r);
+ state
+ .pending_inserts
+ .get_mut(insert_index)
+ .and_then(|row| row.get_mut(c))
+ .map(|cell| *cell = new_value);
+ }
+ false => {
+ let original = state.cell_original_value(r, c);
+ match new_value == original {
+ true => state.pending_edits.remove(&(r, c)),
+ false => state.pending_edits.insert((r, c), new_value),
+ };
+ }
}
- })
- }
-
- /// Cập nhật dữ liệu gốc cho Grid
- pub fn set_data(&mut self, columns: Vec, rows: Vec, cx: &mut Context) {
- let cached_rows: Vec> = rows
- .into_iter()
- .map(|row| {
- row.values
- .into_iter()
- .map(|val| match val {
- Some(v) => v.to_string().into(),
- None => "NULL".into(),
- })
- .collect()
- })
- .collect();
- self.table.update(cx, |table, cx| {
- let delegate = table.delegate_mut();
- delegate.state.columns = columns;
- delegate.state.original_rows = cached_rows;
- delegate.state.pending_edits.clear();
- delegate.state.pending_deletes.clear();
- delegate.state.pending_inserts.clear();
-
- // Cập nhật lại cached_columns trong Delegate
- *delegate = GridDelegate::new(delegate.state.clone(), self.cell_editor.clone());
- table.refresh(cx);
- });
+ state.editing_state = None;
+ Ok((r, c))
+ })
}
- /// Cấu hình siêu dữ liệu để Grid biết nó có thể Edit được không
- pub fn set_metadata(
+ fn on_table_event(
&mut self,
- source_table: Option,
- primary_keys: Vec,
+ table: &Entity>,
+ event: &TableEvent,
+ window: &mut Window,
cx: &mut Context,
) {
- self.table.update(cx, |table, cx| {
- let delegate = table.delegate_mut();
- delegate.state.source_table = source_table;
- delegate.state.primary_keys = primary_keys;
- cx.notify();
- });
+ match event {
+ TableEvent::DoubleClickedCell(row_ix, col_ix) => {
+ self.activate_editor(*row_ix, *col_ix, window, cx);
+ }
+ TableEvent::SelectCell(row_ix, col_ix) => {
+ // Focus lại editing cell khi giá trị edit hiện tại không hợp lệ
+ if let Some(EditingState {
+ row,
+ col,
+ has_error,
+ }) = self.table.read(cx).delegate().state.editing_state
+ {
+ table.update(cx, |table, cx| {
+ table.clear_selection(cx);
+ });
+
+ if row == *row_ix && col == *col_ix && !has_error {
+ self.cell_editor.update(cx, |input, cx| {
+ input.focus(window, cx);
+ });
+ }
+ }
+ cx.notify();
+ }
+ _ => {}
+ }
}
- fn on_refresh(&mut self, _: &ClickEvent, _window: &mut Window, _cx: &mut Context) {
- println!("SmartDataGrid: Đã bấm nút Refresh");
+ /// Refresh lại data grid
+ fn on_refresh(&mut self, _: &ClickEvent, _window: &mut Window, cx: &mut Context) {
+ self.load(cx);
}
/// Copy cell hoặc row đang selected vào clipboard.
@@ -247,7 +346,7 @@ impl SmartDataGrid {
fn on_copy_cell(
&mut self,
_: &crate::action::datagrid::CopyCell,
- _window: &mut Window,
+ _: &mut Window,
cx: &mut Context,
) {
let table = self.table.read(cx);
@@ -273,10 +372,11 @@ impl SmartDataGrid {
}
}
+ /// Lưu các thay đổi xuống dưới database thực tế
fn on_commit_changes(
&mut self,
_: &crate::action::datagrid::CommitChanges,
- _window: &mut Window,
+ _: &mut Window,
cx: &mut Context,
) {
let state = &self.table.read(cx).delegate().state;
@@ -299,6 +399,7 @@ impl SmartDataGrid {
) {
if let Some((r, c)) = self.table.read(cx).selected_cell() {
self.activate_editor(r, c, window, cx);
+ cx.notify();
}
}
@@ -317,6 +418,7 @@ impl SmartDataGrid {
}
Err(error) => eprintln!("Enter key error: {error}"),
};
+ cx.notify();
}
fn on_cancel_edit(
@@ -333,15 +435,81 @@ impl SmartDataGrid {
table.focus_handle(cx).focus(window, cx);
table.delegate_mut().state.editing_state = None;
});
+ cx.notify();
+ }
+
+ /// Xóa dòng đã chọn, hoặc dòng chứa ô đã chọn nếu không có dòng nào được chọn
+ fn on_delete_row(
+ &mut self,
+ _: &crate::action::datagrid::DeleteRow,
+ _: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.table.update(cx, |table, _| {
+ let selected_row = match (table.selected_row(), table.selected_cell()) {
+ (Some(row_ix), _) => row_ix,
+ (_, Some((row_ix, _))) => row_ix,
+ _ => return, // Không làm gì cả nếu cả hai đều là None
+ };
+
+ let state = &mut table.delegate_mut().state;
+ match state.is_inserted_row(selected_row) {
+ true => {
+ let ix = state.insert_index(selected_row);
+ state.pending_inserts.remove(ix);
+ }
+ false if state.pending_deletes.contains(&selected_row) => {
+ state.pending_deletes.remove(&selected_row);
+ }
+ false => {
+ state.pending_deletes.insert(selected_row);
+ }
+ }
+ });
+ cx.notify();
+ }
+
+ /// Thêm một dòng mới vào cuối bảng
+ fn on_add_row(
+ &mut self,
+ _: &crate::action::datagrid::AddRow,
+ _: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.table.update(cx, |table, _| {
+ let state = &mut table.delegate_mut().state;
+ let col_count = state.columns.len();
+ if col_count == 0 {
+ return;
+ }
+ state.pending_inserts.push(vec![None; col_count]);
+ });
+ cx.notify();
+ }
+
+ fn on_discard_changes(
+ &mut self,
+ _: &crate::action::datagrid::DiscardChanges,
+ _: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.table.update(cx, |table, _| {
+ let state = &mut table.delegate_mut().state;
+ state.pending_edits.clear();
+ state.pending_deletes.clear();
+ state.pending_inserts.clear();
+ state.editing_state = None;
+ });
+ cx.notify();
}
fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement {
let delegate = self.table.read(cx).delegate();
let state = &delegate.state;
- let is_editable = state.is_editable();
+ let is_editable = state.can_edit();
let has_changes = state.has_pending_changes();
- let is_loading = state.is_loading;
+ let is_loading = state.fetch_state.is_loading();
let commit_changes_button = ButtonCustomVariant::new(cx)
.color(cx.theme().green)
@@ -376,7 +544,17 @@ impl SmartDataGrid {
.size_6()
.cursor_pointer()
.icon(AppIcon::Plus)
- .disabled(!is_editable || is_loading),
+ .disabled(!is_editable || is_loading)
+ .on_click({
+ let focus_handle = self.focus_handle.clone();
+ move |_, window, cx| {
+ focus_handle.dispatch_action(
+ &crate::action::datagrid::AddRow,
+ window,
+ cx,
+ );
+ }
+ }),
)
.child(
Button::new("btn-delete-row")
@@ -384,7 +562,17 @@ impl SmartDataGrid {
.size_6()
.cursor_pointer()
.icon(AppIcon::Minus)
- .disabled(!is_editable || is_loading),
+ .disabled(!is_editable || is_loading)
+ .on_click({
+ let focus_handle = self.focus_handle.clone();
+ move |_, window, cx| {
+ focus_handle.dispatch_action(
+ &crate::action::datagrid::DeleteRow,
+ window,
+ cx,
+ );
+ }
+ }),
)
.child(div().w_px().h_4().mx_px().bg(cx.theme().border))
.child(
@@ -395,9 +583,15 @@ impl SmartDataGrid {
.size_6()
.cursor_pointer()
.icon(Icon::new(AppIcon::Check))
- .on_click(|_, window, cx| {
- window
- .dispatch_action(Box::new(crate::action::datagrid::CommitChanges), cx);
+ .on_click({
+ let focus_handle = self.focus_handle.clone();
+ move |_, window, cx| {
+ focus_handle.dispatch_action(
+ &crate::action::datagrid::CommitChanges,
+ window,
+ cx,
+ );
+ }
})
.disabled(!has_changes || is_loading),
)
@@ -409,7 +603,17 @@ impl SmartDataGrid {
.size_6()
.cursor_pointer()
.icon(Icon::new(AppIcon::X))
- .disabled(!has_changes || is_loading),
+ .disabled(!has_changes || is_loading)
+ .on_click({
+ let focus_handle = self.focus_handle.clone();
+ move |_, window, cx| {
+ focus_handle.dispatch_action(
+ &crate::action::datagrid::DiscardChanges,
+ window,
+ cx,
+ );
+ }
+ }),
)
.child(div().flex_1())
.child(
@@ -423,10 +627,17 @@ impl SmartDataGrid {
impl Render for SmartDataGrid {
fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let state = self.table.read(cx).delegate().state.clone();
+ let is_loading = state.fetch_state.is_loading();
+
v_flex()
.key_context("data-grid-container")
+ .track_focus(&self.focus_handle)
.on_action(cx.listener(Self::on_commit_changes))
.on_action(cx.listener(Self::on_copy_cell))
+ .on_action(cx.listener(Self::on_add_row))
+ .on_action(cx.listener(Self::on_delete_row))
+ .on_action(cx.listener(Self::on_discard_changes))
.size_full()
.child(self.render_toolbar(cx))
.child(
@@ -440,11 +651,51 @@ impl Render for SmartDataGrid {
.min_w_0()
.min_h_0()
.overflow_hidden()
- .child(
- DataTable::new(&self.table)
- .stripe(true)
- .bordered(false)
- .scrollbar_visible(true, true),
+ .font_family(cx.theme().mono_font_family.clone())
+ .when(is_loading, |this| this.opacity(0.5))
+ // Hiển thị error view
+ .when_else(
+ state.fetch_state.is_fatal(),
+ |this| {
+ this.child(
+ v_flex().size_full().items_center().justify_center().child(
+ v_flex()
+ .items_center()
+ .gap_2()
+ .max_w_128()
+ .px_12()
+ .child(
+ div()
+ .line_height(px(24.))
+ .text_xl()
+ .text_color(cx.theme().danger_foreground)
+ .child(
+ Icon::new(AppIcon::TriangleWarningFill)
+ .with_size(px(32.)),
+ ),
+ )
+ .child(
+ div()
+ .text_sm()
+ .text_color(cx.theme().muted_foreground)
+ .child(
+ state
+ .fetch_state
+ .as_error()
+ .map(|e| e.message())
+ .unwrap_or_default(),
+ ),
+ ),
+ ),
+ )
+ },
+ |this| {
+ this.child(
+ DataTable::new(&self.table)
+ .bordered(false)
+ .scrollbar_visible(true, true),
+ )
+ },
),
)
}
diff --git a/desktop/src/window/connection_window.rs b/desktop/src/window/connection_window.rs
index 6c8243e..cc91735 100644
--- a/desktop/src/window/connection_window.rs
+++ b/desktop/src/window/connection_window.rs
@@ -469,16 +469,18 @@ impl ConnectionWindow {
.gap_2()
.flex_1()
.child(
- h_flex()
+ div()
.flex_1()
- .border_1()
+ .min_w_0()
.h_8()
+ .border_1()
+ .py_1()
.px_3()
- .overflow_x_scrollbar()
- .items_center()
+ .items_baseline()
.border_color(cx.theme().border)
.rounded(cx.theme().radius)
.text_sm()
+ .text_ellipsis_start()
.when(self.selected_path.is_none(), |this| {
this.text_color(cx.theme().muted_foreground)
})
diff --git a/engine/src/driver/mod.rs b/engine/src/driver/mod.rs
index b32f0ef..9c6cd0b 100644
--- a/engine/src/driver/mod.rs
+++ b/engine/src/driver/mod.rs
@@ -4,7 +4,9 @@ pub mod sqlite;
use crate::database_config::{DatabaseConfig, DatabaseKind};
use crate::error::EngineError;
use crate::result::QueryResult;
-use crate::schema::{DataChangeset, DatabaseBrief, SchemaBrief, TableBrief, TableInfo};
+use crate::schema::{
+ DataChangeset, DataTypeCategory, DatabaseBrief, SchemaBrief, TableBrief, TableInfo,
+};
use crate::{ColumnInfo, ForeignKeyInfo, IndexInfo, PrimaryKey};
/// Cung cấp các quy tắc định dạng SQL (Dialect) cho từng loại Database
@@ -20,7 +22,7 @@ pub trait SqlDialect {
fn quote_identifier(&self, identifier: &str) -> String;
/// Định dạng giá trị dựa trên kiểu dữ liệu
- fn format_value(&self, value: &str, data_type: &str) -> String;
+ fn format_value(&self, value: Option<&str>, data_type: DataTypeCategory) -> String;
}
/// Trait đại diện cho một database driver.
@@ -66,10 +68,11 @@ pub trait DatabaseDriver: SqlDialect + Send + Sync {
.changes
.iter()
.map(|c| {
+ let data_type = self.data_type_categorize(&c.data_type);
format!(
"{} = {}",
self.quote_identifier(&c.column_name),
- self.format_value(&c.value, &c.data_type)
+ self.format_value(c.value.as_deref(), data_type)
)
})
.collect::>()
@@ -79,10 +82,11 @@ pub trait DatabaseDriver: SqlDialect + Send + Sync {
.pk_conditions
.iter()
.map(|c| {
+ let data_type = self.data_type_categorize(&c.data_type);
format!(
"{} = {}",
self.quote_identifier(&c.column_name),
- self.format_value(&c.value, &c.data_type)
+ self.format_value(c.value.as_deref(), data_type)
)
})
.collect::>()
@@ -101,10 +105,11 @@ pub trait DatabaseDriver: SqlDialect + Send + Sync {
.pk_conditions
.iter()
.map(|c| {
+ let data_type = self.data_type_categorize(&c.data_type);
format!(
"{} = {}",
self.quote_identifier(&c.column_name),
- self.format_value(&c.value, &c.data_type)
+ self.format_value(c.value.as_deref(), data_type)
)
})
.collect::>()
@@ -117,6 +122,30 @@ pub trait DatabaseDriver: SqlDialect + Send + Sync {
));
}
+ for insert in &changeset.inserts {
+ let columns = insert
+ .values
+ .iter()
+ .map(|c| self.quote_identifier(&c.column_name))
+ .collect::>()
+ .join(", ");
+ let values = insert
+ .values
+ .iter()
+ .map(|c| {
+ let data_type = self.data_type_categorize(&c.data_type);
+ self.format_value(c.value.as_deref(), data_type)
+ })
+ .collect::>()
+ .join(", ");
+ script.push_str(&format!(
+ "INSERT INTO {} ({}) VALUES ({});\n",
+ self.quote_identifier(&changeset.table_name),
+ columns,
+ values
+ ));
+ }
+
script
}
@@ -183,6 +212,7 @@ pub trait DatabaseDriver: SqlDialect + Send + Sync {
let primary_key = extract_primary_key(&columns);
let foreign_keys = self.get_foreign_keys(table_name).await?;
let indexes = self.get_indexes(table_name).await?;
+
Ok(TableInfo {
name: table_name.to_string(),
columns,
@@ -192,6 +222,11 @@ pub trait DatabaseDriver: SqlDialect + Send + Sync {
})
}
+ async fn get_table_primary_keys(&self, table_name: &str) -> Result {
+ let columns = self.get_columns(table_name).await?;
+ Ok(extract_primary_key(&columns))
+ }
+
/// Đếm số dòng trong table.
async fn get_table_row_count(&self, table_name: &str) -> Result;
@@ -219,6 +254,12 @@ pub trait DatabaseDriver: SqlDialect + Send + Sync {
}
Ok(())
}
+
+ /// Phân loại kiểu dữ liệu SQL → DataTypeCategory.
+ ///
+ /// Mỗi driver override để map kiểu riêng của database.
+ /// Ví dụ: SQLite có "TEXT", PostgreSQL có "VARCHAR" — cả hai → Text.
+ fn data_type_categorize(&self, data_type: &str) -> DataTypeCategory;
}
/// Entry point duy nhất để tạo driver mới.
diff --git a/engine/src/driver/postgres.rs b/engine/src/driver/postgres.rs
index 9c50245..666b333 100644
--- a/engine/src/driver/postgres.rs
+++ b/engine/src/driver/postgres.rs
@@ -8,8 +8,8 @@ use crate::driver::{DatabaseDriver, SqlDialect};
use crate::error::EngineError;
use crate::result::{Column, QueryResult, Row as ResultRow, Value};
use crate::schema::{
- ColumnInfo, DatabaseBrief, ForeignKeyInfo, IndexInfo, PrimaryKey, SchemaBrief, SchemaKind,
- TableBrief, TableKind,
+ ColumnInfo, DataTypeCategory, DatabaseBrief, ForeignKeyInfo, IndexInfo, SchemaBrief,
+ SchemaKind, TableBrief, TableKind,
};
use crate::{DatabaseKind, NetworkDbConfig};
@@ -54,30 +54,17 @@ impl SqlDialect for PostgresDriver {
format!("\"{}\"", identifier)
}
- fn format_value(&self, value: &str, data_type: &str) -> String {
- if value == "NULL" {
- return "NULL".into();
- }
- let dt = data_type.to_uppercase();
- if dt.contains("INT")
- || dt.contains("SERIAL")
- || dt.contains("REAL")
- || dt.contains("FLOAT")
- || dt.contains("DOUBLE")
- || dt.contains("NUMERIC")
- || dt.contains("DECIMAL")
- {
- if value
- .chars()
- .all(|c| c.is_digit(10) || c == '.' || c == '-')
- {
- return value.into();
- }
- }
- if dt == "BOOLEAN" || dt == "BOOL" {
- return value.into();
+ fn format_value(&self, value: Option<&str>, data_type: DataTypeCategory) -> String {
+ match value {
+ Some(value) => match data_type {
+ DataTypeCategory::Integer | DataTypeCategory::Float | DataTypeCategory::Boolean => {
+ value.into()
+ }
+ DataTypeCategory::Binary => format!("\\x{}", value),
+ _ => format!("'{}'", value.replace("'", "''")),
+ },
+ None => "NULL".into(),
}
- format!("'{}'", value.replace("'", "''"))
}
}
@@ -443,6 +430,32 @@ impl DatabaseDriver for PostgresDriver {
Ok(indexes)
}
+
+ fn data_type_categorize(&self, data_type: &str) -> DataTypeCategory {
+ let dt = data_type.to_uppercase();
+ if dt.contains("CHAR") || dt.contains("VARCHAR") || dt.contains("TEXT") {
+ DataTypeCategory::Text
+ } else if dt.contains("SERIAL") || dt.contains("INT") {
+ DataTypeCategory::Integer
+ } else if dt.contains("REAL")
+ || dt.contains("FLOAT")
+ || dt.contains("DOUBLE")
+ || dt.contains("NUMERIC")
+ || dt.contains("DECIMAL")
+ {
+ DataTypeCategory::Float
+ } else if dt.contains("BOOL") {
+ DataTypeCategory::Boolean
+ } else if dt.contains("DATE") || dt.contains("TIME") || dt.contains("INTERVAL") {
+ DataTypeCategory::DateTime
+ } else if dt.contains("BYTEA") || dt.contains("BLOB") {
+ DataTypeCategory::Binary
+ } else if dt.contains("UUID") {
+ DataTypeCategory::Uuid
+ } else {
+ DataTypeCategory::Unknown
+ }
+ }
}
impl PostgresDriver {
diff --git a/engine/src/driver/sqlite.rs b/engine/src/driver/sqlite.rs
index fa7a7fc..5d0d1c0 100644
--- a/engine/src/driver/sqlite.rs
+++ b/engine/src/driver/sqlite.rs
@@ -3,12 +3,12 @@ use std::time::Duration;
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
use sqlx::{Column as SqlxColumn, Executor, Row, Statement, TypeInfo};
-use crate::driver::{DatabaseDriver, SqlDialect, extract_primary_key};
+use crate::driver::{DatabaseDriver, SqlDialect};
use crate::error::EngineError;
use crate::result::{Column, QueryResult, Row as ResultRow, Value};
use crate::schema::{
- ColumnInfo, DatabaseBrief, ForeignKeyInfo, IndexInfo, PrimaryKey, SchemaBrief, SchemaKind,
- TableBrief, TableInfo, TableKind,
+ ColumnInfo, DataTypeCategory, DatabaseBrief, ForeignKeyInfo, IndexInfo, SchemaBrief,
+ SchemaKind, TableBrief, TableKind,
};
use crate::{DatabaseConfig, FileDbConfig};
@@ -56,20 +56,16 @@ impl SqlDialect for SqliteDriver {
format!("\"{}\"", identifier)
}
- fn format_value(&self, value: &str, data_type: &str) -> String {
- if value == "NULL" {
- "NULL".into()
- } else {
- let dt = data_type.to_uppercase();
- if (dt.contains("INT") || dt.contains("REAL") || dt.contains("FLOAT"))
- && value
- .chars()
- .all(|c| c.is_digit(10) || c == '.' || c == '-')
- {
- value.into()
- } else {
- format!("'{}'", value.replace("'", "''"))
- }
+ fn format_value(&self, value: Option<&str>, data_type: DataTypeCategory) -> String {
+ match value {
+ Some(value) => match data_type {
+ DataTypeCategory::Integer | DataTypeCategory::Float | DataTypeCategory::Boolean => {
+ value.into()
+ }
+ DataTypeCategory::Binary => format!("X'{}", value),
+ _ => format!("'{}'", value.replace("'", "''")),
+ },
+ None => "NULL".into(),
}
}
}
@@ -384,6 +380,26 @@ impl DatabaseDriver for SqliteDriver {
Ok(indexes)
}
+
+ fn data_type_categorize(&self, data_type: &str) -> DataTypeCategory {
+ let dt = data_type.to_uppercase();
+ // SQLite storage affinities: TEXT, NUMERIC, INTEGER, REAL, BLOB
+ if dt.contains("CHAR") || dt.contains("CLOB") || dt.contains("TEXT") {
+ DataTypeCategory::Text
+ } else if dt.contains("INT") {
+ DataTypeCategory::Integer
+ } else if dt.contains("REAL") || dt.contains("FLOA") || dt.contains("DOUB") {
+ DataTypeCategory::Float
+ } else if dt.contains("BOOL") {
+ DataTypeCategory::Boolean
+ } else if dt.contains("BLOB") {
+ DataTypeCategory::Binary
+ } else if dt.contains("DATE") || dt.contains("TIME") {
+ DataTypeCategory::DateTime
+ } else {
+ DataTypeCategory::Unknown
+ }
+ }
}
/// Convert một `sqlx::SqliteRow` thành `result::Row`.
@@ -414,7 +430,7 @@ fn convert_value(row: &sqlx::sqlite::SqliteRow, idx: usize) -> Option {
mod tests {
use super::*;
use crate::{
- DataChangeset, FileDbConfig,
+ DataChangeset, FileDbConfig, RowInsert,
schema::{ColumnData, RowDelete, RowUpdate},
};
@@ -896,19 +912,26 @@ mod tests {
updates: vec![RowUpdate {
pk_conditions: vec![ColumnData {
column_name: "id".to_string(),
- value: "1".to_string(),
+ value: Some("1".to_string()),
data_type: "INTEGER".to_string(),
}],
changes: vec![ColumnData {
column_name: "name".to_string(),
- value: "Alice O'Neil".to_string(),
+ value: Some("Alice O'Neil".to_string()),
data_type: "TEXT".to_string(),
}],
}],
deletes: vec![RowDelete {
pk_conditions: vec![ColumnData {
column_name: "id".to_string(),
- value: "2".to_string(),
+ value: Some("2".to_string()),
+ data_type: "INTEGER".to_string(),
+ }],
+ }],
+ inserts: vec![RowInsert {
+ values: vec![ColumnData {
+ column_name: "id".to_string(),
+ value: Some("3".to_string()),
data_type: "INTEGER".to_string(),
}],
}],
diff --git a/engine/src/lib.rs b/engine/src/lib.rs
index 5f07cec..90f6999 100644
--- a/engine/src/lib.rs
+++ b/engine/src/lib.rs
@@ -10,7 +10,8 @@ pub use driver::DatabaseDriver;
pub use error::EngineError;
pub use result::{Column, QueryResult, Row, Value};
pub use schema::{
- ColumnData, ColumnInfo, DataChangeset, DatabaseBrief, ForeignKeyInfo, IndexInfo, PrimaryKey,
- RowDelete, RowUpdate, SchemaBrief, SchemaKind, TableBrief, TableInfo, TableKind,
+ ColumnData, ColumnInfo, DataChangeset, DataTypeCategory, DatabaseBrief, ForeignKeyInfo,
+ IndexInfo, PrimaryKey, RowDelete, RowInsert, RowUpdate, SchemaBrief, SchemaKind, TableBrief,
+ TableInfo, TableKind,
};
pub use sql_client::SqlClient;
diff --git a/engine/src/schema/data_type_category.rs b/engine/src/schema/data_type_category.rs
new file mode 100644
index 0000000..c3a1f1e
--- /dev/null
+++ b/engine/src/schema/data_type_category.rs
@@ -0,0 +1,59 @@
+/// Phân loại kiểu dữ liệu SQL theo hành vi trong UI.
+///
+/// Mỗi driver implement `categorize()` để map kiểu riêng → category chung.
+/// Category quyết định:
+/// - Empty input → NULL hay empty string?
+/// - Validate input như thế nào?
+/// - Format SQL value ra sao?
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DataTypeCategory {
+ /// Chuỗi ký tự: VARCHAR, TEXT, CHAR, NVARCHAR, CLOB...
+ /// Empty input → Some("") (chuỗi rỗng hợp lệ)
+ Text,
+ /// Số nguyên: INTEGER, BIGINT, SMALLINT, SERIAL...
+ /// Empty input → None (NULL)
+ Integer,
+ /// Số thực: REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC...
+ /// Empty input → None (NULL)
+ Float,
+ /// Boolean: BOOLEAN, BOOL...
+ /// Empty input → None (NULL)
+ Boolean,
+ /// Ngày/giờ: DATE, TIME, TIMESTAMP, INTERVAL...
+ /// Empty input → None (NULL)
+ DateTime,
+ /// Dữ liệu nhị phân: BLOB, BYTEA...
+ /// Empty input → None (NULL)
+ Binary,
+ /// UUID
+ /// Empty input → None (NULL)
+ Uuid,
+ /// Không xác định (computed column, kiểu lạ)
+ /// Empty input → None (NULL) — an toàn hơn
+ Unknown,
+}
+
+impl DataTypeCategory {
+ /// Kiểm tra giá trị text có hợp lệ cho category này không.
+ pub fn validate(&self, text: &str) -> bool {
+ match self {
+ DataTypeCategory::Integer => text.parse::().is_ok(),
+ DataTypeCategory::Float => text.parse::().is_ok(),
+ DataTypeCategory::Boolean => {
+ text.eq_ignore_ascii_case("true")
+ || text.eq_ignore_ascii_case("false")
+ || text == "0"
+ || text == "1"
+ }
+ DataTypeCategory::Text | DataTypeCategory::Unknown => true,
+ DataTypeCategory::DateTime => false, // TODO: Cần triển khai date parsing
+ DataTypeCategory::Binary => false, // TODO: Cần triển khai hex validation
+ DataTypeCategory::Uuid => false, // TODO: Cần triển khai UUID parsing
+ }
+ }
+
+ /// Empty input có nên trở thành chuỗi rỗng (Some("")) thay vì NULL (None)?
+ pub fn allows_empty_string(&self) -> bool {
+ matches!(self, DataTypeCategory::Text)
+ }
+}
diff --git a/engine/src/schema/mod.rs b/engine/src/schema/mod.rs
index df7d508..4ef9b01 100644
--- a/engine/src/schema/mod.rs
+++ b/engine/src/schema/mod.rs
@@ -1,4 +1,5 @@
mod column_info;
+mod data_type_category;
mod database_brief;
mod foreign_key_info;
mod index_info;
@@ -9,6 +10,7 @@ mod table_info;
mod table_kind;
pub use column_info::*;
+pub use data_type_category::*;
pub use database_brief::*;
pub use foreign_key_info::*;
pub use index_info::*;
@@ -16,4 +18,4 @@ pub use modification::*;
pub use primary_key::*;
pub use table_brief::*;
pub use table_info::*;
-pub use table_kind::*;
\ No newline at end of file
+pub use table_kind::*;
diff --git a/engine/src/schema/modification.rs b/engine/src/schema/modification.rs
index 872d11c..de4e113 100644
--- a/engine/src/schema/modification.rs
+++ b/engine/src/schema/modification.rs
@@ -1,7 +1,7 @@
#[derive(Debug, Clone)]
pub struct ColumnData {
pub column_name: String,
- pub value: String,
+ pub value: Option,
pub data_type: String,
}
@@ -16,9 +16,15 @@ pub struct RowDelete {
pub pk_conditions: Vec,
}
+#[derive(Debug, Clone)]
+pub struct RowInsert {
+ pub values: Vec,
+}
+
#[derive(Debug, Clone)]
pub struct DataChangeset {
pub table_name: String,
pub updates: Vec,
pub deletes: Vec,
+ pub inserts: Vec,
}
diff --git a/engine/src/sql_client.rs b/engine/src/sql_client.rs
index fc03e5d..d9e00de 100644
--- a/engine/src/sql_client.rs
+++ b/engine/src/sql_client.rs
@@ -1,6 +1,10 @@
+use std::collections::HashMap;
use std::ops::Deref;
use std::sync::Arc;
+use tokio::sync::RwLock;
+
+use crate::TableInfo;
use crate::database_config::DatabaseConfig;
use crate::driver::{self, DatabaseDriver};
use crate::error::EngineError;
@@ -19,7 +23,7 @@ use crate::error::EngineError;
/// so `.clone()` only increments an atomic reference count (~1ns). The underlying
/// database connection, socket, and driver state are **shared**, not duplicated.
///
-/// ```text
+/// ```
/// let client1 = SqlClient::connect(config).await?; // 1-10ms (real I/O)
/// let client2 = client1.clone(); // ~1ns (Arc clone)
/// let client3 = client1.clone(); // ~1ns (Arc clone)
@@ -53,7 +57,11 @@ use crate::error::EngineError;
/// ```
#[derive(Clone)]
pub struct SqlClient {
+ /// Driver của database, cung cấp các phương thức để tương tác với database.
driver: Arc,
+
+ /// Cache của schema, lưu trữ thông tin về các table đã được lấy từ database.
+ schema_cache: Arc>>>,
}
impl Deref for SqlClient {
@@ -78,6 +86,7 @@ impl SqlClient {
let driver = driver::create(&config).await?;
Ok(Self {
driver: Arc::from(driver),
+ schema_cache: Arc::new(RwLock::new(HashMap::new())),
})
}
@@ -94,6 +103,48 @@ impl SqlClient {
pub fn do_nothing(&self) {}
}
+/// Triển khai các phương thức liên quan tới cache
+impl SqlClient {
+ /// Lấy TableInfo với cache. Cache hit → return ngay không query DB.
+ pub async fn get_table_info_cached(
+ &self,
+ table_name: &str,
+ ) -> Result, EngineError> {
+ // 1. Check cache (read lock — cheap)
+ {
+ let cache = self.schema_cache.read().await;
+ if let Some(info) = cache.get(table_name) {
+ println!("Cache hit: {}", table_name);
+ return Ok(Arc::clone(info));
+ }
+ }
+
+ // 2. Cache miss → fetch from DB
+ println!("Cache miss: {}", table_name);
+ let table_info = Arc::new(self.get_table_info(table_name).await?);
+
+ // 3. Store in cache (write lock)
+ {
+ let mut cache = self.schema_cache.write().await;
+ cache
+ .entry(table_name.to_string())
+ .or_insert_with(|| Arc::clone(&table_info));
+ }
+ Ok(table_info)
+ }
+
+ /// Invalidate cache cho 1 table
+ pub async fn invalidate_cache(&self, table_name: &str) {
+ let mut cache = self.schema_cache.write().await;
+ cache.remove(table_name);
+ }
+ /// Invalidate toàn bộ cache
+ pub async fn invalidate_cache_all(&self) {
+ let mut cache = self.schema_cache.write().await;
+ cache.clear();
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/themes/truyvansql.json b/themes/truyvansql.json
index 963ef5f..c30995d 100644
--- a/themes/truyvansql.json
+++ b/themes/truyvansql.json
@@ -15,8 +15,10 @@
"foreground": "#000000",
"border": "#e4e4e7",
"ring": "#0060de",
- "danger.foreground": "#ffffff",
- "danger.background": "#E7000B",
+ "success.background": "#B9F8CF60",
+ "danger.foreground": "#E7000B",
+ "danger.background": "#FFC9C960",
+ "warning.background": "#fff08560",
"sidebar.background": "#FAFAFA00",
"list.active.background": "#0060de15",
"list.active.border": "#0060de",