refactor(sql): move builders to Rust
This commit is contained in:
1 parent
efc68c4244
commit
7a3ff3bff9
76 files changed
+7635
-4487
No files matched your search
@@ -10,6 +10,7 @@ duckdb-bundled = ["duckdb/bundled"]
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
regex = "1"
|
||||
percent-encoding = "2"
|
||||
log = "0.4"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
@@ -55,6 +55,92 @@ pub struct DataGridSaveStatementOptions {
|
||||
pub new_rows: Vec<Vec<Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataGridCopyUpdateStatementOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub table_meta: DataGridTableMeta,
|
||||
pub columns: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_columns: Option<Vec<Option<String>>>,
|
||||
#[serde(default)]
|
||||
pub rows: Vec<Vec<Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataGridCopyInsertStatementOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub table_meta: Option<DataGridTableMeta>,
|
||||
pub columns: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_columns: Option<Vec<Option<String>>>,
|
||||
#[serde(default)]
|
||||
pub rows: Vec<Vec<Value>>,
|
||||
#[serde(default)]
|
||||
pub exclude_primary_keys: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum DataGridContextFilterMode {
|
||||
Equals,
|
||||
NotEquals,
|
||||
IsNull,
|
||||
IsNotNull,
|
||||
Like,
|
||||
NotLike,
|
||||
LessThan,
|
||||
GreaterThan,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataGridContextFilterConditionOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub column_name: String,
|
||||
pub mode: DataGridContextFilterMode,
|
||||
pub value: Value,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub column_info: Option<DataGridColumnInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataGridColumnValueFilterConditionOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub column_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub column_info: Option<DataGridColumnInfo>,
|
||||
pub raw_value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataGridCountSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub table_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub where_input: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HiveTablePropertiesSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub table_name: String,
|
||||
pub property_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataGridSavePreparation {
|
||||
@@ -85,6 +171,219 @@ pub fn prepare_data_grid_save(options: DataGridSaveStatementOptions) -> DataGrid
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_data_grid_copy_update_statements(options: DataGridCopyUpdateStatementOptions) -> Vec<String> {
|
||||
if matches!(options.database_type, Some(DatabaseType::Neo4j | DatabaseType::Tdengine)) {
|
||||
return Vec::new();
|
||||
}
|
||||
let primary_keys = &options.table_meta.primary_keys;
|
||||
if primary_keys.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let save_columns = effective_copy_columns(options.source_columns.as_deref(), &options.columns);
|
||||
let column_info = options.table_meta.columns.as_deref().unwrap_or(&[]);
|
||||
let primary_key_indexes: Vec<Option<usize>> =
|
||||
primary_keys.iter().map(|primary_key| find_column_index(&save_columns, primary_key)).collect();
|
||||
if primary_key_indexes.iter().any(Option::is_none) {
|
||||
return Vec::new();
|
||||
}
|
||||
let primary_key_indexes: Vec<usize> = primary_key_indexes.into_iter().flatten().collect();
|
||||
let primary_key_set: Vec<String> =
|
||||
primary_keys.iter().map(|primary_key| normalize_column_name(primary_key)).collect();
|
||||
let writable_indexes: Vec<(&str, usize)> = save_columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, column)| Some((column.as_deref()?, index)))
|
||||
.filter(|(column, _)| !primary_key_set.contains(&normalize_column_name(column)))
|
||||
.filter(|(column, _)| !is_oracle_row_id(options.database_type, Some(column)))
|
||||
.collect();
|
||||
|
||||
if writable_indexes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let table = qualified_table_name(
|
||||
options.database_type,
|
||||
options.table_meta.schema.as_deref(),
|
||||
&options.table_meta.table_name,
|
||||
);
|
||||
let mut statements = Vec::new();
|
||||
for row in &options.rows {
|
||||
if primary_key_indexes.iter().any(|index| row.get(*index).unwrap_or(&Value::Null).is_null()) {
|
||||
continue;
|
||||
}
|
||||
let sets = writable_indexes
|
||||
.iter()
|
||||
.map(|(column, index)| {
|
||||
format!(
|
||||
"{} = {}",
|
||||
quote_ident(options.database_type, column),
|
||||
format_grid_sql_literal(
|
||||
row.get(*index).unwrap_or(&Value::Null),
|
||||
options.database_type,
|
||||
column_info_for(column_info, column)
|
||||
)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if sets.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let where_clause = primary_keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, primary_key)| {
|
||||
build_column_predicate(
|
||||
options.database_type,
|
||||
primary_key,
|
||||
row.get(primary_key_indexes[index]).unwrap_or(&Value::Null),
|
||||
column_info_for(column_info, primary_key),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" AND ");
|
||||
statements.push(format!("UPDATE {table} SET {sets} WHERE {where_clause};"));
|
||||
}
|
||||
statements
|
||||
}
|
||||
|
||||
pub fn build_data_grid_copy_insert_statement(options: DataGridCopyInsertStatementOptions) -> Option<String> {
|
||||
let save_columns = effective_copy_columns(options.source_columns.as_deref(), &options.columns);
|
||||
let column_info = options.table_meta.as_ref().and_then(|meta| meta.columns.as_deref()).unwrap_or(&[]);
|
||||
let primary_key_set: Vec<String> = options
|
||||
.table_meta
|
||||
.as_ref()
|
||||
.map(|meta| meta.primary_keys.iter().map(|primary_key| normalize_column_name(primary_key)).collect())
|
||||
.unwrap_or_default();
|
||||
let insertable_columns: Vec<(&str, usize)> = save_columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, column)| Some((column.as_deref()?, index)))
|
||||
.filter(|(column, _)| !is_oracle_row_id(options.database_type, Some(column)))
|
||||
.collect();
|
||||
let insert_columns: Vec<(&str, usize)> = insertable_columns
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|(column, _)| {
|
||||
!options.exclude_primary_keys || !primary_key_set.contains(&normalize_column_name(column))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if options.exclude_primary_keys && insert_columns.len() == insertable_columns.len() {
|
||||
return None;
|
||||
}
|
||||
if insert_columns.is_empty() || options.rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let table = options.table_meta.as_ref().map_or_else(
|
||||
|| "table_name".to_string(),
|
||||
|meta| qualified_table_name(options.database_type, meta.schema.as_deref(), &meta.table_name),
|
||||
);
|
||||
let columns = insert_columns
|
||||
.iter()
|
||||
.map(|(column, _)| quote_ident(options.database_type, column))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let value_rows = options
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
format!(
|
||||
"({})",
|
||||
insert_columns
|
||||
.iter()
|
||||
.map(|(column, index)| {
|
||||
format_grid_sql_literal(
|
||||
row.get(*index).unwrap_or(&Value::Null),
|
||||
options.database_type,
|
||||
column_info_for(column_info, column),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Some(format!(
|
||||
"INSERT INTO {table} ({columns}) VALUES{}{};",
|
||||
if value_rows.len() == 1 { " " } else { "\n" },
|
||||
value_rows.join(",\n")
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_data_grid_context_filter_condition(options: DataGridContextFilterConditionOptions) -> Option<String> {
|
||||
let column = column_filter_ref(options.database_type, &options.column_name);
|
||||
let value = &options.value;
|
||||
match options.mode {
|
||||
DataGridContextFilterMode::IsNull => Some(format!("{column} IS NULL")),
|
||||
DataGridContextFilterMode::IsNotNull => Some(format!("{column} IS NOT NULL")),
|
||||
DataGridContextFilterMode::Equals if value.is_null() => Some(format!("{column} IS NULL")),
|
||||
DataGridContextFilterMode::NotEquals if value.is_null() => Some(format!("{column} IS NOT NULL")),
|
||||
DataGridContextFilterMode::Like => Some(format!(
|
||||
"{column} LIKE {}",
|
||||
format_grid_sql_literal(
|
||||
&Value::String(format!("%{}%", value_to_filter_text(value))),
|
||||
options.database_type,
|
||||
None
|
||||
)
|
||||
)),
|
||||
DataGridContextFilterMode::NotLike => Some(format!(
|
||||
"{column} NOT LIKE {}",
|
||||
format_grid_sql_literal(
|
||||
&Value::String(format!("%{}%", value_to_filter_text(value))),
|
||||
options.database_type,
|
||||
None
|
||||
)
|
||||
)),
|
||||
DataGridContextFilterMode::LessThan => Some(format!(
|
||||
"{column} < {}",
|
||||
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
|
||||
)),
|
||||
DataGridContextFilterMode::GreaterThan => Some(format!(
|
||||
"{column} > {}",
|
||||
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
|
||||
)),
|
||||
DataGridContextFilterMode::Equals => Some(format!(
|
||||
"{column} = {}",
|
||||
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
|
||||
)),
|
||||
DataGridContextFilterMode::NotEquals => Some(format!(
|
||||
"{column} <> {}",
|
||||
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_data_grid_column_value_filter_condition(
|
||||
options: DataGridColumnValueFilterConditionOptions,
|
||||
) -> Option<String> {
|
||||
let text = options.raw_value.trim();
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let column = column_filter_ref(options.database_type, &options.column_name);
|
||||
if text.eq_ignore_ascii_case("null") {
|
||||
return Some(format!("{column} IS NULL"));
|
||||
}
|
||||
let value = parse_typed_filter_value(text, options.column_info.as_ref());
|
||||
Some(format!("{column} = {}", format_grid_sql_literal(&value, options.database_type, options.column_info.as_ref())))
|
||||
}
|
||||
|
||||
pub fn build_data_grid_count_sql(options: DataGridCountSqlOptions) -> String {
|
||||
let table = qualified_table_name(options.database_type, options.schema.as_deref(), &options.table_name);
|
||||
let predicate = crate::sql_dialect::normalize_where_input(options.where_input.as_deref());
|
||||
let where_clause = if predicate.is_empty() { String::new() } else { format!(" WHERE ({predicate})") };
|
||||
format!("SELECT COUNT(*) AS cnt FROM {table}{where_clause}")
|
||||
}
|
||||
|
||||
pub fn build_hive_table_properties_sql(options: HiveTablePropertiesSqlOptions) -> String {
|
||||
let table = qualified_table_name(Some(DatabaseType::Hive), options.schema.as_deref(), &options.table_name);
|
||||
let property = options.property_name.replace('\'', "''");
|
||||
format!("SHOW TBLPROPERTIES {table} ('{property}')")
|
||||
}
|
||||
|
||||
fn validate_data_grid_save(options: &DataGridSaveStatementOptions) -> Option<String> {
|
||||
if let Some(error) = validate_inserted_primary_keys(options) {
|
||||
return Some(error);
|
||||
@@ -590,6 +889,13 @@ fn effective_columns(options: &DataGridSaveStatementOptions) -> Vec<Option<Strin
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_copy_columns(source_columns: Option<&[Option<String>]>, columns: &[String]) -> Vec<Option<String>> {
|
||||
match source_columns {
|
||||
Some(source_columns) if source_columns.len() == columns.len() => source_columns.to_vec(),
|
||||
_ => columns.iter().map(|column| Some(column.clone())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_column(options: &DataGridSaveStatementOptions, index: usize) -> Option<&str> {
|
||||
match &options.source_columns {
|
||||
Some(source_columns) if source_columns.len() == options.columns.len() => source_columns.get(index)?.as_deref(),
|
||||
@@ -984,6 +1290,93 @@ fn qualified_table_name(database_type: Option<DatabaseType>, schema: Option<&str
|
||||
crate::sql_dialect::qualified_table_name(database_type, schema, table_name)
|
||||
}
|
||||
|
||||
fn column_filter_ref(database_type: Option<DatabaseType>, column_name: &str) -> String {
|
||||
let quoted = quote_ident(database_type, column_name);
|
||||
if database_type == Some(DatabaseType::Neo4j) {
|
||||
format!("n.{quoted}")
|
||||
} else {
|
||||
quoted
|
||||
}
|
||||
}
|
||||
|
||||
fn value_to_filter_text(value: &Value) -> String {
|
||||
if let Some(value) = value.as_str() {
|
||||
value.to_string()
|
||||
} else if value.is_null() {
|
||||
String::new()
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_typed_filter_value(text: &str, column_info: Option<&DataGridColumnInfo>) -> Value {
|
||||
let unquoted = unwrap_matching_quotes(text);
|
||||
let data_type = column_info.map(|column| column.data_type.to_ascii_lowercase()).unwrap_or_default();
|
||||
if is_boolean_type(&data_type) && unquoted.eq_ignore_ascii_case("true") {
|
||||
return Value::Bool(true);
|
||||
}
|
||||
if is_boolean_type(&data_type) && unquoted.eq_ignore_ascii_case("false") {
|
||||
return Value::Bool(false);
|
||||
}
|
||||
if (is_numeric_type(&data_type) || data_type.is_empty()) && is_numeric_literal(&unquoted) {
|
||||
if let Ok(number) = unquoted.parse::<serde_json::Number>() {
|
||||
return Value::Number(number);
|
||||
}
|
||||
}
|
||||
Value::String(unquoted)
|
||||
}
|
||||
|
||||
fn unwrap_matching_quotes(text: &str) -> String {
|
||||
let mut chars = text.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return String::new();
|
||||
};
|
||||
let Some(last) = text.chars().last() else {
|
||||
return String::new();
|
||||
};
|
||||
if text.len() >= 2 && ((first == '\'' && last == '\'') || (first == '"' && last == '"')) {
|
||||
text[1..text.len() - 1].to_string()
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_numeric_type(data_type: &str) -> bool {
|
||||
let lower = data_type.to_ascii_lowercase();
|
||||
[
|
||||
"int",
|
||||
"integer",
|
||||
"bigint",
|
||||
"smallint",
|
||||
"tinyint",
|
||||
"mediumint",
|
||||
"serial",
|
||||
"number",
|
||||
"numeric",
|
||||
"decimal",
|
||||
"float",
|
||||
"double",
|
||||
"real",
|
||||
"money",
|
||||
]
|
||||
.iter()
|
||||
.any(|part| lower.split(|ch: char| !ch.is_ascii_alphanumeric()).any(|token| token == *part))
|
||||
}
|
||||
|
||||
fn is_boolean_type(data_type: &str) -> bool {
|
||||
let lower = data_type.to_ascii_lowercase();
|
||||
lower.split(|ch: char| !ch.is_ascii_alphanumeric()).any(|token| matches!(token, "bool" | "boolean" | "bit"))
|
||||
}
|
||||
|
||||
fn is_numeric_literal(text: &str) -> bool {
|
||||
if text.trim() != text || text.is_empty() {
|
||||
return false;
|
||||
}
|
||||
text.parse::<f64>().is_ok_and(f64::is_finite)
|
||||
&& text.chars().all(|ch| ch.is_ascii_digit() || matches!(ch, '+' | '-' | '.' | 'e' | 'E'))
|
||||
&& text.chars().any(|ch| ch.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn uses_keyless_row_predicate(database_type: Option<DatabaseType>) -> bool {
|
||||
matches!(
|
||||
database_type,
|
||||
@@ -1110,6 +1503,109 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_copy_update_statements() {
|
||||
let statements = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
table_meta: DataGridTableMeta {
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "users".to_string(),
|
||||
primary_keys: vec!["id".to_string()],
|
||||
columns: None,
|
||||
},
|
||||
columns: vec!["id".to_string(), "name".to_string(), "status".to_string()],
|
||||
source_columns: None,
|
||||
rows: vec![vec![json!(1), json!("Ada"), json!("active")]],
|
||||
});
|
||||
assert_eq!(
|
||||
statements,
|
||||
vec!["UPDATE \"public\".\"users\" SET \"name\" = 'Ada', \"status\" = 'active' WHERE \"id\" = 1;"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_copy_insert_statement_without_primary_keys() {
|
||||
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
table_meta: Some(DataGridTableMeta {
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
primary_keys: vec!["id".to_string()],
|
||||
columns: None,
|
||||
}),
|
||||
columns: vec!["id".to_string(), "login_name".to_string(), "display_name".to_string()],
|
||||
source_columns: None,
|
||||
rows: vec![vec![json!(1), json!("ada"), json!("Ada")], vec![json!(2), json!("linus"), json!("Linus")]],
|
||||
exclude_primary_keys: true,
|
||||
});
|
||||
assert_eq!(
|
||||
statement.as_deref(),
|
||||
Some("INSERT INTO `users` (`login_name`, `display_name`) VALUES\n('ada', 'Ada'),\n('linus', 'Linus');")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_filter_conditions() {
|
||||
assert_eq!(
|
||||
build_data_grid_column_value_filter_condition(DataGridColumnValueFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
column_name: "id".to_string(),
|
||||
column_info: Some(column("id", "int", false, None)),
|
||||
raw_value: "49436".to_string(),
|
||||
})
|
||||
.as_deref(),
|
||||
Some("`id` = 49436")
|
||||
);
|
||||
assert_eq!(
|
||||
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
column_name: "status".to_string(),
|
||||
mode: DataGridContextFilterMode::Like,
|
||||
value: json!("active"),
|
||||
column_info: Some(column("status", "varchar", true, None)),
|
||||
})
|
||||
.as_deref(),
|
||||
Some("\"status\" LIKE '%active%'")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_grid_count_sql() {
|
||||
assert_eq!(
|
||||
build_data_grid_count_sql(DataGridCountSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "users".to_string(),
|
||||
where_input: Some("WHERE active = true;".to_string()),
|
||||
}),
|
||||
"SELECT COUNT(*) AS cnt FROM \"public\".\"users\" WHERE (active = true)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_hive_table_properties_sql() {
|
||||
assert_eq!(
|
||||
build_hive_table_properties_sql(HiveTablePropertiesSqlOptions {
|
||||
schema: Some("default".to_string()),
|
||||
table_name: "events".to_string(),
|
||||
property_name: "transactional".to_string(),
|
||||
}),
|
||||
"SHOW TBLPROPERTIES `events` ('transactional')"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_temporal_copy_literals() {
|
||||
assert_eq!(
|
||||
format_grid_sql_literal(&json!("2026-05-12T00:00:00+00:00"), Some(DatabaseType::Mysql), None),
|
||||
"'2026-05-12 00:00:00'"
|
||||
);
|
||||
assert_eq!(
|
||||
format_grid_sql_literal(&json!("2026-05-12T00:00:00.123456Z"), Some(DatabaseType::Mysql), None),
|
||||
"'2026-05-12 00:00:00.123456'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepares_sqlserver_save_statements() {
|
||||
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::io::Write;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::sql_dialect::{qualified_table_name, quote_table_identifier};
|
||||
|
||||
static EXPORT_CANCELLED: std::sync::LazyLock<RwLock<HashSet<String>>> =
|
||||
std::sync::LazyLock::new(|| RwLock::new(HashSet::new()));
|
||||
@@ -47,6 +49,183 @@ pub enum ExportStatus {
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
pub const DATABASE_EXPORT_ROW_LIMIT: usize = 10_000;
|
||||
pub const DATABASE_EXPORT_PAGE_SIZE: usize = 500;
|
||||
pub const DATABASE_EXPORT_INSERT_BATCH_SIZE: usize = 100;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExportedTableSql {
|
||||
pub display_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub table_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub qualified_table_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ddl: Option<String>,
|
||||
#[serde(default)]
|
||||
pub columns: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub rows: Vec<Vec<Value>>,
|
||||
#[serde(default)]
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildExportInsertStatementsOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub table_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub qualified_table_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub columns: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub rows: Vec<Vec<Value>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub batch_size: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildExportSqlInsertOptions {
|
||||
#[serde(flatten)]
|
||||
pub insert: BuildExportInsertStatementsOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDatabaseSqlExportOptions {
|
||||
pub database_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub exported_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tables: Vec<ExportedTableSql>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub row_limit_per_table: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub insert_batch_size: Option<usize>,
|
||||
}
|
||||
|
||||
pub fn format_export_sql_literal(value: &Value) -> String {
|
||||
if value.is_null() {
|
||||
return "NULL".to_string();
|
||||
}
|
||||
if let Some(number) = value.as_number() {
|
||||
return number.to_string();
|
||||
}
|
||||
if let Some(value) = value.as_bool() {
|
||||
return if value { "TRUE" } else { "FALSE" }.to_string();
|
||||
}
|
||||
let text = value.as_str().map_or_else(|| value.to_string(), ToString::to_string);
|
||||
format!("'{}'", text.replace('\'', "''"))
|
||||
}
|
||||
|
||||
pub fn build_export_insert_statements(options: BuildExportInsertStatementsOptions) -> Result<Vec<String>, String> {
|
||||
if options.columns.is_empty() || options.rows.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let table = export_qualified_table_name(
|
||||
options.database_type,
|
||||
options.schema.as_deref(),
|
||||
options.table_name.as_deref(),
|
||||
options.qualified_table_name.as_deref(),
|
||||
)?;
|
||||
let batch_size = options.batch_size.unwrap_or(DATABASE_EXPORT_INSERT_BATCH_SIZE).max(1);
|
||||
let columns = options
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| quote_table_identifier(options.database_type, column))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let mut statements = Vec::new();
|
||||
|
||||
for rows in options.rows.chunks(batch_size) {
|
||||
let values = rows
|
||||
.iter()
|
||||
.map(|row| format!("({})", row.iter().map(format_export_sql_literal).collect::<Vec<_>>().join(", ")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
statements.push(format!("INSERT INTO {table} ({columns}) VALUES {values};"));
|
||||
}
|
||||
|
||||
Ok(statements)
|
||||
}
|
||||
|
||||
pub fn build_export_sql_insert(options: BuildExportSqlInsertOptions) -> Result<String, String> {
|
||||
build_export_insert_statements(options.insert).map(|statements| statements.join("\n"))
|
||||
}
|
||||
|
||||
pub fn build_database_sql_export(options: BuildDatabaseSqlExportOptions) -> Result<String, String> {
|
||||
let exported_at = options.exported_at.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
|
||||
let row_limit = options.row_limit_per_table.unwrap_or(DATABASE_EXPORT_ROW_LIMIT);
|
||||
let insert_batch_size = options.insert_batch_size.unwrap_or(DATABASE_EXPORT_INSERT_BATCH_SIZE);
|
||||
let mut lines = vec![
|
||||
"-- DBX database export".to_string(),
|
||||
format!("-- Database: {}", options.database_name),
|
||||
format!("-- Exported at: {exported_at}"),
|
||||
format!("-- Row limit per table: {row_limit}"),
|
||||
String::new(),
|
||||
];
|
||||
|
||||
for table in options.tables {
|
||||
if let Some(ddl) = table.ddl.as_ref().map(|ddl| ddl.trim()).filter(|ddl| !ddl.is_empty()) {
|
||||
lines.push(format!("-- Structure for {}", table.display_name));
|
||||
lines.push(format!("{};", ddl.trim_end_matches(';')));
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
lines.push(format!("-- Data for {}", table.display_name));
|
||||
if table.truncated {
|
||||
lines.push(format!("-- Exported rows: {} (truncated at {row_limit})", table.rows.len()));
|
||||
} else {
|
||||
lines.push(format!("-- Exported rows: {}", table.rows.len()));
|
||||
}
|
||||
|
||||
let inserts = build_export_insert_statements(BuildExportInsertStatementsOptions {
|
||||
database_type: table.database_type,
|
||||
schema: table.schema,
|
||||
table_name: table.table_name,
|
||||
qualified_table_name: table.qualified_table_name,
|
||||
columns: table.columns,
|
||||
rows: table.rows,
|
||||
batch_size: Some(insert_batch_size),
|
||||
})?;
|
||||
if inserts.is_empty() {
|
||||
lines.push("-- No rows".to_string());
|
||||
} else {
|
||||
lines.extend(inserts);
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn export_qualified_table_name(
|
||||
database_type: Option<DatabaseType>,
|
||||
schema: Option<&str>,
|
||||
table_name: Option<&str>,
|
||||
qualified_name: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
if let Some(name) = qualified_name.filter(|name| !name.trim().is_empty()) {
|
||||
return Ok(name.to_string());
|
||||
}
|
||||
let table_name = table_name
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.ok_or_else(|| "tableName is required when qualifiedTableName is not provided".to_string())?;
|
||||
Ok(qualified_table_name(database_type, schema, table_name))
|
||||
}
|
||||
|
||||
pub async fn is_export_cancelled(export_id: &str) -> bool {
|
||||
EXPORT_CANCELLED.read().await.contains(export_id)
|
||||
}
|
||||
@@ -463,9 +642,15 @@ fn drop_table_if_exists_sql(table_name: &str, schema: &str, db_type: &DatabaseTy
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{drop_table_if_exists_sql, filter_selected_table_infos};
|
||||
use super::{
|
||||
build_database_sql_export, build_export_insert_statements, drop_table_if_exists_sql,
|
||||
filter_selected_table_infos, format_export_sql_literal, BuildDatabaseSqlExportOptions,
|
||||
BuildExportInsertStatementsOptions, ExportedTableSql, DATABASE_EXPORT_INSERT_BATCH_SIZE,
|
||||
DATABASE_EXPORT_ROW_LIMIT,
|
||||
};
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::types::TableInfo;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn table(name: &str, table_type: &str) -> TableInfo {
|
||||
TableInfo { name: name.to_string(), table_type: table_type.to_string(), comment: None }
|
||||
@@ -502,4 +687,75 @@ mod tests {
|
||||
|
||||
assert_eq!(sql, "DROP TABLE IF EXISTS \"users\";");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_sql_literals_for_export_inserts() {
|
||||
assert_eq!(format_export_sql_literal(&Value::Null), "NULL");
|
||||
assert_eq!(format_export_sql_literal(&json!(42)), "42");
|
||||
assert_eq!(format_export_sql_literal(&json!(true)), "TRUE");
|
||||
assert_eq!(format_export_sql_literal(&json!("O'Hara")), "'O''Hara'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_batched_insert_statements_for_export() {
|
||||
let statements = build_export_insert_statements(BuildExportInsertStatementsOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: Some("users".to_string()),
|
||||
qualified_table_name: None,
|
||||
columns: vec!["id".to_string(), "name".to_string()],
|
||||
rows: vec![vec![json!(1), json!("Ada")], vec![json!(2), json!("O'Hara")], vec![json!(3), json!("Linus")]],
|
||||
batch_size: Some(2),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
statements,
|
||||
vec![
|
||||
"INSERT INTO `users` (`id`, `name`) VALUES (1, 'Ada'), (2, 'O''Hara');",
|
||||
"INSERT INTO `users` (`id`, `name`) VALUES (3, 'Linus');",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_database_sql_export_with_ddl_before_data() {
|
||||
let sql = build_database_sql_export(BuildDatabaseSqlExportOptions {
|
||||
database_name: "app".to_string(),
|
||||
exported_at: Some("2026-05-02T00:00:00.000Z".to_string()),
|
||||
tables: vec![ExportedTableSql {
|
||||
display_name: "users".to_string(),
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: Some("users".to_string()),
|
||||
qualified_table_name: None,
|
||||
ddl: Some("CREATE TABLE `users` (`id` int);".to_string()),
|
||||
columns: vec!["id".to_string()],
|
||||
rows: vec![vec![json!(1)]],
|
||||
truncated: true,
|
||||
}],
|
||||
row_limit_per_table: Some(DATABASE_EXPORT_ROW_LIMIT),
|
||||
insert_batch_size: Some(DATABASE_EXPORT_INSERT_BATCH_SIZE),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sql,
|
||||
vec![
|
||||
"-- DBX database export".to_string(),
|
||||
"-- Database: app".to_string(),
|
||||
"-- Exported at: 2026-05-02T00:00:00.000Z".to_string(),
|
||||
format!("-- Row limit per table: {DATABASE_EXPORT_ROW_LIMIT}"),
|
||||
String::new(),
|
||||
"-- Structure for users".to_string(),
|
||||
"CREATE TABLE `users` (`id` int);".to_string(),
|
||||
String::new(),
|
||||
"-- Data for users".to_string(),
|
||||
format!("-- Exported rows: 1 (truncated at {DATABASE_EXPORT_ROW_LIMIT})"),
|
||||
"INSERT INTO `users` (`id`) VALUES (1);".to_string(),
|
||||
String::new(),
|
||||
]
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::sql_dialect::{qualified_table_name, quote_table_identifier, uses_fetch_first};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DatabaseSearchColumn {
|
||||
pub name: String,
|
||||
pub data_type: String,
|
||||
#[serde(default)]
|
||||
pub is_primary_key: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DatabaseSearchSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub table_name: String,
|
||||
#[serde(default)]
|
||||
pub columns: Vec<DatabaseSearchColumn>,
|
||||
pub term: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DatabaseSearchSql {
|
||||
pub sql: String,
|
||||
pub searchable_columns: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchResultWhereOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default)]
|
||||
pub columns: Vec<DatabaseSearchColumn>,
|
||||
#[serde(default)]
|
||||
pub result_columns: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub row: Vec<Value>,
|
||||
#[serde(default)]
|
||||
pub matched_columns: Vec<String>,
|
||||
}
|
||||
|
||||
const TEXT_TYPES: &[&str] =
|
||||
&["char", "text", "clob", "varchar", "nvarchar", "nchar", "uuid", "uniqueidentifier", "enum"];
|
||||
const NUMBER_TYPES: &[&str] = &["int", "serial", "number", "numeric", "decimal", "float", "double", "real", "money"];
|
||||
const SKIPPED_TYPES: &[&str] = &["blob", "binary", "bytea", "image", "geometry", "geography"];
|
||||
|
||||
pub fn is_text_search_column(column: &DatabaseSearchColumn) -> bool {
|
||||
let data_type = column.data_type.to_ascii_lowercase();
|
||||
if SKIPPED_TYPES.iter().any(|skipped| data_type.contains(skipped)) {
|
||||
return false;
|
||||
}
|
||||
TEXT_TYPES.iter().any(|text_type| data_type.contains(text_type))
|
||||
}
|
||||
|
||||
pub fn is_numeric_search_column(column: &DatabaseSearchColumn) -> bool {
|
||||
let data_type = column.data_type.to_ascii_lowercase();
|
||||
if SKIPPED_TYPES.iter().any(|skipped| data_type.contains(skipped)) {
|
||||
return false;
|
||||
}
|
||||
NUMBER_TYPES.iter().any(|number_type| data_type.contains(number_type))
|
||||
}
|
||||
|
||||
pub fn build_database_search_sql(options: DatabaseSearchSqlOptions) -> Option<DatabaseSearchSql> {
|
||||
let term = clean_term(&options.term);
|
||||
if term.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let text_columns: Vec<_> = options.columns.iter().filter(|column| is_text_search_column(column)).collect();
|
||||
let numeric_term = parse_numeric_term(&term);
|
||||
let numeric_columns: Vec<_> = if numeric_term.is_some() {
|
||||
options.columns.iter().filter(|column| is_numeric_search_column(column)).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let mut conditions = Vec::new();
|
||||
for column in &text_columns {
|
||||
let identifier = quote_table_identifier(options.database_type, &column.name);
|
||||
conditions.push(format!(
|
||||
"{} LIKE {} ESCAPE '~'",
|
||||
text_cast_expression(options.database_type, &identifier),
|
||||
sql_string_literal(&like_pattern(&term))
|
||||
));
|
||||
}
|
||||
for column in &numeric_columns {
|
||||
let identifier = quote_table_identifier(options.database_type, &column.name);
|
||||
conditions.push(format!("{identifier} = {}", numeric_term.as_deref().unwrap_or_default()));
|
||||
}
|
||||
if conditions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let table = qualified_table_name(options.database_type, options.schema.as_deref(), &options.table_name);
|
||||
let where_clause = conditions.join(" OR ");
|
||||
let limit = clamp_limit(options.limit);
|
||||
let searchable_columns =
|
||||
text_columns.iter().chain(numeric_columns.iter()).map(|column| column.name.clone()).collect();
|
||||
|
||||
let sql = if options.database_type == Some(DatabaseType::SqlServer) {
|
||||
format!("SELECT TOP {limit} * FROM {table} WHERE ({where_clause})")
|
||||
} else if options.database_type.is_some_and(uses_fetch_first) {
|
||||
format!("SELECT * FROM {table} WHERE ({where_clause}) FETCH FIRST {limit} ROWS ONLY")
|
||||
} else {
|
||||
format!("SELECT * FROM {table} WHERE ({where_clause}) LIMIT {limit};")
|
||||
};
|
||||
|
||||
Some(DatabaseSearchSql { sql, searchable_columns })
|
||||
}
|
||||
|
||||
pub fn build_search_result_where(options: SearchResultWhereOptions) -> String {
|
||||
let mut value_by_column = std::collections::HashMap::new();
|
||||
for (index, column) in options.result_columns.iter().enumerate() {
|
||||
if let Some(value) = options.row.get(index) {
|
||||
value_by_column.insert(column.to_ascii_lowercase(), value);
|
||||
}
|
||||
}
|
||||
|
||||
let primary_columns: Vec<_> = options
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| column.is_primary_key && value_by_column.contains_key(&column.name.to_ascii_lowercase()))
|
||||
.collect();
|
||||
let fallback_columns: Vec<_> = options
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| {
|
||||
options.matched_columns.iter().any(|name| name.eq_ignore_ascii_case(&column.name))
|
||||
&& value_by_column.contains_key(&column.name.to_ascii_lowercase())
|
||||
})
|
||||
.collect();
|
||||
let selected_columns = if primary_columns.is_empty() { fallback_columns } else { primary_columns };
|
||||
|
||||
selected_columns
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|column| {
|
||||
let identifier = quote_table_identifier(options.database_type, &column.name);
|
||||
let value = value_by_column.get(&column.name.to_ascii_lowercase()).copied().unwrap_or(&Value::Null);
|
||||
if value.is_null() {
|
||||
format!("{identifier} IS NULL")
|
||||
} else {
|
||||
format!("{identifier} = {}", sql_value_literal(options.database_type, column, value))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" AND ")
|
||||
}
|
||||
|
||||
fn clean_term(term: &str) -> String {
|
||||
term.trim().to_string()
|
||||
}
|
||||
|
||||
fn clamp_limit(limit: Option<usize>) -> usize {
|
||||
limit.unwrap_or(20).clamp(1, 100)
|
||||
}
|
||||
|
||||
fn parse_numeric_term(term: &str) -> Option<String> {
|
||||
let trimmed = clean_term(term);
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bytes = trimmed.as_bytes();
|
||||
let start = usize::from(matches!(bytes.first(), Some(b'+') | Some(b'-')));
|
||||
let number = &trimmed[start..];
|
||||
if number.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut dot_count = 0;
|
||||
let mut digit_count = 0;
|
||||
for ch in number.chars() {
|
||||
if ch == '.' {
|
||||
dot_count += 1;
|
||||
if dot_count > 1 {
|
||||
return None;
|
||||
}
|
||||
} else if ch.is_ascii_digit() {
|
||||
digit_count += 1;
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if digit_count == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fn sql_string_literal(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn like_pattern(term: &str) -> String {
|
||||
let mut pattern = String::from("%");
|
||||
for ch in clean_term(term).to_ascii_lowercase().chars() {
|
||||
if matches!(ch, '~' | '%' | '_') {
|
||||
pattern.push('~');
|
||||
}
|
||||
pattern.push(ch);
|
||||
}
|
||||
pattern.push('%');
|
||||
pattern
|
||||
}
|
||||
|
||||
fn text_cast_expression(database_type: Option<DatabaseType>, identifier: &str) -> String {
|
||||
match database_type {
|
||||
Some(DatabaseType::Mysql) => format!("LOWER(CAST({identifier} AS CHAR))"),
|
||||
Some(DatabaseType::SqlServer) => format!("LOWER(CAST({identifier} AS NVARCHAR(MAX)))"),
|
||||
Some(DatabaseType::Oracle) => format!("LOWER(CAST({identifier} AS VARCHAR2(4000)))"),
|
||||
Some(DatabaseType::ClickHouse) => format!("lower(toString({identifier}))"),
|
||||
_ => format!("LOWER(CAST({identifier} AS TEXT))"),
|
||||
}
|
||||
}
|
||||
|
||||
fn sql_value_literal(database_type: Option<DatabaseType>, column: &DatabaseSearchColumn, value: &Value) -> String {
|
||||
match value {
|
||||
Value::Null => "NULL".to_string(),
|
||||
Value::Number(number) => number.to_string(),
|
||||
Value::Bool(value) => {
|
||||
if *value {
|
||||
"TRUE".to_string()
|
||||
} else {
|
||||
"FALSE".to_string()
|
||||
}
|
||||
}
|
||||
Value::String(value) => {
|
||||
if is_numeric_search_column(column) && parse_numeric_term(value).is_some() {
|
||||
return value.trim().to_string();
|
||||
}
|
||||
if database_type == Some(DatabaseType::SqlServer) {
|
||||
format!("N{}", sql_string_literal(value))
|
||||
} else {
|
||||
sql_string_literal(value)
|
||||
}
|
||||
}
|
||||
other => sql_string_literal(&other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn col(name: &str, data_type: &str, is_primary_key: bool) -> DatabaseSearchColumn {
|
||||
DatabaseSearchColumn { name: name.to_string(), data_type: data_type.to_string(), is_primary_key }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_table_search_query_over_text_columns() {
|
||||
let query = build_database_search_sql(DatabaseSearchSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![col("id", "bigint", true), col("email", "varchar", false), col("avatar", "blob", false)],
|
||||
term: "alice@example.com".to_string(),
|
||||
limit: Some(20),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(query.searchable_columns, vec!["email"]);
|
||||
assert_eq!(
|
||||
query.sql,
|
||||
"SELECT * FROM `users` WHERE (LOWER(CAST(`email` AS CHAR)) LIKE '%alice@example.com%' ESCAPE '~') LIMIT 20;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adds_numeric_predicates_only_for_numeric_terms() {
|
||||
let query = build_database_search_sql(DatabaseSearchSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "orders".to_string(),
|
||||
columns: vec![col("id", "integer", true), col("note", "text", false)],
|
||||
term: "42".to_string(),
|
||||
limit: Some(10),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(query.searchable_columns, vec!["note", "id"]);
|
||||
assert!(query.sql.contains("\"id\" = 42"));
|
||||
assert!(query.sql.contains("FROM \"public\".\"orders\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_tables_without_searchable_columns() {
|
||||
assert_eq!(
|
||||
build_database_search_sql(DatabaseSearchSqlOptions {
|
||||
database_type: Some(DatabaseType::Sqlite),
|
||||
schema: None,
|
||||
table_name: "files".to_string(),
|
||||
columns: vec![col("payload", "blob", false)],
|
||||
term: "needle".to_string(),
|
||||
limit: None,
|
||||
}),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_stable_where_predicate_for_opening_result_rows() {
|
||||
let where_clause = build_search_result_where(SearchResultWhereOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
columns: vec![col("id", "integer", true), col("email", "varchar", false)],
|
||||
result_columns: vec!["id".to_string(), "email".to_string()],
|
||||
row: vec![Value::from(42), Value::from("alice@example.com")],
|
||||
matched_columns: Vec::new(),
|
||||
});
|
||||
|
||||
assert_eq!(where_clause, "[id] = 42");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::sql_dialect::{is_schema_aware, quote_table_identifier};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum DatabaseObjectType {
|
||||
Table,
|
||||
View,
|
||||
Procedure,
|
||||
Function,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RenameObjectSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub object_type: DatabaseObjectType,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub old_name: String,
|
||||
pub new_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateDatabaseSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub driver_profile: Option<String>,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub charset: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub collation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DuckDbAttachDatabaseSqlOptions {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DropObjectSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub object_type: DatabaseObjectType,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableAdminSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub table_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DatabaseNameSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SchemaNameSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DuplicateTableStructureSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub source_name: String,
|
||||
pub target_name: String,
|
||||
}
|
||||
|
||||
const MYSQL_COMPATIBLE_PROFILES: &[&str] =
|
||||
&["mysql", "mariadb", "tidb", "oceanbase", "doris", "starrocks", "custom_mysql"];
|
||||
|
||||
pub fn supports_create_database_charset(database_type: Option<DatabaseType>, driver_profile: Option<&str>) -> bool {
|
||||
matches!(
|
||||
database_type,
|
||||
Some(DatabaseType::Mysql | DatabaseType::Doris | DatabaseType::StarRocks | DatabaseType::Goldendb)
|
||||
) || driver_profile.is_some_and(|profile| MYSQL_COMPATIBLE_PROFILES.contains(&profile))
|
||||
}
|
||||
|
||||
pub fn build_create_database_sql(options: CreateDatabaseSqlOptions) -> String {
|
||||
let name = quote_table_identifier(options.database_type, &options.name);
|
||||
let charset = clean_sql_option(options.charset.as_deref());
|
||||
let collation = clean_sql_option(options.collation.as_deref());
|
||||
if !supports_create_database_charset(options.database_type, options.driver_profile.as_deref()) || charset.is_empty()
|
||||
{
|
||||
return format!("CREATE DATABASE {name};");
|
||||
}
|
||||
let collate_clause = if collation.is_empty() { String::new() } else { format!(" COLLATE {collation}") };
|
||||
format!("CREATE DATABASE {name} CHARACTER SET {charset}{collate_clause};")
|
||||
}
|
||||
|
||||
pub fn build_duckdb_attach_database_sql(options: DuckDbAttachDatabaseSqlOptions) -> String {
|
||||
format!(
|
||||
"ATTACH {} AS {};",
|
||||
quote_sql_string(&options.path),
|
||||
quote_table_identifier(Some(DatabaseType::DuckDb), &options.name)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_drop_object_sql(options: DropObjectSqlOptions) -> String {
|
||||
format!(
|
||||
"DROP {} {};",
|
||||
object_type_keyword(options.object_type),
|
||||
qualified_name(options.database_type, options.schema.as_deref(), &options.name)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_drop_table_sql(options: TableAdminSqlOptions) -> String {
|
||||
format!("DROP TABLE {};", qualified_name(options.database_type, options.schema.as_deref(), &options.table_name))
|
||||
}
|
||||
|
||||
pub fn build_empty_table_sql(options: TableAdminSqlOptions) -> String {
|
||||
format!("DELETE FROM {};", qualified_name(options.database_type, options.schema.as_deref(), &options.table_name))
|
||||
}
|
||||
|
||||
pub fn build_truncate_table_sql(options: TableAdminSqlOptions) -> String {
|
||||
let table = qualified_name(options.database_type, options.schema.as_deref(), &options.table_name);
|
||||
if matches!(options.database_type, Some(DatabaseType::Sqlite | DatabaseType::DuckDb)) {
|
||||
format!("DELETE FROM {table};")
|
||||
} else {
|
||||
format!("TRUNCATE TABLE {table};")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_drop_database_sql(options: DatabaseNameSqlOptions) -> String {
|
||||
format!("DROP DATABASE {};", quote_table_identifier(options.database_type, &options.name))
|
||||
}
|
||||
|
||||
pub fn build_create_schema_sql(options: SchemaNameSqlOptions) -> String {
|
||||
format!("CREATE SCHEMA {};", quote_table_identifier(options.database_type, &options.name))
|
||||
}
|
||||
|
||||
pub fn build_drop_schema_sql(options: SchemaNameSqlOptions) -> String {
|
||||
let schema = quote_table_identifier(options.database_type, &options.name);
|
||||
if matches!(options.database_type, Some(DatabaseType::Postgres | DatabaseType::Gaussdb)) {
|
||||
format!("DROP SCHEMA {schema} CASCADE;")
|
||||
} else {
|
||||
format!("DROP SCHEMA {schema};")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_duplicate_table_structure_sql(options: DuplicateTableStructureSqlOptions) -> String {
|
||||
let source = qualified_name(options.database_type, options.schema.as_deref(), &options.source_name);
|
||||
let target = qualified_name(options.database_type, options.schema.as_deref(), &options.target_name);
|
||||
if options.database_type == Some(DatabaseType::Mysql) {
|
||||
return format!("CREATE TABLE {target} LIKE {source};");
|
||||
}
|
||||
if options.database_type.is_some_and(is_postgres_like_structure_copy) {
|
||||
return format!("CREATE TABLE {target} (LIKE {source} INCLUDING ALL);");
|
||||
}
|
||||
if options.database_type == Some(DatabaseType::SqlServer) {
|
||||
return format!("SELECT TOP 0 * INTO {target} FROM {source};");
|
||||
}
|
||||
if options.database_type.is_some_and(uses_fetch_first_duplicate_structure) {
|
||||
return format!("CREATE TABLE {target} AS SELECT * FROM {source} WHERE 1=0");
|
||||
}
|
||||
format!("CREATE TABLE {target} AS SELECT * FROM {source} WHERE 0;")
|
||||
}
|
||||
|
||||
pub fn supports_object_rename(database_type: Option<DatabaseType>, object_type: DatabaseObjectType) -> bool {
|
||||
let Some(database_type) = database_type else {
|
||||
return false;
|
||||
};
|
||||
if database_type == DatabaseType::SqlServer {
|
||||
return true;
|
||||
}
|
||||
if matches!(object_type, DatabaseObjectType::Procedure | DatabaseObjectType::Function) {
|
||||
return false;
|
||||
}
|
||||
if database_type == DatabaseType::Sqlite {
|
||||
return object_type == DatabaseObjectType::Table;
|
||||
}
|
||||
if matches!(database_type, DatabaseType::Mysql | DatabaseType::Goldendb) {
|
||||
return matches!(object_type, DatabaseObjectType::Table | DatabaseObjectType::View);
|
||||
}
|
||||
if is_postgres_like_rename(database_type) || is_oracle_like_rename(database_type) {
|
||||
return matches!(object_type, DatabaseObjectType::Table | DatabaseObjectType::View);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn build_rename_object_sql(options: RenameObjectSqlOptions) -> Result<String, String> {
|
||||
let database_type = options.database_type;
|
||||
if !supports_object_rename(database_type, options.object_type) {
|
||||
return Err(format!(
|
||||
"Renaming {} is not supported for {}.",
|
||||
object_type_keyword(options.object_type),
|
||||
database_label(database_type)
|
||||
));
|
||||
}
|
||||
|
||||
if database_type == Some(DatabaseType::SqlServer) {
|
||||
return Ok(format!(
|
||||
"EXEC sp_rename {}, {}, N'OBJECT';",
|
||||
sqlserver_string(&sqlserver_object_name(options.schema.as_deref(), &options.old_name)),
|
||||
sqlserver_string(&options.new_name)
|
||||
));
|
||||
}
|
||||
|
||||
if matches!(database_type, Some(DatabaseType::Mysql | DatabaseType::Goldendb)) {
|
||||
return Ok(format!(
|
||||
"RENAME TABLE {} TO {};",
|
||||
qualified_name(database_type, options.schema.as_deref(), &options.old_name),
|
||||
qualified_name(database_type, options.schema.as_deref(), &options.new_name)
|
||||
));
|
||||
}
|
||||
|
||||
if database_type == Some(DatabaseType::Sqlite) {
|
||||
return Ok(format!(
|
||||
"ALTER TABLE {} RENAME TO {};",
|
||||
qualified_name(database_type, options.schema.as_deref(), &options.old_name),
|
||||
quote_rename_identifier(database_type, &options.new_name)
|
||||
));
|
||||
}
|
||||
|
||||
if database_type
|
||||
.is_some_and(|database_type| is_postgres_like_rename(database_type) || is_oracle_like_rename(database_type))
|
||||
{
|
||||
return Ok(format!(
|
||||
"ALTER {} {} RENAME TO {};",
|
||||
object_type_keyword(options.object_type),
|
||||
qualified_name(database_type, options.schema.as_deref(), &options.old_name),
|
||||
quote_rename_identifier(database_type, &options.new_name)
|
||||
));
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Renaming {} is not supported for {}.",
|
||||
object_type_keyword(options.object_type),
|
||||
database_label(database_type)
|
||||
))
|
||||
}
|
||||
|
||||
fn is_postgres_like_rename(database_type: DatabaseType) -> bool {
|
||||
matches!(
|
||||
database_type,
|
||||
DatabaseType::Postgres
|
||||
| DatabaseType::Redshift
|
||||
| DatabaseType::Gaussdb
|
||||
| DatabaseType::Kingbase
|
||||
| DatabaseType::Highgo
|
||||
| DatabaseType::Vastbase
|
||||
)
|
||||
}
|
||||
|
||||
fn is_oracle_like_rename(database_type: DatabaseType) -> bool {
|
||||
matches!(database_type, DatabaseType::Oracle | DatabaseType::Dameng)
|
||||
}
|
||||
|
||||
fn is_postgres_like_structure_copy(database_type: DatabaseType) -> bool {
|
||||
matches!(
|
||||
database_type,
|
||||
DatabaseType::Postgres | DatabaseType::Redshift | DatabaseType::Gaussdb | DatabaseType::OpenGauss
|
||||
)
|
||||
}
|
||||
|
||||
fn uses_fetch_first_duplicate_structure(database_type: DatabaseType) -> bool {
|
||||
matches!(database_type, DatabaseType::Oracle | DatabaseType::Dameng)
|
||||
}
|
||||
|
||||
fn sqlserver_string(value: &str) -> String {
|
||||
format!("N'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn quote_rename_identifier(database_type: Option<DatabaseType>, name: &str) -> String {
|
||||
if matches!(database_type, Some(DatabaseType::Mysql | DatabaseType::Goldendb)) {
|
||||
format!("`{}`", name.replace('`', "``"))
|
||||
} else {
|
||||
quote_table_identifier(database_type, name)
|
||||
}
|
||||
}
|
||||
|
||||
fn qualified_name(database_type: Option<DatabaseType>, schema: Option<&str>, name: &str) -> String {
|
||||
if database_type.is_some_and(is_schema_aware) && schema.is_some_and(|schema| !schema.is_empty()) {
|
||||
format!(
|
||||
"{}.{}",
|
||||
quote_rename_identifier(database_type, schema.unwrap()),
|
||||
quote_rename_identifier(database_type, name)
|
||||
)
|
||||
} else {
|
||||
quote_rename_identifier(database_type, name)
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlserver_object_name(schema: Option<&str>, name: &str) -> String {
|
||||
schema
|
||||
.filter(|schema| !schema.is_empty())
|
||||
.map(|schema| format!("{schema}.{name}"))
|
||||
.unwrap_or_else(|| name.to_string())
|
||||
}
|
||||
|
||||
fn object_type_keyword(object_type: DatabaseObjectType) -> &'static str {
|
||||
match object_type {
|
||||
DatabaseObjectType::Table => "TABLE",
|
||||
DatabaseObjectType::View => "VIEW",
|
||||
DatabaseObjectType::Procedure => "PROCEDURE",
|
||||
DatabaseObjectType::Function => "FUNCTION",
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_sql_option(value: Option<&str>) -> String {
|
||||
value.unwrap_or("").trim().replace([';', ' ', '\n', '\r', '\t'], "")
|
||||
}
|
||||
|
||||
fn quote_sql_string(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn database_label(database_type: Option<DatabaseType>) -> String {
|
||||
database_type
|
||||
.and_then(|database_type| serde_json::to_value(database_type).ok())
|
||||
.and_then(|value| value.as_str().map(str::to_string))
|
||||
.unwrap_or_else(|| "this database".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_mysql_create_database_sql_with_charset_and_collation() {
|
||||
assert_eq!(
|
||||
build_create_database_sql(CreateDatabaseSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
driver_profile: Some("mysql".to_string()),
|
||||
name: "app db".to_string(),
|
||||
charset: Some("utf8mb4".to_string()),
|
||||
collation: Some("utf8mb4_unicode_ci".to_string()),
|
||||
}),
|
||||
"CREATE DATABASE `app db` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_create_database_charset_for_non_mysql_types() {
|
||||
assert_eq!(
|
||||
build_create_database_sql(CreateDatabaseSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
driver_profile: None,
|
||||
name: "analytics".to_string(),
|
||||
charset: Some("utf8mb4".to_string()),
|
||||
collation: Some("utf8mb4_unicode_ci".to_string()),
|
||||
}),
|
||||
"CREATE DATABASE \"analytics\";"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_mysql_compatible_create_database_profiles() {
|
||||
assert!(supports_create_database_charset(Some(DatabaseType::Mysql), Some("oceanbase")));
|
||||
assert!(supports_create_database_charset(Some(DatabaseType::Mysql), Some("doris")));
|
||||
assert!(!supports_create_database_charset(Some(DatabaseType::Postgres), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_duckdb_attach_sql() {
|
||||
assert_eq!(
|
||||
build_duckdb_attach_database_sql(DuckDbAttachDatabaseSqlOptions {
|
||||
path: "/Users/me/O'Reilly analytics.duckdb".to_string(),
|
||||
name: "report db".to_string(),
|
||||
}),
|
||||
"ATTACH '/Users/me/O''Reilly analytics.duckdb' AS \"report db\";"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_drop_and_clear_table_sql() {
|
||||
let options = TableAdminSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "events".to_string(),
|
||||
};
|
||||
assert_eq!(build_drop_table_sql(options.clone()), "DROP TABLE \"public\".\"events\";");
|
||||
assert_eq!(build_empty_table_sql(options.clone()), "DELETE FROM \"public\".\"events\";");
|
||||
assert_eq!(build_truncate_table_sql(options), "TRUNCATE TABLE \"public\".\"events\";");
|
||||
assert_eq!(
|
||||
build_truncate_table_sql(TableAdminSqlOptions {
|
||||
database_type: Some(DatabaseType::DuckDb),
|
||||
schema: None,
|
||||
table_name: "events".to_string(),
|
||||
}),
|
||||
"DELETE FROM \"events\";"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_drop_object_database_and_schema_sql() {
|
||||
assert_eq!(
|
||||
build_drop_object_sql(DropObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
object_type: DatabaseObjectType::Procedure,
|
||||
schema: Some("dbo".to_string()),
|
||||
name: "refresh_cache".to_string(),
|
||||
}),
|
||||
"DROP PROCEDURE [dbo].[refresh_cache];"
|
||||
);
|
||||
assert_eq!(
|
||||
build_drop_database_sql(DatabaseNameSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
name: "app db".to_string(),
|
||||
}),
|
||||
"DROP DATABASE `app db`;"
|
||||
);
|
||||
assert_eq!(
|
||||
build_create_schema_sql(SchemaNameSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
name: "analytics".to_string(),
|
||||
}),
|
||||
"CREATE SCHEMA \"analytics\";"
|
||||
);
|
||||
assert_eq!(
|
||||
build_drop_schema_sql(SchemaNameSqlOptions {
|
||||
database_type: Some(DatabaseType::Gaussdb),
|
||||
name: "analytics".to_string(),
|
||||
}),
|
||||
"DROP SCHEMA \"analytics\" CASCADE;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_duplicate_table_structure_sql() {
|
||||
assert_eq!(
|
||||
build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
source_name: "users".to_string(),
|
||||
target_name: "users_copy".to_string(),
|
||||
}),
|
||||
"CREATE TABLE `users_copy` LIKE `users`;"
|
||||
);
|
||||
assert_eq!(
|
||||
build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: Some("public".to_string()),
|
||||
source_name: "users".to_string(),
|
||||
target_name: "users_copy".to_string(),
|
||||
}),
|
||||
"CREATE TABLE \"public\".\"users_copy\" (LIKE \"public\".\"users\" INCLUDING ALL);"
|
||||
);
|
||||
assert_eq!(
|
||||
build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
schema: Some("dbo".to_string()),
|
||||
source_name: "users".to_string(),
|
||||
target_name: "users_copy".to_string(),
|
||||
}),
|
||||
"SELECT TOP 0 * INTO [dbo].[users_copy] FROM [dbo].[users];"
|
||||
);
|
||||
assert_eq!(
|
||||
build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Oracle),
|
||||
schema: Some("HR".to_string()),
|
||||
source_name: "USERS".to_string(),
|
||||
target_name: "USERS_COPY".to_string(),
|
||||
}),
|
||||
"CREATE TABLE \"HR\".\"USERS_COPY\" AS SELECT * FROM \"HR\".\"USERS\" WHERE 1=0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_mysql_table_and_view_rename_sql() {
|
||||
assert_eq!(
|
||||
build_rename_object_sql(RenameObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
object_type: DatabaseObjectType::Table,
|
||||
schema: None,
|
||||
old_name: "users".to_string(),
|
||||
new_name: "app users".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
"RENAME TABLE `users` TO `app users`;"
|
||||
);
|
||||
assert_eq!(
|
||||
build_rename_object_sql(RenameObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::Goldendb),
|
||||
object_type: DatabaseObjectType::View,
|
||||
schema: None,
|
||||
old_name: "active_users".to_string(),
|
||||
new_name: "enabled_users".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
"RENAME TABLE `active_users` TO `enabled_users`;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_postgres_table_and_view_rename_sql() {
|
||||
assert_eq!(
|
||||
build_rename_object_sql(RenameObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
object_type: DatabaseObjectType::Table,
|
||||
schema: Some("public".to_string()),
|
||||
old_name: "orders".to_string(),
|
||||
new_name: "archived orders".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
"ALTER TABLE \"public\".\"orders\" RENAME TO \"archived orders\";"
|
||||
);
|
||||
assert_eq!(
|
||||
build_rename_object_sql(RenameObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
object_type: DatabaseObjectType::View,
|
||||
schema: Some("public".to_string()),
|
||||
old_name: "active_users".to_string(),
|
||||
new_name: "enabled_users".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
"ALTER VIEW \"public\".\"active_users\" RENAME TO \"enabled_users\";"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_sqlserver_routine_rename_sql() {
|
||||
assert_eq!(
|
||||
build_rename_object_sql(RenameObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
object_type: DatabaseObjectType::Function,
|
||||
schema: Some("dbo".to_string()),
|
||||
old_name: "fn_total".to_string(),
|
||||
new_name: "fn_order_total".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
"EXEC sp_rename N'dbo.fn_total', N'fn_order_total', N'OBJECT';"
|
||||
);
|
||||
assert!(supports_object_rename(Some(DatabaseType::SqlServer), DatabaseObjectType::Procedure));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_oracle_family_table_and_view_rename_sql() {
|
||||
assert_eq!(
|
||||
build_rename_object_sql(RenameObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::Oracle),
|
||||
object_type: DatabaseObjectType::Table,
|
||||
schema: Some("HR".to_string()),
|
||||
old_name: "EMPLOYEES".to_string(),
|
||||
new_name: "STAFF".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
"ALTER TABLE \"HR\".\"EMPLOYEES\" RENAME TO \"STAFF\";"
|
||||
);
|
||||
assert_eq!(
|
||||
build_rename_object_sql(RenameObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::Dameng),
|
||||
object_type: DatabaseObjectType::View,
|
||||
schema: Some("SYSDBA".to_string()),
|
||||
old_name: "ACTIVE_USERS".to_string(),
|
||||
new_name: "ENABLED_USERS".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
"ALTER VIEW \"SYSDBA\".\"ACTIVE_USERS\" RENAME TO \"ENABLED_USERS\";"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_direct_routine_renames() {
|
||||
assert!(!supports_object_rename(Some(DatabaseType::Oracle), DatabaseObjectType::Function));
|
||||
assert!(!supports_object_rename(Some(DatabaseType::Dameng), DatabaseObjectType::Procedure));
|
||||
assert!(build_rename_object_sql(RenameObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::Dameng),
|
||||
object_type: DatabaseObjectType::Procedure,
|
||||
schema: Some("SYSDBA".to_string()),
|
||||
old_name: "REFRESH_CACHE".to_string(),
|
||||
new_name: "REFRESH_CACHE_V2".to_string(),
|
||||
})
|
||||
.unwrap_err()
|
||||
.contains("Renaming PROCEDURE is not supported"));
|
||||
assert!(build_rename_object_sql(RenameObjectSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
object_type: DatabaseObjectType::Procedure,
|
||||
schema: None,
|
||||
old_name: "refresh_cache".to_string(),
|
||||
new_name: "refresh_cache_v2".to_string(),
|
||||
})
|
||||
.unwrap_err()
|
||||
.contains("Renaming PROCEDURE is not supported"));
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,19 @@ pub mod data_compare;
|
||||
pub mod data_grid_sql;
|
||||
pub mod database_capabilities;
|
||||
pub mod database_export;
|
||||
pub mod database_search_sql;
|
||||
pub mod db;
|
||||
pub mod db_admin_sql;
|
||||
pub mod external;
|
||||
pub mod history;
|
||||
pub mod models;
|
||||
pub mod mongo_ops;
|
||||
pub mod object_source_sql;
|
||||
pub mod plugins;
|
||||
pub mod query;
|
||||
pub mod query_cancel;
|
||||
pub mod query_execution_sql;
|
||||
pub mod query_result_sql;
|
||||
pub mod redis_ops;
|
||||
pub mod saved_sql;
|
||||
pub mod schema;
|
||||
@@ -26,6 +31,7 @@ pub mod sql_dialect;
|
||||
pub mod sql_editability;
|
||||
pub mod storage;
|
||||
pub mod table_import;
|
||||
pub mod table_structure_sql;
|
||||
pub mod text_export;
|
||||
pub mod transfer;
|
||||
pub mod types;
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::types::ObjectSourceKind;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditableObjectSourceSqlInput {
|
||||
pub database_type: DatabaseType,
|
||||
pub object_type: ObjectSourceKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub name: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RoutineRenameObjectSourceInput {
|
||||
pub database_type: DatabaseType,
|
||||
pub object_type: ObjectSourceKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub name: String,
|
||||
pub new_name: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildViewDdlInput {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub name: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ObjectSourceSaveExecutionMode {
|
||||
#[serde(rename = "single")]
|
||||
Single,
|
||||
#[serde(rename = "script")]
|
||||
Script,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct RoutineDeclaration {
|
||||
kind: ObjectSourceKind,
|
||||
name: String,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
pub fn supports_source_backed_routine_rename(
|
||||
database_type: Option<DatabaseType>,
|
||||
object_type: ObjectSourceKind,
|
||||
) -> bool {
|
||||
if !matches!(object_type, ObjectSourceKind::Function | ObjectSourceKind::Procedure) {
|
||||
return false;
|
||||
}
|
||||
let Some(database_type) = database_type else {
|
||||
return false;
|
||||
};
|
||||
database_type != DatabaseType::SqlServer
|
||||
&& (is_mysql_like(database_type) || is_postgres_like(database_type) || is_oracle_like(database_type))
|
||||
}
|
||||
|
||||
pub fn build_routine_rename_object_source_statements(
|
||||
input: RoutineRenameObjectSourceInput,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if !supports_source_backed_routine_rename(Some(input.database_type), input.object_type.clone()) {
|
||||
return Err(format!(
|
||||
"Renaming {:?} from source is not supported for {:?}.",
|
||||
input.object_type, input.database_type
|
||||
));
|
||||
}
|
||||
|
||||
let source = input.source.trim();
|
||||
let declaration = if is_mysql_like(input.database_type) {
|
||||
mysql_routine_declaration(source)
|
||||
} else {
|
||||
routine_declaration(source)
|
||||
};
|
||||
let Some(declaration) = declaration else {
|
||||
return Err(format!("Cannot find a CREATE {:?} declaration in the object source.", input.object_type));
|
||||
};
|
||||
if declaration.kind != input.object_type {
|
||||
return Err(format!("Cannot find a CREATE {:?} declaration in the object source.", input.object_type));
|
||||
}
|
||||
|
||||
let renamed_source = if is_mysql_like(input.database_type) {
|
||||
replace_mysql_routine_declaration_name(source, &input.new_name)
|
||||
} else {
|
||||
replace_sql_routine_declaration_name(source, input.schema.as_deref(), &input.new_name)
|
||||
};
|
||||
let Some(renamed_source) = renamed_source else {
|
||||
return Err(format!("Cannot rewrite the {:?} name in the object source.", input.object_type));
|
||||
};
|
||||
|
||||
if is_oracle_like(input.database_type) {
|
||||
return Ok(vec![
|
||||
ensure_semicolon(&renamed_source),
|
||||
format!(
|
||||
"DROP {} {};",
|
||||
object_type_keyword(&input.object_type),
|
||||
postgres_qualified_name(input.schema.as_deref(), &input.name)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
build_executable_object_source_statements(EditableObjectSourceSqlInput {
|
||||
database_type: input.database_type,
|
||||
object_type: input.object_type,
|
||||
schema: input.schema,
|
||||
name: input.name,
|
||||
source: renamed_source,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_executable_object_source_statements(input: EditableObjectSourceSqlInput) -> Result<Vec<String>, String> {
|
||||
let source = input.source.trim();
|
||||
if input.database_type == DatabaseType::SqlServer {
|
||||
return Ok(vec![replace_sqlserver_create_with_alter(source)]);
|
||||
}
|
||||
|
||||
if matches!(input.database_type, DatabaseType::Postgres | DatabaseType::Gaussdb)
|
||||
&& input.object_type == ObjectSourceKind::View
|
||||
{
|
||||
return Ok(vec![format!(
|
||||
"CREATE OR REPLACE VIEW {} AS\n{}",
|
||||
postgres_qualified_name(input.schema.as_deref(), &input.name),
|
||||
ensure_semicolon(source)
|
||||
)]);
|
||||
}
|
||||
|
||||
let create_statement = ensure_semicolon(source);
|
||||
let cleanup = build_routine_rename_cleanup(&input, source);
|
||||
Ok(if let Some(cleanup) = cleanup { vec![create_statement, cleanup] } else { vec![create_statement] })
|
||||
}
|
||||
|
||||
pub fn build_executable_object_source_sql(input: EditableObjectSourceSqlInput) -> Result<String, String> {
|
||||
Ok(build_executable_object_source_statements(input)?.join("\n"))
|
||||
}
|
||||
|
||||
pub fn build_view_ddl_sql(input: BuildViewDdlInput) -> String {
|
||||
let source = input.source.trim();
|
||||
if Regex::new(r"(?i)^(?:CREATE|ALTER)\s+").unwrap().is_match(source) {
|
||||
return ensure_semicolon(source);
|
||||
}
|
||||
|
||||
let qualified_name = if matches!(input.database_type, Some(DatabaseType::Mysql | DatabaseType::Goldendb)) {
|
||||
mysql_qualified_name(input.schema.as_deref(), &input.name)
|
||||
} else {
|
||||
postgres_qualified_name(input.schema.as_deref(), &input.name)
|
||||
};
|
||||
|
||||
if input.database_type.is_none()
|
||||
|| input
|
||||
.database_type
|
||||
.is_some_and(|database_type| is_postgres_like(database_type) || database_type == DatabaseType::OpenGauss)
|
||||
{
|
||||
return format!("CREATE OR REPLACE VIEW {qualified_name} AS\n{}", ensure_semicolon(source));
|
||||
}
|
||||
|
||||
format!("CREATE VIEW {qualified_name} AS\n{}", ensure_semicolon(source))
|
||||
}
|
||||
|
||||
pub fn object_source_save_execution_mode(_database_type: DatabaseType) -> ObjectSourceSaveExecutionMode {
|
||||
ObjectSourceSaveExecutionMode::Single
|
||||
}
|
||||
|
||||
fn build_routine_rename_cleanup(input: &EditableObjectSourceSqlInput, source: &str) -> Option<String> {
|
||||
if !matches!(input.object_type, ObjectSourceKind::Function | ObjectSourceKind::Procedure) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if is_mysql_like(input.database_type) {
|
||||
let declaration = mysql_routine_declaration(source)?;
|
||||
if declaration.kind != input.object_type || !routine_name_changed(&declaration.name, &input.name) {
|
||||
return None;
|
||||
}
|
||||
return Some(format!(
|
||||
"DROP {} IF EXISTS {};",
|
||||
object_type_keyword(&input.object_type),
|
||||
mysql_qualified_name(input.schema.as_deref(), &input.name)
|
||||
));
|
||||
}
|
||||
|
||||
if !is_postgres_like(input.database_type) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let declaration = routine_declaration(source)?;
|
||||
if declaration.kind != input.object_type || !routine_name_changed(&declaration.name, &input.name) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"DROP {} IF EXISTS {}{};",
|
||||
object_type_keyword(&input.object_type),
|
||||
postgres_qualified_name(input.schema.as_deref(), &input.name),
|
||||
declaration.signature
|
||||
))
|
||||
}
|
||||
|
||||
fn is_postgres_like(database_type: DatabaseType) -> bool {
|
||||
matches!(
|
||||
database_type,
|
||||
DatabaseType::Postgres
|
||||
| DatabaseType::Redshift
|
||||
| DatabaseType::Gaussdb
|
||||
| DatabaseType::Kingbase
|
||||
| DatabaseType::Highgo
|
||||
| DatabaseType::Vastbase
|
||||
)
|
||||
}
|
||||
|
||||
fn is_mysql_like(database_type: DatabaseType) -> bool {
|
||||
matches!(database_type, DatabaseType::Mysql | DatabaseType::Goldendb)
|
||||
}
|
||||
|
||||
fn is_oracle_like(database_type: DatabaseType) -> bool {
|
||||
matches!(database_type, DatabaseType::Oracle | DatabaseType::Dameng)
|
||||
}
|
||||
|
||||
fn object_type_keyword(object_type: &ObjectSourceKind) -> &'static str {
|
||||
match object_type {
|
||||
ObjectSourceKind::View => "VIEW",
|
||||
ObjectSourceKind::Procedure => "PROCEDURE",
|
||||
ObjectSourceKind::Function => "FUNCTION",
|
||||
}
|
||||
}
|
||||
|
||||
fn quote_postgres_identifier(value: &str) -> String {
|
||||
format!("\"{}\"", value.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
fn quote_mysql_identifier(value: &str) -> String {
|
||||
format!("`{}`", value.replace('`', "``"))
|
||||
}
|
||||
|
||||
fn ensure_semicolon(sql: &str) -> String {
|
||||
let trimmed = sql.trim();
|
||||
if trimmed.ends_with(';') {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{trimmed};")
|
||||
}
|
||||
}
|
||||
|
||||
fn postgres_qualified_name(schema: Option<&str>, name: &str) -> String {
|
||||
schema
|
||||
.into_iter()
|
||||
.chain(std::iter::once(name))
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(quote_postgres_identifier)
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
|
||||
fn mysql_qualified_name(schema: Option<&str>, name: &str) -> String {
|
||||
schema
|
||||
.into_iter()
|
||||
.chain(std::iter::once(name))
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(quote_mysql_identifier)
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
|
||||
fn unquote_postgres_identifier(value: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2 {
|
||||
trimmed[1..trimmed.len() - 1].replace("\"\"", "\"")
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn split_qualified_routine_name(value: &str) -> Vec<String> {
|
||||
Regex::new(r#""(?:""|[^"])+"|[A-Za-z_][\w$]*"#)
|
||||
.unwrap()
|
||||
.find_iter(value)
|
||||
.map(|part| unquote_postgres_identifier(part.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn unquote_mysql_identifier(value: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.starts_with('`') && trimmed.ends_with('`') && trimmed.len() >= 2 {
|
||||
trimmed[1..trimmed.len() - 1].replace("``", "`")
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn split_mysql_qualified_routine_name(value: &str) -> Vec<String> {
|
||||
Regex::new(r"`(?:``|[^`])+`|[A-Za-z_][\w$]*")
|
||||
.unwrap()
|
||||
.find_iter(value)
|
||||
.map(|part| unquote_mysql_identifier(part.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn routine_declaration(source: &str) -> Option<RoutineDeclaration> {
|
||||
let re = Regex::new(
|
||||
r#"(?is)^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:(?:NON)?EDITIONABLE\s+)?(FUNCTION|PROCEDURE)\s+((?:"(?:""|[^"])+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"(?:""|[^"])+"|[A-Za-z_][\w$]*))?)\s*(\(.*?\))?"#,
|
||||
)
|
||||
.unwrap();
|
||||
let captures = re.captures(source)?;
|
||||
let kind = parse_object_source_kind(captures.get(1)?.as_str())?;
|
||||
let name_parts = split_qualified_routine_name(captures.get(2)?.as_str());
|
||||
let name = name_parts.last()?.clone();
|
||||
let signature = captures.get(3).map(|value| value.as_str().trim().to_string()).unwrap_or_default();
|
||||
Some(RoutineDeclaration { kind, name, signature })
|
||||
}
|
||||
|
||||
fn replace_sql_routine_declaration_name(source: &str, schema: Option<&str>, new_name: &str) -> Option<String> {
|
||||
let re = Regex::new(
|
||||
r#"(?is)^(\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:(?:NON)?EDITIONABLE\s+)?(?:FUNCTION|PROCEDURE)\s+)((?:"(?:""|[^"])+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"(?:""|[^"])+"|[A-Za-z_][\w$]*))?)"#,
|
||||
)
|
||||
.unwrap();
|
||||
let captures = re.captures(source)?;
|
||||
let full = captures.get(0)?;
|
||||
let prefix = captures.get(1)?.as_str();
|
||||
let existing_name = captures.get(2)?.as_str();
|
||||
let existing_parts = split_qualified_routine_name(existing_name);
|
||||
let schema_name =
|
||||
schema.or_else(|| existing_parts.first().filter(|_| existing_parts.len() > 1).map(String::as_str));
|
||||
let replacement = if let Some(schema_name) = schema_name {
|
||||
format!("{}.{}", quote_postgres_identifier(schema_name), quote_postgres_identifier(new_name))
|
||||
} else {
|
||||
quote_postgres_identifier(new_name)
|
||||
};
|
||||
Some(format!("{}{}{}{}", &source[..full.start()], prefix, replacement, &source[full.end()..]))
|
||||
}
|
||||
|
||||
fn mysql_routine_declaration(source: &str) -> Option<RoutineDeclaration> {
|
||||
let re = Regex::new(
|
||||
r"(?is)^\s*CREATE\s+(?:DEFINER\s*=\s*(?:`(?:``|[^`])+`|'(?:''|[^'])+'|[^\s]+)\s*@\s*(?:`(?:``|[^`])+`|'(?:''|[^'])+'|[^\s]+)\s+)?(FUNCTION|PROCEDURE)\s+((?:`(?:``|[^`])+`|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:`(?:``|[^`])+`|[A-Za-z_][\w$]*))?)",
|
||||
)
|
||||
.unwrap();
|
||||
let captures = re.captures(source)?;
|
||||
let kind = parse_object_source_kind(captures.get(1)?.as_str())?;
|
||||
let name_parts = split_mysql_qualified_routine_name(captures.get(2)?.as_str());
|
||||
let name = name_parts.last()?.clone();
|
||||
Some(RoutineDeclaration { kind, name, signature: String::new() })
|
||||
}
|
||||
|
||||
fn replace_mysql_routine_declaration_name(source: &str, new_name: &str) -> Option<String> {
|
||||
let re = Regex::new(
|
||||
r"(?is)^(\s*CREATE\s+(?:DEFINER\s*=\s*(?:`(?:``|[^`])+`|'(?:''|[^'])+'|[^\s]+)\s*@\s*(?:`(?:``|[^`])+`|'(?:''|[^'])+'|[^\s]+)\s+)?(?:FUNCTION|PROCEDURE)\s+)((?:`(?:``|[^`])+`|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:`(?:``|[^`])+`|[A-Za-z_][\w$]*))?)",
|
||||
)
|
||||
.unwrap();
|
||||
let captures = re.captures(source)?;
|
||||
let full = captures.get(0)?;
|
||||
let prefix = captures.get(1)?.as_str();
|
||||
Some(format!("{}{}{}{}", &source[..full.start()], prefix, quote_mysql_identifier(new_name), &source[full.end()..]))
|
||||
}
|
||||
|
||||
fn routine_name_changed(source_name: &str, saved_name: &str) -> bool {
|
||||
!source_name.eq_ignore_ascii_case(saved_name)
|
||||
}
|
||||
|
||||
fn replace_sqlserver_create_with_alter(source: &str) -> String {
|
||||
Regex::new(r"(?i)^CREATE\s+(?:OR\s+ALTER\s+)?").unwrap().replace(source, "ALTER ").to_string()
|
||||
}
|
||||
|
||||
fn parse_object_source_kind(value: &str) -> Option<ObjectSourceKind> {
|
||||
if value.eq_ignore_ascii_case("VIEW") {
|
||||
Some(ObjectSourceKind::View)
|
||||
} else if value.eq_ignore_ascii_case("PROCEDURE") {
|
||||
Some(ObjectSourceKind::Procedure)
|
||||
} else if value.eq_ignore_ascii_case("FUNCTION") {
|
||||
Some(ObjectSourceKind::Function)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn input(database_type: DatabaseType, object_type: ObjectSourceKind, source: &str) -> EditableObjectSourceSqlInput {
|
||||
EditableObjectSourceSqlInput {
|
||||
database_type,
|
||||
object_type,
|
||||
schema: Some("public".to_string()),
|
||||
name: "refresh_cache".to_string(),
|
||||
source: source.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_edited_source_saves_as_alter() {
|
||||
let sql = build_executable_object_source_sql(EditableObjectSourceSqlInput {
|
||||
database_type: DatabaseType::SqlServer,
|
||||
object_type: ObjectSourceKind::Procedure,
|
||||
schema: Some("dbo".to_string()),
|
||||
name: "usp_demo".to_string(),
|
||||
source: "CREATE PROCEDURE dbo.usp_demo AS SELECT 1;".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(sql, "ALTER PROCEDURE dbo.usp_demo AS SELECT 1;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_view_body_opens_as_create_or_replace_view() {
|
||||
let sql = build_executable_object_source_sql(EditableObjectSourceSqlInput {
|
||||
database_type: DatabaseType::Postgres,
|
||||
object_type: ObjectSourceKind::View,
|
||||
schema: Some("public".to_string()),
|
||||
name: "active users".to_string(),
|
||||
source: " SELECT id, name FROM users WHERE active ".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
sql,
|
||||
"CREATE OR REPLACE VIEW \"public\".\"active users\" AS\nSELECT id, name FROM users WHERE active;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_ddl_wraps_postgres_body_as_create_or_replace_view() {
|
||||
let sql = build_view_ddl_sql(BuildViewDdlInput {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: Some("public".to_string()),
|
||||
name: "active users".to_string(),
|
||||
source: " SELECT id, name FROM users WHERE active ".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
sql,
|
||||
"CREATE OR REPLACE VIEW \"public\".\"active users\" AS\nSELECT id, name FROM users WHERE active;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_ddl_keeps_existing_create_view_statement() {
|
||||
let sql = build_view_ddl_sql(BuildViewDdlInput {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: Some("reporting".to_string()),
|
||||
name: "active_users".to_string(),
|
||||
source: "CREATE ALGORITHM=UNDEFINED VIEW `active_users` AS SELECT `id` FROM `users`".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(sql, "CREATE ALGORITHM=UNDEFINED VIEW `active_users` AS SELECT `id` FROM `users`;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_ddl_uses_create_view_for_non_postgres_like_databases() {
|
||||
let sql = build_view_ddl_sql(BuildViewDdlInput {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: Some("reporting".to_string()),
|
||||
name: "active_users".to_string(),
|
||||
source: "SELECT id FROM users".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(sql, "CREATE VIEW `reporting`.`active_users` AS\nSELECT id FROM users;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_procedure_rename_adds_drop_cleanup() {
|
||||
let statements = build_executable_object_source_statements(input(
|
||||
DatabaseType::Postgres,
|
||||
ObjectSourceKind::Procedure,
|
||||
"CREATE OR REPLACE PROCEDURE \"public\".\"refresh_cache_v2\"(mode text)\nLANGUAGE SQL\nAS $$ SELECT 1 $$;",
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
statements,
|
||||
vec![
|
||||
"CREATE OR REPLACE PROCEDURE \"public\".\"refresh_cache_v2\"(mode text)\nLANGUAGE SQL\nAS $$ SELECT 1 $$;",
|
||||
"DROP PROCEDURE IF EXISTS \"public\".\"refresh_cache\"(mode text);",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_routine_rename_adds_drop_cleanup() {
|
||||
let statements = build_executable_object_source_statements(EditableObjectSourceSqlInput {
|
||||
database_type: DatabaseType::Mysql,
|
||||
object_type: ObjectSourceKind::Procedure,
|
||||
schema: Some("app".to_string()),
|
||||
name: "refresh_cache".to_string(),
|
||||
source:
|
||||
"CREATE DEFINER=`root`@`%` PROCEDURE `refresh_cache_v2`(IN mode_name varchar(20)) BEGIN SELECT 1; END"
|
||||
.to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
statements,
|
||||
vec![
|
||||
"CREATE DEFINER=`root`@`%` PROCEDURE `refresh_cache_v2`(IN mode_name varchar(20)) BEGIN SELECT 1; END;",
|
||||
"DROP PROCEDURE IF EXISTS `app`.`refresh_cache`;",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_family_routine_rename_rewrites_source_and_drops_original() {
|
||||
let statements = build_routine_rename_object_source_statements(RoutineRenameObjectSourceInput {
|
||||
database_type: DatabaseType::Dameng,
|
||||
object_type: ObjectSourceKind::Procedure,
|
||||
schema: Some("SYSDBA".to_string()),
|
||||
name: "SP_TAB_BAKSET_REMOVE_BATCH".to_string(),
|
||||
new_name: "SP_TAB_BAKSET_REMOVE_BATCH_2".to_string(),
|
||||
source:
|
||||
"CREATE OR REPLACE PROCEDURE \"SYSDBA\".\"SP_TAB_BAKSET_REMOVE_BATCH\" AS\nBEGIN\n SELECT 1;\nEND;"
|
||||
.to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
statements,
|
||||
vec![
|
||||
"CREATE OR REPLACE PROCEDURE \"SYSDBA\".\"SP_TAB_BAKSET_REMOVE_BATCH_2\" AS\nBEGIN\n SELECT 1;\nEND;",
|
||||
"DROP PROCEDURE \"SYSDBA\".\"SP_TAB_BAKSET_REMOVE_BATCH\";",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExplainSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub sql: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExplainSqlBuildResult {
|
||||
pub ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sql: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DroppedFilePreviewSqlOptions {
|
||||
pub path: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
pub fn build_explain_sql(options: ExplainSqlOptions) -> ExplainSqlBuildResult {
|
||||
if !supports_explain_plan(options.database_type) {
|
||||
return explain_err("unsupported");
|
||||
}
|
||||
|
||||
let source = strip_trailing_semicolons(options.sql.trim());
|
||||
if source.is_empty() {
|
||||
return explain_err("empty");
|
||||
}
|
||||
if !is_safe_explain_source(&source) {
|
||||
return explain_err("unsafe");
|
||||
}
|
||||
|
||||
let sql = if options.database_type == Some(DatabaseType::Postgres) {
|
||||
format!("EXPLAIN (FORMAT JSON) {source}")
|
||||
} else {
|
||||
format!("EXPLAIN FORMAT=JSON {source}")
|
||||
};
|
||||
ExplainSqlBuildResult { ok: true, sql: Some(sql), reason: None }
|
||||
}
|
||||
|
||||
pub fn build_dropped_file_preview_sql(options: DroppedFilePreviewSqlOptions) -> Option<String> {
|
||||
let lower = options.path.to_lowercase();
|
||||
let escaped = options.path.replace('\'', "''");
|
||||
let limit = options.limit.unwrap_or(1000).max(1);
|
||||
if lower.ends_with(".parquet") {
|
||||
return Some(format!("SELECT * FROM read_parquet('{escaped}') LIMIT {limit}"));
|
||||
}
|
||||
if lower.ends_with(".csv") {
|
||||
return Some(format!("SELECT * FROM read_csv('{escaped}') LIMIT {limit}"));
|
||||
}
|
||||
if lower.ends_with(".tsv") {
|
||||
return Some(format!("SELECT * FROM read_csv('{escaped}', delim='\\t') LIMIT {limit}"));
|
||||
}
|
||||
if lower.ends_with(".json") {
|
||||
return Some(format!("SELECT * FROM read_json('{escaped}') LIMIT {limit}"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn supports_explain_plan(database_type: Option<DatabaseType>) -> bool {
|
||||
matches!(database_type, Some(DatabaseType::Mysql | DatabaseType::Postgres))
|
||||
}
|
||||
|
||||
fn explain_err(reason: &str) -> ExplainSqlBuildResult {
|
||||
ExplainSqlBuildResult { ok: false, sql: None, reason: Some(reason.to_string()) }
|
||||
}
|
||||
|
||||
fn strip_trailing_semicolons(sql: &str) -> String {
|
||||
sql.trim_end().trim_end_matches(';').trim_end().to_string()
|
||||
}
|
||||
|
||||
fn is_safe_explain_source(sql: &str) -> bool {
|
||||
let source = strip_sql_comments(sql).trim_start().to_lowercase();
|
||||
["select", "with", "table", "values"].iter().any(|keyword| {
|
||||
source == *keyword || source.starts_with(&format!("{keyword} ")) || source.starts_with(&format!("{keyword}\n"))
|
||||
})
|
||||
}
|
||||
|
||||
fn strip_sql_comments(sql: &str) -> String {
|
||||
let mut output = String::with_capacity(sql.len());
|
||||
let mut chars = sql.chars().peekable();
|
||||
let mut in_line_comment = false;
|
||||
let mut in_block_comment = false;
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if in_line_comment {
|
||||
if ch == '\n' {
|
||||
in_line_comment = false;
|
||||
output.push(' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_block_comment {
|
||||
if ch == '*' && chars.peek() == Some(&'/') {
|
||||
chars.next();
|
||||
in_block_comment = false;
|
||||
output.push(' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '-' && chars.peek() == Some(&'-') {
|
||||
chars.next();
|
||||
in_line_comment = true;
|
||||
continue;
|
||||
}
|
||||
if ch == '#' {
|
||||
in_line_comment = true;
|
||||
continue;
|
||||
}
|
||||
if ch == '/' && chars.peek() == Some(&'*') {
|
||||
chars.next();
|
||||
in_block_comment = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
output.push(ch);
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_postgres_json_explain_sql() {
|
||||
let result = build_explain_sql(ExplainSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
sql: " select * from users where id = 1; ".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
ExplainSqlBuildResult {
|
||||
ok: true,
|
||||
sql: Some("EXPLAIN (FORMAT JSON) select * from users where id = 1".to_string()),
|
||||
reason: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_mysql_json_explain_and_rejects_unsafe_sql() {
|
||||
assert_eq!(
|
||||
build_explain_sql(ExplainSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
sql: "SELECT * FROM users;".to_string(),
|
||||
}),
|
||||
ExplainSqlBuildResult {
|
||||
ok: true,
|
||||
sql: Some("EXPLAIN FORMAT=JSON SELECT * FROM users".to_string()),
|
||||
reason: None,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
build_explain_sql(ExplainSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
sql: "delete from users".to_string(),
|
||||
}),
|
||||
ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_dropped_file_preview_sql() {
|
||||
assert_eq!(
|
||||
build_dropped_file_preview_sql(DroppedFilePreviewSqlOptions {
|
||||
path: "/tmp/O'Hara.csv".to_string(),
|
||||
limit: Some(25),
|
||||
}),
|
||||
Some("SELECT * FROM read_csv('/tmp/O''Hara.csv') LIMIT 25".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
build_dropped_file_preview_sql(DroppedFilePreviewSqlOptions {
|
||||
path: "/tmp/data.tsv".to_string(),
|
||||
limit: None,
|
||||
}),
|
||||
Some("SELECT * FROM read_csv('/tmp/data.tsv', delim='\\t') LIMIT 1000".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
build_dropped_file_preview_sql(DroppedFilePreviewSqlOptions {
|
||||
path: "/tmp/data.txt".to_string(),
|
||||
limit: None,
|
||||
}),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::sql::find_statement_at_cursor;
|
||||
use crate::sql_dialect::{quote_table_identifier, uses_fetch_first};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QuerySqlBuildResult {
|
||||
pub ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sql: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QueryPagination {
|
||||
pub limit: usize,
|
||||
pub offset: usize,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QueryPaginationExecutionPlanOptions {
|
||||
pub sql: String,
|
||||
pub query_base_sql: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub pagination: QueryPagination,
|
||||
pub use_agent_cursor: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QueryPaginationExecutionPlan {
|
||||
pub sql_to_execute: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub page_sql: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub page_limit: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub page_offset: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub count_sql: Option<String>,
|
||||
pub use_agent_result_session: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PaginatedQuerySqlOptions {
|
||||
pub original_sql: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub limit: usize,
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CountQuerySqlOptions {
|
||||
pub original_sql: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum QuerySortDirection {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
impl QuerySortDirection {
|
||||
fn as_sql(self) -> &'static str {
|
||||
match self {
|
||||
Self::Asc => "ASC",
|
||||
Self::Desc => "DESC",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SortedQuerySqlOptions {
|
||||
pub original_sql: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default)]
|
||||
pub result_columns: Vec<String>,
|
||||
pub column_index: usize,
|
||||
pub column: String,
|
||||
pub direction: QuerySortDirection,
|
||||
}
|
||||
|
||||
pub fn build_query_pagination_execution_plan(
|
||||
options: QueryPaginationExecutionPlanOptions,
|
||||
) -> QueryPaginationExecutionPlan {
|
||||
let mut plan = QueryPaginationExecutionPlan {
|
||||
sql_to_execute: options.sql.clone(),
|
||||
page_sql: None,
|
||||
page_limit: None,
|
||||
page_offset: None,
|
||||
count_sql: None,
|
||||
use_agent_result_session: false,
|
||||
};
|
||||
|
||||
let counted = build_count_query_sql(CountQuerySqlOptions {
|
||||
original_sql: options.query_base_sql.clone(),
|
||||
database_type: options.database_type,
|
||||
});
|
||||
if counted.ok {
|
||||
plan.count_sql = counted.sql;
|
||||
}
|
||||
|
||||
if options.pagination.session_id.is_some() {
|
||||
plan.page_limit = Some(options.pagination.limit);
|
||||
plan.page_offset = Some(options.pagination.offset);
|
||||
plan.use_agent_result_session = true;
|
||||
return plan;
|
||||
}
|
||||
|
||||
if options.use_agent_cursor && options.pagination.offset == 0 {
|
||||
plan.sql_to_execute = options.query_base_sql;
|
||||
plan.page_limit = Some(options.pagination.limit);
|
||||
plan.page_offset = Some(options.pagination.offset);
|
||||
plan.use_agent_result_session = true;
|
||||
return plan;
|
||||
}
|
||||
|
||||
let paginated = build_paginated_query_sql(PaginatedQuerySqlOptions {
|
||||
original_sql: options.sql,
|
||||
database_type: options.database_type,
|
||||
limit: options.pagination.limit,
|
||||
offset: options.pagination.offset,
|
||||
});
|
||||
if paginated.ok {
|
||||
plan.sql_to_execute = paginated.sql.clone().unwrap_or_default();
|
||||
plan.page_sql = paginated.sql;
|
||||
plan.page_limit = Some(options.pagination.limit);
|
||||
plan.page_offset = Some(options.pagination.offset);
|
||||
}
|
||||
plan
|
||||
}
|
||||
|
||||
pub fn build_paginated_query_sql(options: PaginatedQuerySqlOptions) -> QuerySqlBuildResult {
|
||||
let Ok(statement) = single_selectable_statement(&options.original_sql) else {
|
||||
return err(single_statement_error_reason(&options.original_sql));
|
||||
};
|
||||
if unsupported_pagination_type(options.database_type) {
|
||||
return err("unsupported");
|
||||
}
|
||||
|
||||
let safe_limit = options.limit.max(1);
|
||||
let safe_offset = options.offset;
|
||||
let alias = quote_table_identifier(options.database_type, "dbx_page");
|
||||
let wrapped_sql = if options.database_type == Some(DatabaseType::SqlServer) {
|
||||
sql_server_statement_for_derived_table(&statement)
|
||||
} else {
|
||||
statement
|
||||
};
|
||||
let base = format!("SELECT * FROM ({wrapped_sql}) {alias}");
|
||||
|
||||
if options.database_type == Some(DatabaseType::SqlServer) {
|
||||
if safe_offset > 0 {
|
||||
return err("unsupported");
|
||||
}
|
||||
return ok(format!("SELECT TOP ({safe_limit}) * FROM ({wrapped_sql}) {alias}"));
|
||||
}
|
||||
|
||||
if options.database_type.is_some_and(uses_fetch_first) {
|
||||
let offset_sql = if safe_offset > 0 { format!(" OFFSET {safe_offset} ROWS") } else { String::new() };
|
||||
return ok(format!("{base}{offset_sql} FETCH FIRST {safe_limit} ROWS ONLY"));
|
||||
}
|
||||
|
||||
let offset_sql = if safe_offset > 0 { format!(" OFFSET {safe_offset}") } else { String::new() };
|
||||
ok(format!("{base} LIMIT {safe_limit}{offset_sql};"))
|
||||
}
|
||||
|
||||
pub fn build_count_query_sql(options: CountQuerySqlOptions) -> QuerySqlBuildResult {
|
||||
let Ok(statement) = single_selectable_statement(&options.original_sql) else {
|
||||
return err(single_statement_error_reason(&options.original_sql));
|
||||
};
|
||||
if unsupported_pagination_type(options.database_type) {
|
||||
return err("unsupported");
|
||||
}
|
||||
|
||||
let alias = quote_table_identifier(options.database_type, "dbx_count");
|
||||
let wrapped_sql = if options.database_type == Some(DatabaseType::SqlServer) {
|
||||
sql_server_statement_for_derived_table(&statement)
|
||||
} else {
|
||||
statement
|
||||
};
|
||||
ok(format!("SELECT COUNT(*) AS dbx_total_rows FROM ({wrapped_sql}) {alias};"))
|
||||
}
|
||||
|
||||
pub fn build_sorted_query_sql(options: SortedQuerySqlOptions) -> QuerySqlBuildResult {
|
||||
let base_sql = options.original_sql.trim();
|
||||
if base_sql.is_empty() {
|
||||
return err("empty");
|
||||
}
|
||||
|
||||
let statement = find_statement_at_cursor(base_sql, 0).trim().trim_end_matches(';').trim().to_string();
|
||||
if statement.is_empty() {
|
||||
return err("empty");
|
||||
}
|
||||
if statement.len() != base_sql.trim_end_matches(';').trim().len() {
|
||||
return err("multi");
|
||||
}
|
||||
if statement.trim_start().to_ascii_uppercase().starts_with("WITH") {
|
||||
return err("with");
|
||||
}
|
||||
if !statement.trim_start().to_ascii_uppercase().starts_with("SELECT") {
|
||||
return err("not_select");
|
||||
}
|
||||
|
||||
let aliases = build_derived_column_aliases(&options.result_columns);
|
||||
let use_derived_column_aliases = options.database_type != Some(DatabaseType::Mysql);
|
||||
let sort_alias = if use_derived_column_aliases {
|
||||
aliases
|
||||
.get(options.column_index)
|
||||
.or_else(|| {
|
||||
options
|
||||
.result_columns
|
||||
.iter()
|
||||
.position(|column| column == &options.column)
|
||||
.and_then(|index| aliases.get(index))
|
||||
})
|
||||
.cloned()
|
||||
.unwrap_or_else(|| fallback_alias(options.column_index))
|
||||
} else {
|
||||
options.result_columns.get(options.column_index).cloned().unwrap_or_else(|| options.column.clone())
|
||||
};
|
||||
let quoted_column = quote_table_identifier(options.database_type, &sort_alias);
|
||||
let wrapped_statement = if options.database_type == Some(DatabaseType::SqlServer) {
|
||||
sql_server_statement_for_derived_table(&statement)
|
||||
} else {
|
||||
statement
|
||||
};
|
||||
|
||||
if use_derived_column_aliases {
|
||||
let alias_list = aliases
|
||||
.iter()
|
||||
.map(|alias| quote_table_identifier(options.database_type, alias))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
ok(format!(
|
||||
"SELECT * FROM ({wrapped_statement}) t({alias_list}) ORDER BY {quoted_column} {};",
|
||||
options.direction.as_sql()
|
||||
))
|
||||
} else {
|
||||
ok(format!("SELECT * FROM ({wrapped_statement}) t ORDER BY {quoted_column} {};", options.direction.as_sql()))
|
||||
}
|
||||
}
|
||||
|
||||
fn ok(sql: String) -> QuerySqlBuildResult {
|
||||
QuerySqlBuildResult { ok: true, sql: Some(sql), reason: None }
|
||||
}
|
||||
|
||||
fn err(reason: &str) -> QuerySqlBuildResult {
|
||||
QuerySqlBuildResult { ok: false, sql: None, reason: Some(reason.to_string()) }
|
||||
}
|
||||
|
||||
fn unsupported_pagination_type(database_type: Option<DatabaseType>) -> bool {
|
||||
matches!(
|
||||
database_type,
|
||||
Some(DatabaseType::Neo4j | DatabaseType::MongoDb | DatabaseType::Redis | DatabaseType::Elasticsearch)
|
||||
)
|
||||
}
|
||||
|
||||
fn single_selectable_statement(original_sql: &str) -> Result<String, ()> {
|
||||
let base_sql = original_sql.trim();
|
||||
if base_sql.is_empty() {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let statement = find_statement_at_cursor(base_sql, 0).trim().trim_end_matches(';').trim().to_string();
|
||||
if statement.is_empty() {
|
||||
return Err(());
|
||||
}
|
||||
if statement.len() != base_sql.trim_end_matches(';').trim().len() {
|
||||
return Err(());
|
||||
}
|
||||
let upper = statement.trim_start().to_ascii_uppercase();
|
||||
if !(upper.starts_with("SELECT") || upper.starts_with("WITH")) {
|
||||
return Err(());
|
||||
}
|
||||
if has_top_level_select_into(&statement) {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
Ok(statement)
|
||||
}
|
||||
|
||||
fn single_statement_error_reason(original_sql: &str) -> &'static str {
|
||||
let base_sql = original_sql.trim();
|
||||
if base_sql.is_empty() {
|
||||
return "empty";
|
||||
}
|
||||
let statement = find_statement_at_cursor(base_sql, 0).trim().trim_end_matches(';').trim().to_string();
|
||||
if statement.is_empty() {
|
||||
return "empty";
|
||||
}
|
||||
if statement.len() != base_sql.trim_end_matches(';').trim().len() {
|
||||
return "multi";
|
||||
}
|
||||
"not_select"
|
||||
}
|
||||
|
||||
fn has_top_level_select_into(sql: &str) -> bool {
|
||||
let mut saw_select = false;
|
||||
for token in top_level_sql_tokens(sql) {
|
||||
if !saw_select {
|
||||
saw_select = token.text == "SELECT";
|
||||
continue;
|
||||
}
|
||||
if token.text == "INTO" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn sql_server_statement_for_derived_table(statement: &str) -> String {
|
||||
let Some(order_by) = find_top_level_trailing_order_by(statement) else {
|
||||
return statement.to_string();
|
||||
};
|
||||
if has_top_level_select_top(statement) || has_top_level_for_xml(statement) {
|
||||
return statement.to_string();
|
||||
}
|
||||
statement[..order_by].trim_end().to_string()
|
||||
}
|
||||
|
||||
fn find_top_level_trailing_order_by(sql: &str) -> Option<usize> {
|
||||
let tokens = top_level_sql_tokens(sql);
|
||||
for index in (0..tokens.len().saturating_sub(1)).rev() {
|
||||
if tokens[index].text == "ORDER" && tokens.get(index + 1).is_some_and(|token| token.text == "BY") {
|
||||
return Some(tokens[index].start);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn has_top_level_select_top(sql: &str) -> bool {
|
||||
let tokens = top_level_sql_tokens(sql);
|
||||
let Some(select_index) = tokens.iter().position(|token| token.text == "SELECT") else {
|
||||
return false;
|
||||
};
|
||||
let from_index = tokens
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(index, token)| *index > select_index && token.text == "FROM")
|
||||
.map(|(index, _)| index)
|
||||
.unwrap_or(tokens.len());
|
||||
tokens[select_index + 1..from_index].iter().any(|token| token.text == "TOP")
|
||||
}
|
||||
|
||||
fn has_top_level_for_xml(sql: &str) -> bool {
|
||||
let tokens = top_level_sql_tokens(sql);
|
||||
tokens
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(index, token)| token.text == "FOR" && tokens.get(index + 1).is_some_and(|next| next.text == "XML"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct SqlToken {
|
||||
text: String,
|
||||
start: usize,
|
||||
}
|
||||
|
||||
fn top_level_sql_tokens(sql: &str) -> Vec<SqlToken> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut i = 0;
|
||||
let mut depth = 0usize;
|
||||
|
||||
while i < sql.len() {
|
||||
let ch = next_char(sql, i);
|
||||
let next = next_char_at(sql, i + ch.len_utf8());
|
||||
|
||||
if ch == '-' && next == Some('-') {
|
||||
i += 2;
|
||||
while i < sql.len() && next_char(sql, i) != '\n' {
|
||||
i += next_char(sql, i).len_utf8();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '/' && next == Some('*') {
|
||||
i += 2;
|
||||
while i < sql.len() {
|
||||
let current = next_char(sql, i);
|
||||
let following = next_char_at(sql, i + current.len_utf8());
|
||||
if current == '*' && following == Some('/') {
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
i += current.len_utf8();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(ch, '\'' | '"' | '`') {
|
||||
i = skip_sql_quoted(sql, i, ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '[' {
|
||||
i = skip_sql_bracket_identifier(sql, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '(' {
|
||||
depth += 1;
|
||||
i += ch.len_utf8();
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == ')' {
|
||||
depth = depth.saturating_sub(1);
|
||||
i += ch.len_utf8();
|
||||
continue;
|
||||
}
|
||||
|
||||
if depth == 0 && is_sql_token_start(ch) {
|
||||
let start = i;
|
||||
i += ch.len_utf8();
|
||||
while i < sql.len() && is_sql_token_part(next_char(sql, i)) {
|
||||
i += next_char(sql, i).len_utf8();
|
||||
}
|
||||
tokens.push(SqlToken { text: sql[start..i].to_ascii_uppercase(), start });
|
||||
continue;
|
||||
}
|
||||
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
|
||||
tokens
|
||||
}
|
||||
|
||||
fn skip_sql_quoted(sql: &str, pos: usize, quote: char) -> usize {
|
||||
let mut i = pos + quote.len_utf8();
|
||||
while i < sql.len() {
|
||||
let ch = next_char(sql, i);
|
||||
let next = next_char_at(sql, i + ch.len_utf8());
|
||||
if ch == quote {
|
||||
if next == Some(quote) {
|
||||
i += ch.len_utf8() + quote.len_utf8();
|
||||
continue;
|
||||
}
|
||||
return i + ch.len_utf8();
|
||||
}
|
||||
if quote == '\'' && ch == '\\' {
|
||||
i += ch.len_utf8();
|
||||
if i < sql.len() {
|
||||
i += next_char(sql, i).len_utf8();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
sql.len()
|
||||
}
|
||||
|
||||
fn skip_sql_bracket_identifier(sql: &str, pos: usize) -> usize {
|
||||
let mut i = pos + 1;
|
||||
while i < sql.len() {
|
||||
let ch = next_char(sql, i);
|
||||
let next = next_char_at(sql, i + ch.len_utf8());
|
||||
if ch == ']' {
|
||||
if next == Some(']') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
return i + 1;
|
||||
}
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
sql.len()
|
||||
}
|
||||
|
||||
fn is_sql_token_start(ch: char) -> bool {
|
||||
ch.is_ascii_alphabetic() || ch == '_'
|
||||
}
|
||||
|
||||
fn is_sql_token_part(ch: char) -> bool {
|
||||
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$' | '#')
|
||||
}
|
||||
|
||||
fn next_char(sql: &str, index: usize) -> char {
|
||||
sql[index..].chars().next().unwrap_or('\0')
|
||||
}
|
||||
|
||||
fn next_char_at(sql: &str, index: usize) -> Option<char> {
|
||||
if index >= sql.len() {
|
||||
None
|
||||
} else {
|
||||
sql[index..].chars().next()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_derived_column_aliases(result_columns: &[String]) -> Vec<String> {
|
||||
let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
result_columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, column)| {
|
||||
let base = normalize_alias_base(column, index);
|
||||
let count = seen.entry(base.clone()).and_modify(|value| *value += 1).or_insert(1);
|
||||
if *count == 1 {
|
||||
base
|
||||
} else {
|
||||
format!("{base}_{count}")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_alias_base(column: &str, index: usize) -> String {
|
||||
let compact = column.split_whitespace().collect::<Vec<_>>().join("_");
|
||||
let safe = compact
|
||||
.chars()
|
||||
.map(|ch| if ch.is_alphanumeric() || matches!(ch, '_' | '$') { ch } else { '_' })
|
||||
.collect::<String>()
|
||||
.trim_matches('_')
|
||||
.to_string();
|
||||
if safe.is_empty() {
|
||||
fallback_alias(index)
|
||||
} else {
|
||||
safe
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_alias(index: usize) -> String {
|
||||
format!("column_{}", index + 1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wraps_single_select_query_with_limit_and_offset() {
|
||||
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
|
||||
original_sql: "SELECT id, name FROM users;".to_string(),
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
limit: 100,
|
||||
offset: 200,
|
||||
});
|
||||
|
||||
assert_eq!(result.ok, true);
|
||||
assert_eq!(
|
||||
result.sql.unwrap(),
|
||||
"SELECT * FROM (SELECT id, name FROM users) \"dbx_page\" LIMIT 100 OFFSET 200;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_sqlserver_top_pagination_for_first_page() {
|
||||
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
|
||||
original_sql: "SELECT id FROM users ORDER BY id DESC".to_string(),
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
assert_eq!(result.ok, true);
|
||||
assert_eq!(result.sql.unwrap(), "SELECT TOP (100) * FROM (SELECT id FROM users) [dbx_page]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_sqlserver_offset_pagination_for_later_pages() {
|
||||
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
|
||||
original_sql: "SELECT id FROM users".to_string(),
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
limit: 100,
|
||||
offset: 300,
|
||||
});
|
||||
|
||||
assert_eq!(result, err("unsupported"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_fetch_first_pagination_for_oracle() {
|
||||
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
|
||||
original_sql: "SELECT id FROM users".to_string(),
|
||||
database_type: Some(DatabaseType::Oracle),
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
assert_eq!(result.sql.unwrap(), "SELECT * FROM (SELECT id FROM users) \"dbx_page\" FETCH FIRST 100 ROWS ONLY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_mysql_style_alias_for_pagination() {
|
||||
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
|
||||
original_sql: "SELECT id FROM users WHERE active = 1".to_string(),
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
assert_eq!(result.sql.unwrap(), "SELECT * FROM (SELECT id FROM users WHERE active = 1) `dbx_page` LIMIT 50;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_multiple_statements_for_pagination() {
|
||||
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
|
||||
original_sql: "SELECT 1; SELECT 2;".to_string(),
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
assert_eq!(result, err("multi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_select_into_for_pagination() {
|
||||
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
|
||||
original_sql: "SELECT * INTO copy_users FROM users WHERE active = 1".to_string(),
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
assert_eq!(result, err("not_select"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_count_query() {
|
||||
let result = build_count_query_sql(CountQuerySqlOptions {
|
||||
original_sql: "WITH cte AS (SELECT 1 AS id) SELECT * FROM cte".to_string(),
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
result.sql.unwrap(),
|
||||
"SELECT COUNT(*) AS dbx_total_rows FROM (WITH cte AS (SELECT 1 AS id) SELECT * FROM cte) `dbx_count`;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_agent_cursor_pagination_plan() {
|
||||
let plan = build_query_pagination_execution_plan(QueryPaginationExecutionPlanOptions {
|
||||
sql: "SELECT * FROM events".to_string(),
|
||||
query_base_sql: "SELECT * FROM events".to_string(),
|
||||
database_type: Some(DatabaseType::Oracle),
|
||||
pagination: QueryPagination { limit: 500, offset: 0, session_id: None },
|
||||
use_agent_cursor: true,
|
||||
});
|
||||
|
||||
assert_eq!(plan.sql_to_execute, "SELECT * FROM events");
|
||||
assert_eq!(plan.page_limit, Some(500));
|
||||
assert_eq!(plan.page_offset, Some(0));
|
||||
assert!(plan.page_sql.is_none());
|
||||
assert!(plan.use_agent_result_session);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_sorted_query_sql() {
|
||||
let result = build_sorted_query_sql(SortedQuerySqlOptions {
|
||||
original_sql: "SELECT c.id, m.id FROM t_campaign c LEFT JOIN t_campaign_mdf m ON m.campaign_id = c.id"
|
||||
.to_string(),
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
result_columns: vec!["id".to_string(), "id".to_string()],
|
||||
column_index: 1,
|
||||
column: "id".to_string(),
|
||||
direction: QuerySortDirection::Asc,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
result.sql.unwrap(),
|
||||
"SELECT * FROM (SELECT c.id, m.id FROM t_campaign c LEFT JOIN t_campaign_mdf m ON m.campaign_id = c.id) t(\"id\", \"id_2\") ORDER BY \"id_2\" ASC;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_mysql_sorted_query_without_alias_list() {
|
||||
let result = build_sorted_query_sql(SortedQuerySqlOptions {
|
||||
original_sql: "SELECT * FROM admin LIMIT 100;".to_string(),
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
result_columns: vec![
|
||||
"id".to_string(),
|
||||
"guid".to_string(),
|
||||
"role_guid".to_string(),
|
||||
"login_name".to_string(),
|
||||
"password".to_string(),
|
||||
],
|
||||
column_index: 3,
|
||||
column: "login_name".to_string(),
|
||||
direction: QuerySortDirection::Asc,
|
||||
});
|
||||
|
||||
assert_eq!(result.sql.unwrap(), "SELECT * FROM (SELECT * FROM admin LIMIT 100) t ORDER BY `login_name` ASC;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_sqlserver_order_by_for_sorted_query() {
|
||||
let result = build_sorted_query_sql(SortedQuerySqlOptions {
|
||||
original_sql: "SELECT id, name FROM users ORDER BY id DESC".to_string(),
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
result_columns: vec!["id".to_string(), "name".to_string()],
|
||||
column_index: 1,
|
||||
column: "name".to_string(),
|
||||
direction: QuerySortDirection::Asc,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
result.sql.unwrap(),
|
||||
"SELECT * FROM (SELECT id, name FROM users) t([id], [name]) ORDER BY [name] ASC;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_with_query_sorting() {
|
||||
let result = build_sorted_query_sql(SortedQuerySqlOptions {
|
||||
original_sql: "WITH cte AS (SELECT 1) SELECT * FROM cte".to_string(),
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
result_columns: vec!["id".to_string()],
|
||||
column_index: 0,
|
||||
column: "id".to_string(),
|
||||
direction: QuerySortDirection::Asc,
|
||||
});
|
||||
|
||||
assert_eq!(result, err("with"));
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,223 @@ pub fn split_sql_statements(sql: &str) -> Vec<String> {
|
||||
statements
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SqlStatementRange {
|
||||
pub text: String,
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
pub fn find_statement_at_cursor(sql: &str, cursor_pos: usize) -> String {
|
||||
let statements = split_sql_statement_ranges(sql);
|
||||
let cursor = utf16_offset_to_byte_index(sql, cursor_pos);
|
||||
|
||||
for statement in &statements {
|
||||
if cursor >= statement.start && cursor <= statement.end {
|
||||
return statement.text.clone();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(last) = statements.last() {
|
||||
if cursor >= last.end {
|
||||
return last.text.clone();
|
||||
}
|
||||
}
|
||||
|
||||
sql.trim().to_string()
|
||||
}
|
||||
|
||||
fn split_sql_statement_ranges(sql: &str) -> Vec<SqlStatementRange> {
|
||||
let mut ranges = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut i = 0;
|
||||
let mut in_single_quote = false;
|
||||
let mut in_double_quote = false;
|
||||
let mut in_backtick = false;
|
||||
let mut in_line_comment = false;
|
||||
let mut in_block_comment = false;
|
||||
let mut dollar_quote_tag: Option<String> = None;
|
||||
let mut custom_delimiter: Option<String> = None;
|
||||
|
||||
while i < sql.len() {
|
||||
if let Some(tag) = &dollar_quote_tag {
|
||||
if sql[i..].starts_with(tag) {
|
||||
i += tag.len();
|
||||
dollar_quote_tag = None;
|
||||
continue;
|
||||
}
|
||||
i += next_char_len(sql, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
let ch = next_char(sql, i);
|
||||
let next = next_char_at(sql, i + ch.len_utf8());
|
||||
|
||||
if in_line_comment {
|
||||
i += ch.len_utf8();
|
||||
if ch == '\n' {
|
||||
in_line_comment = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_block_comment {
|
||||
if ch == '*' && next == Some('/') {
|
||||
i += 2;
|
||||
in_block_comment = false;
|
||||
} else {
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if !in_single_quote && !in_double_quote && !in_backtick {
|
||||
if ch == '-' && next == Some('-') {
|
||||
in_line_comment = true;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if ch == '/' && next == Some('*') {
|
||||
in_block_comment = true;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if let Some(tag) = dollar_quote_tag_at_str(sql, i) {
|
||||
if custom_delimiter.is_none() && !is_on_delimiter_line(sql, start, i) {
|
||||
i += tag.len();
|
||||
dollar_quote_tag = Some(tag);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ch == '\n' {
|
||||
let line_start = sql[..i].rfind('\n').map_or(0, |pos| pos + 1);
|
||||
let line = sql[line_start..i].trim();
|
||||
if let Some(new_delimiter) = parse_delimiter_command(line) {
|
||||
let before = sql[start..line_start].trim();
|
||||
if has_executable_sql(before) {
|
||||
push_statement_range(&mut ranges, sql, start, line_start);
|
||||
}
|
||||
custom_delimiter = if new_delimiter == ";" { None } else { Some(new_delimiter.to_string()) };
|
||||
start = i + ch.len_utf8();
|
||||
i = start;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match ch {
|
||||
'\'' if !in_double_quote && !in_backtick && !is_escaped_single_quote(sql, i) => {
|
||||
in_single_quote = !in_single_quote;
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
'"' if !in_single_quote && !in_backtick => {
|
||||
in_double_quote = !in_double_quote;
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
'`' if !in_single_quote && !in_double_quote => {
|
||||
in_backtick = !in_backtick;
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
';' if !in_single_quote && !in_double_quote && !in_backtick && custom_delimiter.is_none() => {
|
||||
push_statement_range(&mut ranges, sql, start, i);
|
||||
i += ch.len_utf8();
|
||||
start = i;
|
||||
}
|
||||
_ => {
|
||||
i += ch.len_utf8();
|
||||
if !in_single_quote && !in_double_quote && !in_backtick {
|
||||
if let Some(delimiter) = &custom_delimiter {
|
||||
if sql[start..i].ends_with(delimiter) {
|
||||
let end = i - delimiter.len();
|
||||
push_statement_range(&mut ranges, sql, start, end);
|
||||
start = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed = sql[start..].trim();
|
||||
let last_line = trimmed.rsplit('\n').next().unwrap_or(trimmed).trim();
|
||||
if parse_delimiter_command(last_line).is_some() {
|
||||
if let Some(line_start) = sql[start..].rfind('\n').map(|pos| start + pos + 1) {
|
||||
push_statement_range(&mut ranges, sql, start, line_start);
|
||||
}
|
||||
} else {
|
||||
push_statement_range(&mut ranges, sql, start, sql.len());
|
||||
}
|
||||
|
||||
ranges
|
||||
}
|
||||
|
||||
fn push_statement_range(ranges: &mut Vec<SqlStatementRange>, sql: &str, start: usize, end: usize) {
|
||||
let text = sql[start..end].trim();
|
||||
if has_executable_sql(text) {
|
||||
ranges.push(SqlStatementRange { text: text.to_string(), start, end });
|
||||
}
|
||||
}
|
||||
|
||||
fn utf16_offset_to_byte_index(sql: &str, offset: usize) -> usize {
|
||||
let mut utf16_seen = 0;
|
||||
for (byte_index, ch) in sql.char_indices() {
|
||||
if utf16_seen >= offset {
|
||||
return byte_index;
|
||||
}
|
||||
utf16_seen += ch.len_utf16();
|
||||
if utf16_seen > offset {
|
||||
return byte_index + ch.len_utf8();
|
||||
}
|
||||
}
|
||||
sql.len()
|
||||
}
|
||||
|
||||
fn next_char(sql: &str, index: usize) -> char {
|
||||
sql[index..].chars().next().unwrap_or('\0')
|
||||
}
|
||||
|
||||
fn next_char_at(sql: &str, index: usize) -> Option<char> {
|
||||
if index >= sql.len() {
|
||||
None
|
||||
} else {
|
||||
sql[index..].chars().next()
|
||||
}
|
||||
}
|
||||
|
||||
fn next_char_len(sql: &str, index: usize) -> usize {
|
||||
next_char(sql, index).len_utf8()
|
||||
}
|
||||
|
||||
fn is_escaped_single_quote(sql: &str, index: usize) -> bool {
|
||||
index > 0 && sql.as_bytes().get(index - 1) == Some(&b'\\')
|
||||
}
|
||||
|
||||
fn is_on_delimiter_line(sql: &str, range_start: usize, index: usize) -> bool {
|
||||
let line_start = sql[range_start..index].rfind('\n').map_or(range_start, |pos| range_start + pos + 1);
|
||||
sql[line_start..index]
|
||||
.trim_start()
|
||||
.as_bytes()
|
||||
.get(..9)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"delimiter"))
|
||||
}
|
||||
|
||||
fn dollar_quote_tag_at_str(sql: &str, index: usize) -> Option<String> {
|
||||
let rest = &sql[index..];
|
||||
if !rest.starts_with('$') {
|
||||
return None;
|
||||
}
|
||||
let end = rest[1..].find('$')? + 1;
|
||||
let tag = &rest[..=end];
|
||||
if tag.len() == 2 {
|
||||
return Some(tag.to_string());
|
||||
}
|
||||
let name = &tag[1..tag.len() - 1];
|
||||
if !name.chars().all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
|
||||
return None;
|
||||
}
|
||||
Some(tag.to_string())
|
||||
}
|
||||
|
||||
pub fn split_sql_batches(sql: &str) -> Vec<String> {
|
||||
let mut batches = Vec::new();
|
||||
let mut current_start = 0;
|
||||
@@ -972,4 +1189,47 @@ SELECT 2 $$
|
||||
DELIMITER ;";
|
||||
assert_eq!(super::split_sql_statements(sql), vec!["SELECT 1", "SELECT 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_statement_at_cursor() {
|
||||
let sql = "SELECT 1; SELECT 2";
|
||||
|
||||
assert_eq!(super::find_statement_at_cursor(sql, 3), "SELECT 1");
|
||||
assert_eq!(super::find_statement_at_cursor(sql, 12), "SELECT 2");
|
||||
assert_eq!(super::find_statement_at_cursor(sql, 18), "SELECT 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_statement_at_cursor_after_unicode_comment() {
|
||||
let sql = "-- 判断字段是否存在\nSELECT 1; SELECT 2";
|
||||
let cursor_byte = sql.find("SELECT 2").unwrap();
|
||||
let cursor = sql[..cursor_byte].encode_utf16().count();
|
||||
|
||||
assert_eq!(super::find_statement_at_cursor(sql, cursor), "SELECT 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_statement_with_dollar_quote() {
|
||||
let sql = "SELECT $$a;b$$; SELECT 2";
|
||||
|
||||
assert_eq!(super::find_statement_at_cursor(sql, 3), "SELECT $$a;b$$");
|
||||
assert_eq!(super::find_statement_at_cursor(sql, 17), "SELECT 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_statement_with_custom_delimiter() {
|
||||
let sql = "\
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE foo()
|
||||
BEGIN
|
||||
SELECT 1;
|
||||
END //
|
||||
DELIMITER ;
|
||||
SELECT 2;";
|
||||
let cursor = sql.find("SELECT 1").unwrap();
|
||||
let next_cursor = sql.rfind("SELECT 2").unwrap();
|
||||
|
||||
assert_eq!(super::find_statement_at_cursor(sql, cursor), "CREATE PROCEDURE foo()\nBEGIN\n SELECT 1;\nEND");
|
||||
assert_eq!(super::find_statement_at_cursor(sql, next_cursor), "SELECT 2");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
use crate::models::connection::DatabaseType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const DBX_ROWID_COLUMN: &str = "__DBX_ROWID";
|
||||
pub const DBX_NEO4J_ELEMENT_ID_COLUMN: &str = "__DBX_ELEMENT_ID";
|
||||
pub const DBX_TDENGINE_TBNAME_COLUMN: &str = "tbname";
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TableSelectSqlOptions<'a> {
|
||||
@@ -10,10 +15,113 @@ pub struct TableSelectSqlOptions<'a> {
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableDataSelectSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub table_name: String,
|
||||
#[serde(default)]
|
||||
pub primary_keys: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub columns: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub fallback_order_columns: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub order_by: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub offset: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub where_input: Option<String>,
|
||||
#[serde(default)]
|
||||
pub include_row_id: bool,
|
||||
}
|
||||
|
||||
pub fn build_count_table_sql(database_type: Option<DatabaseType>, schema: Option<&str>, table_name: &str) -> String {
|
||||
format!("SELECT COUNT(*) AS row_count FROM {}", qualified_table_name(database_type, schema, table_name))
|
||||
}
|
||||
|
||||
pub fn build_table_data_select_sql(options: TableDataSelectSqlOptions) -> String {
|
||||
let database_type = options.database_type;
|
||||
let limit = options.limit.unwrap_or(100);
|
||||
if database_type == Some(DatabaseType::Neo4j) {
|
||||
return build_neo4j_table_select_sql(&options, limit);
|
||||
}
|
||||
|
||||
let table = qualified_table_name(database_type, options.schema.as_deref(), &options.table_name);
|
||||
let predicate = normalize_where_input(options.where_input.as_deref());
|
||||
let where_clause = if predicate.is_empty() { String::new() } else { format!(" WHERE ({predicate})") };
|
||||
let row_id_alias =
|
||||
if options.include_row_id && database_type == Some(DatabaseType::Oracle) { Some("t") } else { None };
|
||||
let default_order_alias = if database_type == Some(DatabaseType::Jdbc) { Some("dbx_t") } else { row_id_alias };
|
||||
let default_order_by = if !options.primary_keys.is_empty() {
|
||||
Some(
|
||||
options
|
||||
.primary_keys
|
||||
.iter()
|
||||
.map(|pk| format!("{} ASC", quote_order_identifier(database_type, pk, default_order_alias)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)
|
||||
} else if !options.fallback_order_columns.is_empty() {
|
||||
Some(
|
||||
options
|
||||
.fallback_order_columns
|
||||
.iter()
|
||||
.map(|column| format!("{} ASC", quote_table_identifier(database_type, column)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let order_by = options.order_by.as_deref().filter(|order| !order.trim().is_empty()).or(default_order_by.as_deref());
|
||||
let order = order_by.map(|order_by| format!(" ORDER BY {order_by}")).unwrap_or_default();
|
||||
|
||||
let select_columns = if options.include_row_id && database_type == Some(DatabaseType::Oracle) {
|
||||
format!("ROWIDTOCHAR(t.ROWID) AS \"{DBX_ROWID_COLUMN}\", t.*")
|
||||
} else {
|
||||
build_select_columns(database_type, &options.columns)
|
||||
};
|
||||
let table_alias = if options.include_row_id && database_type.is_some_and(uses_fetch_first) {
|
||||
format!("{table} t")
|
||||
} else if database_type == Some(DatabaseType::Jdbc) && default_order_by.is_some() {
|
||||
format!("{table} dbx_t")
|
||||
} else {
|
||||
table
|
||||
};
|
||||
|
||||
if database_type.is_some_and(uses_fetch_first) {
|
||||
let offset = options
|
||||
.offset
|
||||
.filter(|offset| *offset > 0)
|
||||
.map(|offset| format!(" OFFSET {offset} ROWS"))
|
||||
.unwrap_or_default();
|
||||
return format!(
|
||||
"SELECT {select_columns} FROM {table_alias}{where_clause}{order}{offset} FETCH FIRST {limit} ROWS ONLY"
|
||||
);
|
||||
}
|
||||
|
||||
if database_type == Some(DatabaseType::SqlServer) {
|
||||
return build_sqlserver_table_select_sql(
|
||||
&table_alias,
|
||||
&where_clause,
|
||||
order_by.unwrap_or("(SELECT NULL)"),
|
||||
&options.columns,
|
||||
limit,
|
||||
options.offset.unwrap_or(0),
|
||||
);
|
||||
}
|
||||
|
||||
let offset =
|
||||
options.offset.filter(|offset| *offset > 0).map(|offset| format!(" OFFSET {offset}")).unwrap_or_default();
|
||||
format!("SELECT {select_columns} FROM {table_alias}{where_clause}{order} LIMIT {limit}{offset};")
|
||||
}
|
||||
|
||||
pub fn build_table_select_sql(options: TableSelectSqlOptions<'_>) -> String {
|
||||
let database_type = options.database_type;
|
||||
let table = qualified_table_name(database_type, options.schema, options.table_name);
|
||||
@@ -76,6 +184,139 @@ pub fn quote_table_identifier(database_type: Option<DatabaseType>, name: &str) -
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_where_input(where_input: Option<&str>) -> String {
|
||||
let trimmed = where_input.unwrap_or("").trim().trim_end_matches(';').trim();
|
||||
if trimmed.len() >= 5 && trimmed[..5].eq_ignore_ascii_case("where") {
|
||||
trimmed[5..].trim().to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_oracle_row_id(database_type: Option<DatabaseType>, name: &str) -> bool {
|
||||
database_type == Some(DatabaseType::Oracle) && name.eq_ignore_ascii_case(DBX_ROWID_COLUMN)
|
||||
}
|
||||
|
||||
fn is_tdengine_tbname(database_type: Option<DatabaseType>, name: &str) -> bool {
|
||||
database_type == Some(DatabaseType::Tdengine) && name.eq_ignore_ascii_case(DBX_TDENGINE_TBNAME_COLUMN)
|
||||
}
|
||||
|
||||
fn quote_order_identifier(database_type: Option<DatabaseType>, name: &str, table_alias: Option<&str>) -> String {
|
||||
if is_oracle_row_id(database_type, name) {
|
||||
return table_alias.map(|alias| format!("{alias}.ROWID")).unwrap_or_else(|| "ROWID".to_string());
|
||||
}
|
||||
if is_tdengine_tbname(database_type, name) {
|
||||
return DBX_TDENGINE_TBNAME_COLUMN.to_string();
|
||||
}
|
||||
let quoted = quote_table_identifier(database_type, name);
|
||||
table_alias.map(|alias| format!("{alias}.{quoted}")).unwrap_or(quoted)
|
||||
}
|
||||
|
||||
fn build_select_columns(database_type: Option<DatabaseType>, columns: &[String]) -> String {
|
||||
if columns.is_empty() {
|
||||
return "*".to_string();
|
||||
}
|
||||
if database_type == Some(DatabaseType::Tdengine) {
|
||||
let mut tdengine_columns = Vec::new();
|
||||
if !columns.iter().any(|column| column.eq_ignore_ascii_case(DBX_TDENGINE_TBNAME_COLUMN)) {
|
||||
tdengine_columns.push(DBX_TDENGINE_TBNAME_COLUMN.to_string());
|
||||
}
|
||||
tdengine_columns.extend(columns.iter().cloned());
|
||||
return tdengine_columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
if is_tdengine_tbname(database_type, column) {
|
||||
DBX_TDENGINE_TBNAME_COLUMN.to_string()
|
||||
} else {
|
||||
let ident = quote_table_identifier(database_type, column);
|
||||
format!("{ident} AS {ident}")
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
}
|
||||
if database_type != Some(DatabaseType::Hive) {
|
||||
return "*".to_string();
|
||||
}
|
||||
columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let ident = quote_table_identifier(database_type, column);
|
||||
format!("{ident} AS {ident}")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn build_sqlserver_table_select_sql(
|
||||
table: &str,
|
||||
where_clause: &str,
|
||||
order_by: &str,
|
||||
columns: &[String],
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> String {
|
||||
let columns_sql = if columns.is_empty() {
|
||||
"*".to_string()
|
||||
} else {
|
||||
columns
|
||||
.iter()
|
||||
.map(|column| quote_table_identifier(Some(DatabaseType::SqlServer), column))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
let order = if order_by == "(SELECT NULL)" { String::new() } else { format!(" ORDER BY {order_by}") };
|
||||
if offset == 0 {
|
||||
return format!("SELECT TOP ({limit}) {columns_sql} FROM {table}{where_clause}{order}");
|
||||
}
|
||||
|
||||
let page_alias = quote_table_identifier(Some(DatabaseType::SqlServer), "dbx_page");
|
||||
let row_number_alias = quote_table_identifier(Some(DatabaseType::SqlServer), "__dbx_row_num");
|
||||
let end = offset + limit;
|
||||
format!(
|
||||
"WITH {page_alias} AS (SELECT {columns_sql}, ROW_NUMBER() OVER (ORDER BY {order_by}) AS {row_number_alias} FROM {table}{where_clause}) SELECT {columns_sql} FROM {page_alias} WHERE {row_number_alias} > {offset} AND {row_number_alias} <= {end} ORDER BY {row_number_alias}"
|
||||
)
|
||||
}
|
||||
|
||||
fn build_neo4j_table_select_sql(options: &TableDataSelectSqlOptions, limit: usize) -> String {
|
||||
let label = quote_table_identifier(Some(DatabaseType::Neo4j), &options.table_name);
|
||||
let predicate = normalize_where_input(options.where_input.as_deref());
|
||||
let where_clause = if predicate.is_empty() { String::new() } else { format!(" WHERE {predicate}") };
|
||||
let returned_columns = if options.columns.is_empty() {
|
||||
"n".to_string()
|
||||
} else {
|
||||
options
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let ident = quote_table_identifier(Some(DatabaseType::Neo4j), column);
|
||||
format!("n.{ident} AS {ident}")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
let returns = format!(
|
||||
"elementId(n) AS {}, {returned_columns}",
|
||||
quote_table_identifier(Some(DatabaseType::Neo4j), DBX_NEO4J_ELEMENT_ID_COLUMN)
|
||||
);
|
||||
let default_order_by = if options.primary_keys.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
options
|
||||
.primary_keys
|
||||
.iter()
|
||||
.map(|pk| format!("n.{} ASC", quote_table_identifier(Some(DatabaseType::Neo4j), pk)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)
|
||||
};
|
||||
let order_by = options.order_by.as_deref().filter(|order| !order.trim().is_empty()).or(default_order_by.as_deref());
|
||||
let order = order_by.map(|order_by| format!(" ORDER BY {order_by}")).unwrap_or_default();
|
||||
let skip = options.offset.filter(|offset| *offset > 0).map(|offset| format!(" SKIP {offset}")).unwrap_or_default();
|
||||
format!("MATCH (n:{label}){where_clause} RETURN {returns}{order}{skip} LIMIT {limit};")
|
||||
}
|
||||
|
||||
pub fn is_schema_aware(database_type: DatabaseType) -> bool {
|
||||
matches!(
|
||||
database_type,
|
||||
@@ -165,4 +406,154 @@ mod tests {
|
||||
"SELECT TOP (100) [id], [name] FROM [dbo].[users] ORDER BY [id] ASC"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_table_data_where_and_schema_queries() {
|
||||
assert_eq!(
|
||||
build_table_data_select_sql(TableDataSelectSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
primary_keys: vec!["id".to_string()],
|
||||
columns: Vec::new(),
|
||||
fallback_order_columns: Vec::new(),
|
||||
order_by: None,
|
||||
limit: Some(100),
|
||||
offset: None,
|
||||
where_input: Some("where status = 'active'".to_string()),
|
||||
include_row_id: false,
|
||||
}),
|
||||
"SELECT * FROM `users` WHERE (status = 'active') ORDER BY `id` ASC LIMIT 100;"
|
||||
);
|
||||
assert_eq!(
|
||||
build_table_data_select_sql(TableDataSelectSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "orders".to_string(),
|
||||
primary_keys: Vec::new(),
|
||||
columns: Vec::new(),
|
||||
fallback_order_columns: Vec::new(),
|
||||
order_by: None,
|
||||
limit: Some(50),
|
||||
offset: Some(100),
|
||||
where_input: Some("WHERE amount > 10".to_string()),
|
||||
include_row_id: false,
|
||||
}),
|
||||
"SELECT * FROM \"public\".\"orders\" WHERE (amount > 10) LIMIT 50 OFFSET 100;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_table_data_special_column_queries() {
|
||||
assert_eq!(
|
||||
build_table_data_select_sql(TableDataSelectSqlOptions {
|
||||
database_type: Some(DatabaseType::Tdengine),
|
||||
schema: Some("test_db".to_string()),
|
||||
table_name: "meters".to_string(),
|
||||
primary_keys: vec!["ts".to_string()],
|
||||
columns: vec![
|
||||
"ts".to_string(),
|
||||
"current".to_string(),
|
||||
"voltage".to_string(),
|
||||
"location".to_string(),
|
||||
"groupid".to_string(),
|
||||
],
|
||||
fallback_order_columns: Vec::new(),
|
||||
order_by: None,
|
||||
limit: Some(100),
|
||||
offset: None,
|
||||
where_input: None,
|
||||
include_row_id: false,
|
||||
}),
|
||||
"SELECT tbname, `ts` AS `ts`, `current` AS `current`, `voltage` AS `voltage`, `location` AS `location`, `groupid` AS `groupid` FROM `test_db`.`meters` ORDER BY `ts` ASC LIMIT 100;"
|
||||
);
|
||||
assert_eq!(
|
||||
build_table_data_select_sql(TableDataSelectSqlOptions {
|
||||
database_type: Some(DatabaseType::Hive),
|
||||
schema: None,
|
||||
table_name: "departments".to_string(),
|
||||
primary_keys: Vec::new(),
|
||||
columns: vec!["id".to_string(), "name".to_string()],
|
||||
fallback_order_columns: Vec::new(),
|
||||
order_by: None,
|
||||
limit: Some(100),
|
||||
offset: None,
|
||||
where_input: None,
|
||||
include_row_id: false,
|
||||
}),
|
||||
"SELECT `id` AS `id`, `name` AS `name` FROM `departments` LIMIT 100;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_sqlserver_table_data_pages() {
|
||||
assert_eq!(
|
||||
build_table_data_select_sql(TableDataSelectSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
schema: Some("dbo".to_string()),
|
||||
table_name: "accounts".to_string(),
|
||||
primary_keys: vec!["id".to_string()],
|
||||
columns: Vec::new(),
|
||||
fallback_order_columns: Vec::new(),
|
||||
order_by: None,
|
||||
limit: Some(25),
|
||||
offset: None,
|
||||
where_input: Some("where id = 1".to_string()),
|
||||
include_row_id: false,
|
||||
}),
|
||||
"SELECT TOP (25) * FROM [dbo].[accounts] WHERE (id = 1) ORDER BY [id] ASC"
|
||||
);
|
||||
assert_eq!(
|
||||
build_table_data_select_sql(TableDataSelectSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
schema: Some("sales".to_string()),
|
||||
table_name: "orders".to_string(),
|
||||
primary_keys: vec!["order_id".to_string()],
|
||||
columns: vec!["order_id".to_string(), "customer".to_string()],
|
||||
fallback_order_columns: Vec::new(),
|
||||
order_by: None,
|
||||
limit: Some(50),
|
||||
offset: Some(100),
|
||||
where_input: None,
|
||||
include_row_id: false,
|
||||
}),
|
||||
"WITH [dbx_page] AS (SELECT [order_id], [customer], ROW_NUMBER() OVER (ORDER BY [order_id] ASC) AS [__dbx_row_num] FROM [sales].[orders]) SELECT [order_id], [customer] FROM [dbx_page] WHERE [__dbx_row_num] > 100 AND [__dbx_row_num] <= 150 ORDER BY [__dbx_row_num]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_oracle_and_neo4j_table_data_queries() {
|
||||
assert_eq!(
|
||||
build_table_data_select_sql(TableDataSelectSqlOptions {
|
||||
database_type: Some(DatabaseType::Oracle),
|
||||
schema: Some("DBXTEST".to_string()),
|
||||
table_name: "DBX_LOAD_TABLE_006".to_string(),
|
||||
primary_keys: vec![DBX_ROWID_COLUMN.to_string()],
|
||||
columns: Vec::new(),
|
||||
fallback_order_columns: Vec::new(),
|
||||
order_by: None,
|
||||
limit: Some(100),
|
||||
offset: None,
|
||||
where_input: None,
|
||||
include_row_id: true,
|
||||
}),
|
||||
"SELECT ROWIDTOCHAR(t.ROWID) AS \"__DBX_ROWID\", t.* FROM \"DBXTEST\".\"DBX_LOAD_TABLE_006\" t ORDER BY t.ROWID ASC FETCH FIRST 100 ROWS ONLY"
|
||||
);
|
||||
assert_eq!(
|
||||
build_table_data_select_sql(TableDataSelectSqlOptions {
|
||||
database_type: Some(DatabaseType::Neo4j),
|
||||
schema: None,
|
||||
table_name: "Employee".to_string(),
|
||||
primary_keys: vec!["id".to_string()],
|
||||
columns: vec!["id".to_string(), "first name".to_string(), "role".to_string()],
|
||||
fallback_order_columns: Vec::new(),
|
||||
order_by: None,
|
||||
limit: Some(100),
|
||||
offset: None,
|
||||
where_input: None,
|
||||
include_row_id: false,
|
||||
}),
|
||||
"MATCH (n:`Employee`) RETURN elementId(n) AS `__DBX_ELEMENT_ID`, n.`id` AS `id`, n.`first name` AS `first name`, n.`role` AS `role` ORDER BY n.`id` ASC LIMIT 100;"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -127,8 +127,60 @@ async fn main() {
|
||||
.route("/query/execute-script", post(routes::query::execute_script))
|
||||
.route("/query/execute-in-transaction", post(routes::query::execute_in_transaction))
|
||||
.route("/query/analyze-sql-references", post(routes::query::analyze_sql_references))
|
||||
.route("/query/find-statement-at-cursor", post(routes::query::find_statement_at_cursor))
|
||||
.route("/query/prepare-pagination-plan", post(routes::query::prepare_query_pagination_execution_plan))
|
||||
.route("/query/build-sorted-sql", post(routes::query::build_sorted_query_sql))
|
||||
.route("/query/build-explain-sql", post(routes::query::build_explain_sql))
|
||||
.route("/query/build-dropped-file-preview-sql", post(routes::query::build_dropped_file_preview_sql))
|
||||
.route("/query/build-table-select-sql", post(routes::query::build_table_select_sql))
|
||||
.route("/query/build-database-search-sql", post(routes::query::build_database_search_sql))
|
||||
.route("/query/build-search-result-where", post(routes::query::build_search_result_where))
|
||||
.route("/query/build-rename-object-sql", post(routes::query::build_rename_object_sql))
|
||||
.route("/query/build-create-database-sql", post(routes::query::build_create_database_sql))
|
||||
.route("/query/build-duckdb-attach-database-sql", post(routes::query::build_duckdb_attach_database_sql))
|
||||
.route("/query/build-drop-object-sql", post(routes::query::build_drop_object_sql))
|
||||
.route("/query/build-drop-table-sql", post(routes::query::build_drop_table_sql))
|
||||
.route("/query/build-empty-table-sql", post(routes::query::build_empty_table_sql))
|
||||
.route("/query/build-truncate-table-sql", post(routes::query::build_truncate_table_sql))
|
||||
.route("/query/build-drop-database-sql", post(routes::query::build_drop_database_sql))
|
||||
.route("/query/build-create-schema-sql", post(routes::query::build_create_schema_sql))
|
||||
.route("/query/build-drop-schema-sql", post(routes::query::build_drop_schema_sql))
|
||||
.route("/query/build-duplicate-table-structure-sql", post(routes::query::build_duplicate_table_structure_sql))
|
||||
.route(
|
||||
"/query/build-executable-object-source-statements",
|
||||
post(routes::query::build_executable_object_source_statements),
|
||||
)
|
||||
.route("/query/build-executable-object-source-sql", post(routes::query::build_executable_object_source_sql))
|
||||
.route(
|
||||
"/query/build-routine-rename-object-source-statements",
|
||||
post(routes::query::build_routine_rename_object_source_statements),
|
||||
)
|
||||
.route("/query/build-view-ddl-sql", post(routes::query::build_view_ddl_sql))
|
||||
.route("/query/build-table-structure-change-sql", post(routes::query::build_table_structure_change_sql))
|
||||
.route("/query/build-create-table-sql", post(routes::query::build_create_table_sql))
|
||||
.route("/query/analyze-editability", post(routes::query::analyze_editable_query_editability))
|
||||
.route("/query/prepare-data-grid-save", post(routes::query::prepare_data_grid_save))
|
||||
.route(
|
||||
"/query/build-data-grid-copy-update-statements",
|
||||
post(routes::query::build_data_grid_copy_update_statements),
|
||||
)
|
||||
.route(
|
||||
"/query/build-data-grid-copy-insert-statement",
|
||||
post(routes::query::build_data_grid_copy_insert_statement),
|
||||
)
|
||||
.route(
|
||||
"/query/build-data-grid-context-filter-condition",
|
||||
post(routes::query::build_data_grid_context_filter_condition),
|
||||
)
|
||||
.route(
|
||||
"/query/build-data-grid-column-value-filter-condition",
|
||||
post(routes::query::build_data_grid_column_value_filter_condition),
|
||||
)
|
||||
.route("/query/build-data-grid-count-sql", post(routes::query::build_data_grid_count_sql))
|
||||
.route("/query/build-hive-table-properties-sql", post(routes::query::build_hive_table_properties_sql))
|
||||
.route("/query/build-export-insert-statements", post(routes::query::build_export_insert_statements))
|
||||
.route("/query/build-export-sql-insert", post(routes::query::build_export_sql_insert))
|
||||
.route("/query/build-database-sql-export", post(routes::query::build_database_sql_export))
|
||||
.route("/data-compare/prepare", post(routes::data_compare::prepare_data_compare))
|
||||
.route("/data-compare/prepare-from-tables", post(routes::data_compare::prepare_data_compare_from_tables))
|
||||
.route("/query/cancel", post(routes::query::cancel_query))
|
||||
|
||||
@@ -57,12 +57,187 @@ pub struct AnalyzeEditableQueryRequest {
|
||||
pub sql: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FindStatementAtCursorRequest {
|
||||
pub sql: String,
|
||||
pub cursor_pos: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PrepareQueryPaginationExecutionPlanRequest {
|
||||
pub options: dbx_core::query_result_sql::QueryPaginationExecutionPlanOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildSortedQuerySqlRequest {
|
||||
pub options: dbx_core::query_result_sql::SortedQuerySqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildExplainSqlRequest {
|
||||
pub options: dbx_core::query_execution_sql::ExplainSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDroppedFilePreviewSqlRequest {
|
||||
pub options: dbx_core::query_execution_sql::DroppedFilePreviewSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildTableSelectSqlRequest {
|
||||
pub options: dbx_core::sql_dialect::TableDataSelectSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDatabaseSearchSqlRequest {
|
||||
pub options: dbx_core::database_search_sql::DatabaseSearchSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildSearchResultWhereRequest {
|
||||
pub options: dbx_core::database_search_sql::SearchResultWhereOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildRenameObjectSqlRequest {
|
||||
pub options: dbx_core::db_admin_sql::RenameObjectSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildCreateDatabaseSqlRequest {
|
||||
pub options: dbx_core::db_admin_sql::CreateDatabaseSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDuckDbAttachDatabaseSqlRequest {
|
||||
pub options: dbx_core::db_admin_sql::DuckDbAttachDatabaseSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDropObjectSqlRequest {
|
||||
pub options: dbx_core::db_admin_sql::DropObjectSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildTableAdminSqlRequest {
|
||||
pub options: dbx_core::db_admin_sql::TableAdminSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDatabaseNameSqlRequest {
|
||||
pub options: dbx_core::db_admin_sql::DatabaseNameSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildSchemaNameSqlRequest {
|
||||
pub options: dbx_core::db_admin_sql::SchemaNameSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDuplicateTableStructureSqlRequest {
|
||||
pub options: dbx_core::db_admin_sql::DuplicateTableStructureSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildExecutableObjectSourceRequest {
|
||||
pub input: dbx_core::object_source_sql::EditableObjectSourceSqlInput,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildRoutineRenameObjectSourceRequest {
|
||||
pub input: dbx_core::object_source_sql::RoutineRenameObjectSourceInput,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildViewDdlRequest {
|
||||
pub input: dbx_core::object_source_sql::BuildViewDdlInput,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildTableStructureSqlRequest {
|
||||
pub options: dbx_core::table_structure_sql::TableStructureSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PrepareDataGridSaveRequest {
|
||||
pub options: dbx_core::data_grid_sql::DataGridSaveStatementOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDataGridCopyUpdateStatementsRequest {
|
||||
pub options: dbx_core::data_grid_sql::DataGridCopyUpdateStatementOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDataGridCopyInsertStatementRequest {
|
||||
pub options: dbx_core::data_grid_sql::DataGridCopyInsertStatementOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDataGridContextFilterConditionRequest {
|
||||
pub options: dbx_core::data_grid_sql::DataGridContextFilterConditionOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDataGridColumnValueFilterConditionRequest {
|
||||
pub options: dbx_core::data_grid_sql::DataGridColumnValueFilterConditionOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDataGridCountSqlRequest {
|
||||
pub options: dbx_core::data_grid_sql::DataGridCountSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildHiveTablePropertiesSqlRequest {
|
||||
pub options: dbx_core::data_grid_sql::HiveTablePropertiesSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildExportInsertStatementsRequest {
|
||||
pub options: dbx_core::database_export::BuildExportInsertStatementsOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildExportSqlInsertRequest {
|
||||
pub options: dbx_core::database_export::BuildExportSqlInsertOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDatabaseSqlExportRequest {
|
||||
pub options: dbx_core::database_export::BuildDatabaseSqlExportOptions,
|
||||
}
|
||||
|
||||
pub async fn execute_query(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<ExecuteQueryRequest>,
|
||||
@@ -200,6 +375,128 @@ pub async fn analyze_sql_references(
|
||||
dbx_core::sql_analysis::analyze_sql_references(&req.sql, req.dialect.as_deref()).map(Json).map_err(AppError)
|
||||
}
|
||||
|
||||
pub async fn find_statement_at_cursor(Json(req): Json<FindStatementAtCursorRequest>) -> Json<String> {
|
||||
Json(dbx_core::sql::find_statement_at_cursor(&req.sql, req.cursor_pos))
|
||||
}
|
||||
|
||||
pub async fn prepare_query_pagination_execution_plan(
|
||||
Json(req): Json<PrepareQueryPaginationExecutionPlanRequest>,
|
||||
) -> Json<dbx_core::query_result_sql::QueryPaginationExecutionPlan> {
|
||||
Json(dbx_core::query_result_sql::build_query_pagination_execution_plan(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_sorted_query_sql(
|
||||
Json(req): Json<BuildSortedQuerySqlRequest>,
|
||||
) -> Json<dbx_core::query_result_sql::QuerySqlBuildResult> {
|
||||
Json(dbx_core::query_result_sql::build_sorted_query_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_explain_sql(
|
||||
Json(req): Json<BuildExplainSqlRequest>,
|
||||
) -> Json<dbx_core::query_execution_sql::ExplainSqlBuildResult> {
|
||||
Json(dbx_core::query_execution_sql::build_explain_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_dropped_file_preview_sql(
|
||||
Json(req): Json<BuildDroppedFilePreviewSqlRequest>,
|
||||
) -> Json<Option<String>> {
|
||||
Json(dbx_core::query_execution_sql::build_dropped_file_preview_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_table_select_sql(Json(req): Json<BuildTableSelectSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::sql_dialect::build_table_data_select_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_database_search_sql(
|
||||
Json(req): Json<BuildDatabaseSearchSqlRequest>,
|
||||
) -> Json<Option<dbx_core::database_search_sql::DatabaseSearchSql>> {
|
||||
Json(dbx_core::database_search_sql::build_database_search_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_search_result_where(Json(req): Json<BuildSearchResultWhereRequest>) -> Json<String> {
|
||||
Json(dbx_core::database_search_sql::build_search_result_where(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_rename_object_sql(Json(req): Json<BuildRenameObjectSqlRequest>) -> Result<Json<String>, AppError> {
|
||||
dbx_core::db_admin_sql::build_rename_object_sql(req.options).map(Json).map_err(AppError)
|
||||
}
|
||||
|
||||
pub async fn build_create_database_sql(Json(req): Json<BuildCreateDatabaseSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_create_database_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_duckdb_attach_database_sql(Json(req): Json<BuildDuckDbAttachDatabaseSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_duckdb_attach_database_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_drop_object_sql(Json(req): Json<BuildDropObjectSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_drop_object_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_drop_table_sql(Json(req): Json<BuildTableAdminSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_drop_table_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_empty_table_sql(Json(req): Json<BuildTableAdminSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_empty_table_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_truncate_table_sql(Json(req): Json<BuildTableAdminSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_truncate_table_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_drop_database_sql(Json(req): Json<BuildDatabaseNameSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_drop_database_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_create_schema_sql(Json(req): Json<BuildSchemaNameSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_create_schema_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_drop_schema_sql(Json(req): Json<BuildSchemaNameSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_drop_schema_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_duplicate_table_structure_sql(
|
||||
Json(req): Json<BuildDuplicateTableStructureSqlRequest>,
|
||||
) -> Json<String> {
|
||||
Json(dbx_core::db_admin_sql::build_duplicate_table_structure_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_executable_object_source_statements(
|
||||
Json(req): Json<BuildExecutableObjectSourceRequest>,
|
||||
) -> Result<Json<Vec<String>>, AppError> {
|
||||
dbx_core::object_source_sql::build_executable_object_source_statements(req.input).map(Json).map_err(AppError)
|
||||
}
|
||||
|
||||
pub async fn build_executable_object_source_sql(
|
||||
Json(req): Json<BuildExecutableObjectSourceRequest>,
|
||||
) -> Result<Json<String>, AppError> {
|
||||
dbx_core::object_source_sql::build_executable_object_source_sql(req.input).map(Json).map_err(AppError)
|
||||
}
|
||||
|
||||
pub async fn build_routine_rename_object_source_statements(
|
||||
Json(req): Json<BuildRoutineRenameObjectSourceRequest>,
|
||||
) -> Result<Json<Vec<String>>, AppError> {
|
||||
dbx_core::object_source_sql::build_routine_rename_object_source_statements(req.input).map(Json).map_err(AppError)
|
||||
}
|
||||
|
||||
pub async fn build_view_ddl_sql(Json(req): Json<BuildViewDdlRequest>) -> Json<String> {
|
||||
Json(dbx_core::object_source_sql::build_view_ddl_sql(req.input))
|
||||
}
|
||||
|
||||
pub async fn build_table_structure_change_sql(
|
||||
Json(req): Json<BuildTableStructureSqlRequest>,
|
||||
) -> Json<dbx_core::table_structure_sql::TableStructureSqlResult> {
|
||||
Json(dbx_core::table_structure_sql::build_table_structure_change_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_create_table_sql(
|
||||
Json(req): Json<BuildTableStructureSqlRequest>,
|
||||
) -> Json<dbx_core::table_structure_sql::TableStructureSqlResult> {
|
||||
Json(dbx_core::table_structure_sql::build_create_table_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn analyze_editable_query_editability(
|
||||
Json(req): Json<AnalyzeEditableQueryRequest>,
|
||||
) -> Json<dbx_core::sql_editability::QueryEditability> {
|
||||
@@ -211,3 +508,51 @@ pub async fn prepare_data_grid_save(
|
||||
) -> Json<dbx_core::data_grid_sql::DataGridSavePreparation> {
|
||||
Json(dbx_core::data_grid_sql::prepare_data_grid_save(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_data_grid_copy_update_statements(
|
||||
Json(req): Json<BuildDataGridCopyUpdateStatementsRequest>,
|
||||
) -> Json<Vec<String>> {
|
||||
Json(dbx_core::data_grid_sql::build_data_grid_copy_update_statements(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_data_grid_copy_insert_statement(
|
||||
Json(req): Json<BuildDataGridCopyInsertStatementRequest>,
|
||||
) -> Json<Option<String>> {
|
||||
Json(dbx_core::data_grid_sql::build_data_grid_copy_insert_statement(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_data_grid_context_filter_condition(
|
||||
Json(req): Json<BuildDataGridContextFilterConditionRequest>,
|
||||
) -> Json<Option<String>> {
|
||||
Json(dbx_core::data_grid_sql::build_data_grid_context_filter_condition(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_data_grid_column_value_filter_condition(
|
||||
Json(req): Json<BuildDataGridColumnValueFilterConditionRequest>,
|
||||
) -> Json<Option<String>> {
|
||||
Json(dbx_core::data_grid_sql::build_data_grid_column_value_filter_condition(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_data_grid_count_sql(Json(req): Json<BuildDataGridCountSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::data_grid_sql::build_data_grid_count_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_hive_table_properties_sql(Json(req): Json<BuildHiveTablePropertiesSqlRequest>) -> Json<String> {
|
||||
Json(dbx_core::data_grid_sql::build_hive_table_properties_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_export_insert_statements(
|
||||
Json(req): Json<BuildExportInsertStatementsRequest>,
|
||||
) -> Result<Json<Vec<String>>, AppError> {
|
||||
dbx_core::database_export::build_export_insert_statements(req.options).map(Json).map_err(AppError)
|
||||
}
|
||||
|
||||
pub async fn build_export_sql_insert(Json(req): Json<BuildExportSqlInsertRequest>) -> Result<Json<String>, AppError> {
|
||||
dbx_core::database_export::build_export_sql_insert(req.options).map(Json).map_err(AppError)
|
||||
}
|
||||
|
||||
pub async fn build_database_sql_export(
|
||||
Json(req): Json<BuildDatabaseSqlExportRequest>,
|
||||
) -> Result<Json<String>, AppError> {
|
||||
dbx_core::database_export::build_database_sql_export(req.options).map(Json).map_err(AppError)
|
||||
}
|
||||
Reference in new issue
Block a user