feat(core): split foundational capabilities into dedicated crates
This commit is contained in:
402 files changed
+4386
-3367
No files matched your search
@@ -0,0 +1,240 @@
|
||||
use crate::data_grid_sql::{DataGridCopyInsertMode, DataGridTableMeta};
|
||||
use crate::models::connection::DatabaseType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::fmt;
|
||||
|
||||
pub const DATA_GRID_EXTRACTOR_CONTRACT_VERSION: u16 = 1;
|
||||
|
||||
const DEFAULT_DSV_COLUMN_SEPARATOR: &str = ",";
|
||||
const DEFAULT_DSV_ROW_SEPARATOR: &str = "\n";
|
||||
const DEFAULT_DSV_NULL_TEXT: &str = "NULL";
|
||||
const DEFAULT_DSV_QUOTE: char = '"';
|
||||
const DEFAULT_SQL_SKIP_COMPUTED_COLUMNS: bool = true;
|
||||
const DEFAULT_SQL_SKIP_GENERATED_COLUMNS: bool = true;
|
||||
const DEFAULT_JSON_PRETTY: bool = true;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum DataGridExtractorId {
|
||||
Raw,
|
||||
Tsv,
|
||||
TsvWithHeaders,
|
||||
Csv,
|
||||
CsvWithHeaders,
|
||||
PipeSeparated,
|
||||
Dsv,
|
||||
Json,
|
||||
JsonLines,
|
||||
OneRow,
|
||||
SqlInList,
|
||||
SqlInserts,
|
||||
SqlUpdates,
|
||||
SqlSelect,
|
||||
WhereClause,
|
||||
Markdown,
|
||||
Html,
|
||||
Xml,
|
||||
Pretty,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum DataGridSelectionKind {
|
||||
#[default]
|
||||
Cells,
|
||||
Rows,
|
||||
Columns,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum DataGridQuotePolicy {
|
||||
Always,
|
||||
#[default]
|
||||
Minimal,
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase", default, deny_unknown_fields)]
|
||||
pub struct DataGridDsvOptions {
|
||||
pub column_separator: String,
|
||||
pub row_separator: String,
|
||||
pub null_text: String,
|
||||
pub quote: char,
|
||||
pub quote_policy: DataGridQuotePolicy,
|
||||
pub include_column_header: bool,
|
||||
pub include_row_header: bool,
|
||||
}
|
||||
|
||||
impl Default for DataGridDsvOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
column_separator: DEFAULT_DSV_COLUMN_SEPARATOR.to_owned(),
|
||||
row_separator: DEFAULT_DSV_ROW_SEPARATOR.to_owned(),
|
||||
null_text: DEFAULT_DSV_NULL_TEXT.to_owned(),
|
||||
quote: DEFAULT_DSV_QUOTE,
|
||||
quote_policy: DataGridQuotePolicy::Minimal,
|
||||
include_column_header: false,
|
||||
include_row_header: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase", default, deny_unknown_fields)]
|
||||
pub struct DataGridSqlExtractorOptions {
|
||||
pub skip_computed_columns: bool,
|
||||
pub skip_generated_columns: bool,
|
||||
pub insert_mode: DataGridCopyInsertMode,
|
||||
pub exclude_primary_keys_from_insert: bool,
|
||||
}
|
||||
|
||||
impl Default for DataGridSqlExtractorOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
skip_computed_columns: DEFAULT_SQL_SKIP_COMPUTED_COLUMNS,
|
||||
skip_generated_columns: DEFAULT_SQL_SKIP_GENERATED_COLUMNS,
|
||||
insert_mode: DataGridCopyInsertMode::Merged,
|
||||
exclude_primary_keys_from_insert: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase", default, deny_unknown_fields)]
|
||||
pub struct DataGridJsonExtractorOptions {
|
||||
pub pretty: bool,
|
||||
pub camel_case_field_names: bool,
|
||||
}
|
||||
|
||||
impl Default for DataGridJsonExtractorOptions {
|
||||
fn default() -> Self {
|
||||
Self { pretty: DEFAULT_JSON_PRETTY, camel_case_field_names: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase", default, deny_unknown_fields)]
|
||||
pub struct DataGridExtractorOptions {
|
||||
pub dsv: DataGridDsvOptions,
|
||||
pub sql: DataGridSqlExtractorOptions,
|
||||
pub json: DataGridJsonExtractorOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct DataGridExtractColumn {
|
||||
pub display_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_name: Option<String>,
|
||||
pub source_index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct DataGridExtractRequest {
|
||||
pub version: u16,
|
||||
pub extractor: DataGridExtractorId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub identifier_quote: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub table_meta: Option<DataGridTableMeta>,
|
||||
#[serde(default)]
|
||||
pub columns: Vec<DataGridExtractColumn>,
|
||||
#[serde(default)]
|
||||
pub selected_column_indexes: Vec<usize>,
|
||||
#[serde(default)]
|
||||
pub rows: Vec<Vec<Value>>,
|
||||
#[serde(default)]
|
||||
pub selection_kind: DataGridSelectionKind,
|
||||
#[serde(default)]
|
||||
pub options: DataGridExtractorOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum DataGridExtractWarningCode {
|
||||
OmittedColumns,
|
||||
DuplicateJsonColumnNames,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataGridExtractWarning {
|
||||
pub code: DataGridExtractWarningCode,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataGridExtractResult {
|
||||
pub text: String,
|
||||
pub mime_type: String,
|
||||
pub file_extension: String,
|
||||
pub row_count: usize,
|
||||
pub column_count: usize,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub omitted_columns: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub warnings: Vec<DataGridExtractWarning>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum DataGridExtractErrorCode {
|
||||
UnsupportedVersion,
|
||||
EmptySelection,
|
||||
InvalidRawSelection,
|
||||
InvalidSelectSelection,
|
||||
InvalidColumnIndex,
|
||||
InvalidColumnMapping,
|
||||
MissingTableMetadata,
|
||||
MissingPrimaryKey,
|
||||
NullPrimaryKey,
|
||||
NoWritableColumns,
|
||||
UnsupportedDatabase,
|
||||
InvalidDsvConfiguration,
|
||||
InputTooLarge,
|
||||
OutputTooLarge,
|
||||
EncodingFailed,
|
||||
ExecutionFailed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataGridExtractError {
|
||||
pub code: DataGridExtractErrorCode,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl DataGridExtractError {
|
||||
pub fn new(code: DataGridExtractErrorCode, message: impl Into<String>) -> Self {
|
||||
Self { code, message: message.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DataGridExtractError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DataGridExtractError {}
|
||||
@@ -0,0 +1,232 @@
|
||||
use super::{
|
||||
value_text, write_bytes, DataGridDsvOptions, DataGridExtractError, DataGridExtractErrorCode, DataGridQuotePolicy,
|
||||
ExtractContext,
|
||||
};
|
||||
use std::borrow::Cow;
|
||||
use std::io::Write;
|
||||
|
||||
const MAX_SEPARATOR_CHARACTERS: usize = 8;
|
||||
const MAX_NULL_TEXT_CHARACTERS: usize = 64;
|
||||
|
||||
/// A single DSV data cell. NULL is carried as structured state (not pre-flattened
|
||||
/// into the `null_text` string) so the sentinel can be encoded distinctly from a
|
||||
/// string value that happens to equal it.
|
||||
enum DsvCell<'a> {
|
||||
Null,
|
||||
Text(Cow<'a, str>),
|
||||
}
|
||||
|
||||
pub(super) fn write_dsv(
|
||||
context: &ExtractContext<'_>,
|
||||
output: &mut dyn Write,
|
||||
options: &DataGridDsvOptions,
|
||||
) -> Result<(), DataGridExtractError> {
|
||||
validate_dsv_options(options, true)?;
|
||||
let mut first_row = true;
|
||||
if options.include_column_header {
|
||||
write_dsv_row(
|
||||
output,
|
||||
context.selected_columns.iter().map(|column| Cow::Borrowed(column.display_name.as_str())),
|
||||
options,
|
||||
options.include_row_header.then(|| "#".to_string()),
|
||||
)?;
|
||||
first_row = false;
|
||||
}
|
||||
for (row_index, row) in context.request.rows.iter().enumerate() {
|
||||
if !first_row {
|
||||
write_bytes(output, options.row_separator.as_bytes())?;
|
||||
}
|
||||
write_dsv_data_row(
|
||||
output,
|
||||
context.selected_source_indexes.iter().map(|source_index| {
|
||||
let value = &row[*source_index];
|
||||
if value.is_null() {
|
||||
DsvCell::Null
|
||||
} else {
|
||||
DsvCell::Text(value_text(value))
|
||||
}
|
||||
}),
|
||||
options,
|
||||
options.include_row_header.then(|| (row_index + 1).to_string()),
|
||||
)?;
|
||||
first_row = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes a row of header strings (column names / row-number header). Headers
|
||||
/// are never NULL, so they use the plain string field writer.
|
||||
fn write_dsv_row<'a>(
|
||||
output: &mut dyn Write,
|
||||
values: impl Iterator<Item = Cow<'a, str>>,
|
||||
options: &DataGridDsvOptions,
|
||||
row_header: Option<String>,
|
||||
) -> Result<(), DataGridExtractError> {
|
||||
let mut first = true;
|
||||
if let Some(header) = row_header {
|
||||
write_dsv_field(output, &header, options)?;
|
||||
first = false;
|
||||
}
|
||||
for value in values {
|
||||
if !first {
|
||||
write_bytes(output, options.column_separator.as_bytes())?;
|
||||
}
|
||||
write_dsv_field(output, &value, options)?;
|
||||
first = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes a row of data cells, preserving NULL as a distinct sentinel.
|
||||
fn write_dsv_data_row<'a>(
|
||||
output: &mut dyn Write,
|
||||
cells: impl Iterator<Item = DsvCell<'a>>,
|
||||
options: &DataGridDsvOptions,
|
||||
row_header: Option<String>,
|
||||
) -> Result<(), DataGridExtractError> {
|
||||
let mut first = true;
|
||||
if let Some(header) = row_header {
|
||||
write_dsv_field(output, &header, options)?;
|
||||
first = false;
|
||||
}
|
||||
for cell in cells {
|
||||
if !first {
|
||||
write_bytes(output, options.column_separator.as_bytes())?;
|
||||
}
|
||||
write_dsv_data_field(output, &cell, options)?;
|
||||
first = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dsv_needs_quote(value: &str, options: &DataGridDsvOptions) -> bool {
|
||||
value.contains(&options.column_separator)
|
||||
|| value.contains(&options.row_separator)
|
||||
|| value.contains(options.quote)
|
||||
|| value.contains('\r')
|
||||
|| value.contains('\n')
|
||||
}
|
||||
|
||||
fn write_quoted_dsv_field(
|
||||
output: &mut dyn Write,
|
||||
value: &str,
|
||||
options: &DataGridDsvOptions,
|
||||
) -> Result<(), DataGridExtractError> {
|
||||
let quote = options.quote;
|
||||
let mut quote_buffer = [0; 4];
|
||||
let quote_text = quote.encode_utf8(&mut quote_buffer);
|
||||
write_bytes(output, quote_text.as_bytes())?;
|
||||
let mut segments = value.split(quote);
|
||||
if let Some(first) = segments.next() {
|
||||
write_bytes(output, first.as_bytes())?;
|
||||
}
|
||||
for segment in segments {
|
||||
write_bytes(output, quote_text.as_bytes())?;
|
||||
write_bytes(output, quote_text.as_bytes())?;
|
||||
write_bytes(output, segment.as_bytes())?;
|
||||
}
|
||||
write_bytes(output, quote_text.as_bytes())
|
||||
}
|
||||
|
||||
/// Encodes a header (non-data) string field. Quotes per policy; a header equal
|
||||
/// to the NULL sentinel is NOT special-cased because headers are not data.
|
||||
fn write_dsv_field(
|
||||
output: &mut dyn Write,
|
||||
value: &str,
|
||||
options: &DataGridDsvOptions,
|
||||
) -> Result<(), DataGridExtractError> {
|
||||
let should_quote = match options.quote_policy {
|
||||
DataGridQuotePolicy::Always => true,
|
||||
DataGridQuotePolicy::Never => false,
|
||||
DataGridQuotePolicy::Minimal => dsv_needs_quote(value, options),
|
||||
};
|
||||
if !should_quote {
|
||||
return write_bytes(output, value.as_bytes());
|
||||
}
|
||||
write_quoted_dsv_field(output, value, options)
|
||||
}
|
||||
|
||||
/// Encodes a data cell. NULL is always written as the bare sentinel (never
|
||||
/// quoted/escaped); a string equal to the sentinel is force-quoted in Minimal
|
||||
/// mode so it stays distinguishable from a real NULL. Under Always the string is
|
||||
/// quoted and the bare NULL still differs; under Never the two are inherently
|
||||
/// indistinguishable (documented limitation).
|
||||
fn write_dsv_data_field(
|
||||
output: &mut dyn Write,
|
||||
cell: &DsvCell<'_>,
|
||||
options: &DataGridDsvOptions,
|
||||
) -> Result<(), DataGridExtractError> {
|
||||
match cell {
|
||||
DsvCell::Null => write_bytes(output, options.null_text.as_bytes()),
|
||||
DsvCell::Text(value) => {
|
||||
let should_quote = match options.quote_policy {
|
||||
DataGridQuotePolicy::Always => true,
|
||||
DataGridQuotePolicy::Never => false,
|
||||
DataGridQuotePolicy::Minimal => {
|
||||
dsv_needs_quote(value, options) || value.as_ref() == options.null_text.as_str()
|
||||
}
|
||||
};
|
||||
if !should_quote {
|
||||
return write_bytes(output, value.as_bytes());
|
||||
}
|
||||
write_quoted_dsv_field(output, value, options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn write_one_row(context: &ExtractContext<'_>, output: &mut dyn Write) -> Result<(), DataGridExtractError> {
|
||||
let options = DataGridDsvOptions { column_separator: ",".to_string(), ..context.request.options.dsv.clone() };
|
||||
validate_dsv_options(&options, false)?;
|
||||
let values = context
|
||||
.request
|
||||
.rows
|
||||
.iter()
|
||||
.flat_map(|row| context.selected_source_indexes.iter().map(move |source_index| &row[*source_index]));
|
||||
let mut first = true;
|
||||
for value in values {
|
||||
if !first {
|
||||
write_bytes(output, options.column_separator.as_bytes())?;
|
||||
}
|
||||
let cell = if value.is_null() { DsvCell::Null } else { DsvCell::Text(value_text(value)) };
|
||||
write_dsv_data_field(output, &cell, &options)?;
|
||||
first = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_dsv_options(options: &DataGridDsvOptions, uses_row_separator: bool) -> Result<(), DataGridExtractError> {
|
||||
if options.column_separator.is_empty() || (uses_row_separator && options.row_separator.is_empty()) {
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::InvalidDsvConfiguration,
|
||||
"DSV column and row separators must not be empty.",
|
||||
));
|
||||
}
|
||||
if options.column_separator.chars().count() > MAX_SEPARATOR_CHARACTERS
|
||||
|| options.row_separator.chars().count() > MAX_SEPARATOR_CHARACTERS
|
||||
|| options.null_text.chars().count() > MAX_NULL_TEXT_CHARACTERS
|
||||
{
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::InvalidDsvConfiguration,
|
||||
"DSV separators may contain at most 8 characters and NULL text at most 64 characters.",
|
||||
));
|
||||
}
|
||||
if uses_row_separator
|
||||
&& (options.column_separator.contains(&options.row_separator)
|
||||
|| options.row_separator.contains(&options.column_separator))
|
||||
{
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::InvalidDsvConfiguration,
|
||||
"DSV column and row separators must not overlap.",
|
||||
));
|
||||
}
|
||||
if options.quote.is_control()
|
||||
|| options.column_separator.contains(options.quote)
|
||||
|| (uses_row_separator && options.row_separator.contains(options.quote))
|
||||
{
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::InvalidDsvConfiguration,
|
||||
"DSV quote must be a non-control character that is not part of a separator.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use super::{value_text, write_bytes, DataGridExtractError, ExtractContext};
|
||||
use std::io::Write;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
pub(super) fn write_markdown(context: &ExtractContext<'_>, output: &mut dyn Write) -> Result<(), DataGridExtractError> {
|
||||
let headers = context.selected_columns.iter().map(|column| markdown_text(&column.display_name));
|
||||
write_bytes(output, format!("| {} |\n", headers.collect::<Vec<_>>().join(" | ")).as_bytes())?;
|
||||
write_bytes(
|
||||
output,
|
||||
format!("| {} |", context.selected_columns.iter().map(|_| "---").collect::<Vec<_>>().join(" | ")).as_bytes(),
|
||||
)?;
|
||||
for row in &context.request.rows {
|
||||
write_bytes(output, b"\n")?;
|
||||
let values =
|
||||
context.selected_source_indexes.iter().map(|source_index| markdown_text(&value_text(&row[*source_index])));
|
||||
write_bytes(output, format!("| {} |", values.collect::<Vec<_>>().join(" | ")).as_bytes())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn write_html(context: &ExtractContext<'_>, output: &mut dyn Write) -> Result<(), DataGridExtractError> {
|
||||
write_bytes(output, b"<table>\n <thead><tr>")?;
|
||||
for column in &context.selected_columns {
|
||||
write_bytes(output, format!("<th>{}</th>", html_text(&column.display_name)).as_bytes())?;
|
||||
}
|
||||
write_bytes(output, b"</tr></thead>\n <tbody>")?;
|
||||
for row in &context.request.rows {
|
||||
write_bytes(output, b"\n <tr>")?;
|
||||
for source_index in &context.selected_source_indexes {
|
||||
let value = &row[*source_index];
|
||||
write_bytes(output, format!("<td>{}</td>", html_text(&value_text(value))).as_bytes())?;
|
||||
}
|
||||
write_bytes(output, b"</tr>")?;
|
||||
}
|
||||
write_bytes(output, b"\n </tbody>\n</table>")
|
||||
}
|
||||
|
||||
pub(super) fn write_xml(context: &ExtractContext<'_>, output: &mut dyn Write) -> Result<(), DataGridExtractError> {
|
||||
write_bytes(output, b"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rows>")?;
|
||||
for row in &context.request.rows {
|
||||
write_bytes(output, b"\n <row>")?;
|
||||
for (column, source_index) in context.selected_columns.iter().zip(&context.selected_source_indexes) {
|
||||
let value = &row[*source_index];
|
||||
let name = html_text(&column.display_name);
|
||||
if value.is_null() {
|
||||
write_bytes(output, format!("\n <column name=\"{name}\" null=\"true\" />").as_bytes())?;
|
||||
} else {
|
||||
write_bytes(
|
||||
output,
|
||||
format!("\n <column name=\"{name}\">{}</column>", html_text(&value_text(value))).as_bytes(),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
write_bytes(output, b"\n </row>")?;
|
||||
}
|
||||
write_bytes(output, b"\n</rows>")
|
||||
}
|
||||
|
||||
pub(super) fn write_pretty(context: &ExtractContext<'_>, output: &mut dyn Write) -> Result<(), DataGridExtractError> {
|
||||
let widths = context
|
||||
.selected_columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, column)| {
|
||||
context
|
||||
.request
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| value_text(&row[context.selected_source_indexes[index]]).replace(['\r', '\n'], " ").width())
|
||||
.fold(column.display_name.width(), usize::max)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
write_pretty_border(output, &widths)?;
|
||||
write_pretty_row(output, context.selected_columns.iter().map(|column| column.display_name.as_str()), &widths)?;
|
||||
write_pretty_border(output, &widths)?;
|
||||
for row in &context.request.rows {
|
||||
let values = context
|
||||
.selected_source_indexes
|
||||
.iter()
|
||||
.map(|source_index| value_text(&row[*source_index]).replace(['\r', '\n'], " "));
|
||||
write_pretty_owned_row(output, values, &widths)?;
|
||||
}
|
||||
write_pretty_border(output, &widths)
|
||||
}
|
||||
|
||||
fn write_pretty_owned_row(
|
||||
output: &mut dyn Write,
|
||||
values: impl Iterator<Item = String>,
|
||||
widths: &[usize],
|
||||
) -> Result<(), DataGridExtractError> {
|
||||
write_bytes(output, b"|")?;
|
||||
for (index, value) in values.enumerate() {
|
||||
let padding = widths.get(index).copied().unwrap_or_default().saturating_sub(value.width());
|
||||
write_bytes(output, b" ")?;
|
||||
write_bytes(output, value.as_bytes())?;
|
||||
write_repeated_byte(output, b' ', padding)?;
|
||||
write_bytes(output, b" |")?;
|
||||
}
|
||||
write_bytes(output, b"\n")
|
||||
}
|
||||
|
||||
fn write_pretty_border(output: &mut dyn Write, widths: &[usize]) -> Result<(), DataGridExtractError> {
|
||||
write_bytes(output, b"+")?;
|
||||
for width in widths {
|
||||
write_repeated_byte(output, b'-', width.saturating_add(2))?;
|
||||
write_bytes(output, b"+")?;
|
||||
}
|
||||
write_bytes(output, b"\n")
|
||||
}
|
||||
|
||||
fn write_pretty_row<'a>(
|
||||
output: &mut dyn Write,
|
||||
values: impl Iterator<Item = &'a str>,
|
||||
widths: &[usize],
|
||||
) -> Result<(), DataGridExtractError> {
|
||||
write_bytes(output, b"|")?;
|
||||
for (index, value) in values.enumerate() {
|
||||
let padding = widths.get(index).copied().unwrap_or_default().saturating_sub(value.width());
|
||||
write_bytes(output, b" ")?;
|
||||
write_bytes(output, value.as_bytes())?;
|
||||
write_repeated_byte(output, b' ', padding)?;
|
||||
write_bytes(output, b" |")?;
|
||||
}
|
||||
write_bytes(output, b"\n")
|
||||
}
|
||||
|
||||
fn write_repeated_byte(output: &mut dyn Write, byte: u8, mut count: usize) -> Result<(), DataGridExtractError> {
|
||||
let mut buffer = [0u8; 1024];
|
||||
buffer.fill(byte);
|
||||
while count > 0 {
|
||||
let chunk_size = count.min(buffer.len());
|
||||
write_bytes(output, &buffer[..chunk_size])?;
|
||||
count -= chunk_size;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn markdown_text(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('|', "\\|").replace(['\r', '\n'], "<br>")
|
||||
}
|
||||
|
||||
fn html_text(value: &str) -> String {
|
||||
value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\'', "'")
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
use super::{
|
||||
write_bytes, DataGridExtractColumn, DataGridExtractError, DataGridExtractErrorCode, DataGridExtractWarning,
|
||||
DataGridExtractWarningCode, ExtractContext,
|
||||
};
|
||||
use serde::ser::{SerializeMap, SerializeSeq};
|
||||
use serde::{Serialize, Serializer};
|
||||
use std::io::Write;
|
||||
|
||||
pub(super) fn write_json(
|
||||
context: &ExtractContext<'_>,
|
||||
output: &mut dyn Write,
|
||||
) -> Result<Vec<DataGridExtractWarning>, DataGridExtractError> {
|
||||
let (names, warnings) =
|
||||
unique_json_names(&context.selected_columns, context.request.options.json.camel_case_field_names);
|
||||
let rows =
|
||||
JsonRows { names: &names, source_indexes: &context.selected_source_indexes, rows: &context.request.rows };
|
||||
let result = if context.request.options.json.pretty {
|
||||
serde_json::to_writer_pretty(output, &rows)
|
||||
} else {
|
||||
serde_json::to_writer(output, &rows)
|
||||
};
|
||||
result.map_err(|error| {
|
||||
DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::EncodingFailed,
|
||||
format!("Failed to encode JSON extractor output: {error}"),
|
||||
)
|
||||
})?;
|
||||
Ok(warnings)
|
||||
}
|
||||
|
||||
pub(super) fn write_json_lines(
|
||||
context: &ExtractContext<'_>,
|
||||
output: &mut dyn Write,
|
||||
) -> Result<Vec<DataGridExtractWarning>, DataGridExtractError> {
|
||||
let (names, warnings) =
|
||||
unique_json_names(&context.selected_columns, context.request.options.json.camel_case_field_names);
|
||||
for (index, row) in context.request.rows.iter().enumerate() {
|
||||
if index > 0 {
|
||||
write_bytes(output, b"\n")?;
|
||||
}
|
||||
serde_json::to_writer(
|
||||
&mut *output,
|
||||
&JsonRow { names: &names, source_indexes: &context.selected_source_indexes, values: row },
|
||||
)
|
||||
.map_err(|error| {
|
||||
DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::EncodingFailed,
|
||||
format!("Failed to encode JSON Lines extractor output: {error}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(warnings)
|
||||
}
|
||||
|
||||
struct JsonRows<'a, 'value> {
|
||||
names: &'a [String],
|
||||
source_indexes: &'a [usize],
|
||||
rows: &'value [Vec<serde_json::Value>],
|
||||
}
|
||||
|
||||
impl Serialize for JsonRows<'_, '_> {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut sequence = serializer.serialize_seq(Some(self.rows.len()))?;
|
||||
for row in self.rows {
|
||||
sequence.serialize_element(&JsonRow {
|
||||
names: self.names,
|
||||
source_indexes: self.source_indexes,
|
||||
values: row,
|
||||
})?;
|
||||
}
|
||||
sequence.end()
|
||||
}
|
||||
}
|
||||
|
||||
struct JsonRow<'a, 'value> {
|
||||
names: &'a [String],
|
||||
source_indexes: &'a [usize],
|
||||
values: &'value [serde_json::Value],
|
||||
}
|
||||
|
||||
impl Serialize for JsonRow<'_, '_> {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut map = serializer.serialize_map(Some(self.names.len()))?;
|
||||
for (name, source_index) in self.names.iter().zip(self.source_indexes) {
|
||||
map.serialize_entry(name, &self.values[*source_index])?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_json_names(
|
||||
columns: &[&DataGridExtractColumn],
|
||||
camel_case_field_names: bool,
|
||||
) -> (Vec<String>, Vec<DataGridExtractWarning>) {
|
||||
let base_names = columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
if camel_case_field_names {
|
||||
lower_camel_json_name(&column.display_name)
|
||||
} else {
|
||||
column.display_name.clone()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let reserved = base_names.iter().map(String::as_str).collect::<std::collections::HashSet<_>>();
|
||||
let mut emitted = std::collections::HashSet::<String>::new();
|
||||
let mut suffixes = std::collections::HashMap::<String, usize>::new();
|
||||
let mut duplicate = false;
|
||||
let names = base_names
|
||||
.iter()
|
||||
.map(|base| {
|
||||
let base = base.clone();
|
||||
if emitted.insert(base.clone()) {
|
||||
return base;
|
||||
}
|
||||
duplicate = true;
|
||||
let suffix = suffixes.entry(base.clone()).or_insert(2);
|
||||
loop {
|
||||
let candidate = format!("{base}_{suffix}");
|
||||
*suffix += 1;
|
||||
if !reserved.contains(candidate.as_str()) && emitted.insert(candidate.clone()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let warnings = if duplicate {
|
||||
vec![DataGridExtractWarning {
|
||||
code: DataGridExtractWarningCode::DuplicateJsonColumnNames,
|
||||
message: "Duplicate JSON column names were suffixed to prevent data loss.".to_string(),
|
||||
}]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
(names, warnings)
|
||||
}
|
||||
|
||||
fn lower_camel_json_name(name: &str) -> String {
|
||||
if !name.contains('_') {
|
||||
return if name.chars().any(char::is_lowercase) { name.to_owned() } else { name.to_lowercase() };
|
||||
}
|
||||
|
||||
let mut words = name.split('_').filter(|word| !word.is_empty());
|
||||
let Some(first) = words.next() else {
|
||||
return name.to_owned();
|
||||
};
|
||||
let mut converted = first.to_lowercase();
|
||||
for word in words {
|
||||
let mut characters = word.chars();
|
||||
if let Some(first_character) = characters.next() {
|
||||
converted.extend(first_character.to_uppercase());
|
||||
converted.extend(characters.flat_map(char::to_lowercase));
|
||||
}
|
||||
}
|
||||
converted
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
use std::io::{self, Write};
|
||||
|
||||
mod contract;
|
||||
mod delimited;
|
||||
mod document;
|
||||
mod json;
|
||||
mod raw;
|
||||
mod sql;
|
||||
|
||||
pub use contract::*;
|
||||
use delimited::{write_dsv, write_one_row};
|
||||
use document::{write_html, write_markdown, write_pretty, write_xml};
|
||||
use json::{write_json, write_json_lines};
|
||||
use raw::write_raw;
|
||||
use sql::{write_sql_in_list, write_sql_inserts, write_sql_select, write_sql_updates, write_where_clause};
|
||||
|
||||
const DATA_GRID_EXTRACTOR_MAX_OUTPUT_BYTES: usize = 32 * 1024 * 1024;
|
||||
const DATA_GRID_EXTRACTOR_MAX_INPUT_BYTES: usize = 64 * 1024 * 1024;
|
||||
const DATA_GRID_EXTRACTOR_MAX_ROWS: usize = 100_000;
|
||||
const DATA_GRID_EXTRACTOR_MAX_COLUMNS: usize = 10_000;
|
||||
const DATA_GRID_EXTRACTOR_MAX_CELLS: usize = 2_000_000;
|
||||
|
||||
struct ExtractContext<'a> {
|
||||
request: &'a DataGridExtractRequest,
|
||||
selected_columns: Vec<&'a DataGridExtractColumn>,
|
||||
selected_source_indexes: Vec<usize>,
|
||||
selected_column_info: Vec<Option<&'a crate::data_grid_sql::DataGridColumnInfo>>,
|
||||
}
|
||||
|
||||
pub fn extract_data_grid_selection(
|
||||
request: DataGridExtractRequest,
|
||||
) -> Result<DataGridExtractResult, DataGridExtractError> {
|
||||
if request.version != DATA_GRID_EXTRACTOR_CONTRACT_VERSION {
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::UnsupportedVersion,
|
||||
format!("Unsupported data grid extractor contract version: {}", request.version),
|
||||
));
|
||||
}
|
||||
let context = build_context(&request)?;
|
||||
let mut output = BoundedOutput::new(DATA_GRID_EXTRACTOR_MAX_OUTPUT_BYTES);
|
||||
let metadata = write_extraction(&context, &mut output);
|
||||
if output.exceeded_limit() {
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::OutputTooLarge,
|
||||
"Extracted clipboard output exceeds 32 MiB; export the data to a file instead.",
|
||||
));
|
||||
}
|
||||
let metadata = metadata?;
|
||||
let text = String::from_utf8(output.into_bytes()).map_err(|error| {
|
||||
DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::EncodingFailed,
|
||||
format!("Extractor produced invalid UTF-8: {error}"),
|
||||
)
|
||||
})?;
|
||||
Ok(DataGridExtractResult {
|
||||
text,
|
||||
mime_type: metadata.mime_type.to_owned(),
|
||||
file_extension: metadata.file_extension.to_owned(),
|
||||
row_count: request.rows.len(),
|
||||
column_count: context.selected_columns.len(),
|
||||
omitted_columns: metadata.omitted_columns,
|
||||
warnings: metadata.warnings,
|
||||
})
|
||||
}
|
||||
|
||||
struct BoundedOutput {
|
||||
bytes: Vec<u8>,
|
||||
max_bytes: usize,
|
||||
exceeded_limit: bool,
|
||||
}
|
||||
|
||||
impl BoundedOutput {
|
||||
fn new(max_bytes: usize) -> Self {
|
||||
Self { bytes: Vec::new(), max_bytes, exceeded_limit: false }
|
||||
}
|
||||
|
||||
fn exceeded_limit(&self) -> bool {
|
||||
self.exceeded_limit
|
||||
}
|
||||
|
||||
fn into_bytes(self) -> Vec<u8> {
|
||||
self.bytes
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for BoundedOutput {
|
||||
fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
|
||||
if buffer.len() > self.max_bytes.saturating_sub(self.bytes.len()) {
|
||||
self.exceeded_limit = true;
|
||||
return Err(io::Error::other("data grid extractor output limit exceeded"));
|
||||
}
|
||||
self.bytes.extend_from_slice(buffer);
|
||||
Ok(buffer.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_context(request: &DataGridExtractRequest) -> Result<ExtractContext<'_>, DataGridExtractError> {
|
||||
validate_request_budget(request)?;
|
||||
if request.rows.is_empty() || request.selected_column_indexes.is_empty() {
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::EmptySelection,
|
||||
"Select at least one data grid cell before extracting data.",
|
||||
));
|
||||
}
|
||||
let mut selected_columns = Vec::with_capacity(request.selected_column_indexes.len());
|
||||
let mut unique_selected_indexes = std::collections::HashSet::with_capacity(request.selected_column_indexes.len());
|
||||
for index in &request.selected_column_indexes {
|
||||
if !unique_selected_indexes.insert(*index) {
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::InvalidColumnIndex,
|
||||
format!("Selected column index {index} is duplicated."),
|
||||
));
|
||||
}
|
||||
let column = request.columns.get(*index).ok_or_else(|| {
|
||||
DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::InvalidColumnIndex,
|
||||
format!("Selected column index {index} is outside the extractor column list."),
|
||||
)
|
||||
})?;
|
||||
selected_columns.push(column);
|
||||
}
|
||||
let selected_source_indexes = selected_columns.iter().map(|column| column.source_index).collect::<Vec<_>>();
|
||||
for row in &request.rows {
|
||||
for column in &selected_columns {
|
||||
row.get(column.source_index).ok_or_else(|| {
|
||||
DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::InvalidColumnMapping,
|
||||
format!("Column '{}' maps to missing row index {}.", column.display_name, column.source_index),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
let column_info_by_name = request
|
||||
.table_meta
|
||||
.as_ref()
|
||||
.and_then(|table_meta| table_meta.columns.as_ref())
|
||||
.map(|columns| {
|
||||
columns
|
||||
.iter()
|
||||
.map(|info| (normalized_name(info.name.as_str()).to_ascii_uppercase(), info))
|
||||
.collect::<std::collections::HashMap<_, _>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let selected_column_info = selected_columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let source_name = column.source_name.as_deref().unwrap_or(&column.display_name);
|
||||
column_info_by_name.get(&normalized_name(source_name).to_ascii_uppercase()).copied()
|
||||
})
|
||||
.collect();
|
||||
Ok(ExtractContext { request, selected_columns, selected_source_indexes, selected_column_info })
|
||||
}
|
||||
|
||||
fn validate_request_budget(request: &DataGridExtractRequest) -> Result<(), DataGridExtractError> {
|
||||
if request.rows.len() > DATA_GRID_EXTRACTOR_MAX_ROWS
|
||||
|| request.columns.len() > DATA_GRID_EXTRACTOR_MAX_COLUMNS
|
||||
|| request.selected_column_indexes.len() > DATA_GRID_EXTRACTOR_MAX_COLUMNS
|
||||
{
|
||||
return Err(input_too_large_error());
|
||||
}
|
||||
|
||||
let mut cell_count = 0usize;
|
||||
let mut estimated_bytes = request
|
||||
.columns
|
||||
.iter()
|
||||
.fold(0usize, |size, column| {
|
||||
size.saturating_add(column.display_name.len())
|
||||
.saturating_add(column.source_name.as_deref().map_or(0, str::len))
|
||||
})
|
||||
.saturating_add(request.options.dsv.column_separator.len())
|
||||
.saturating_add(request.options.dsv.row_separator.len())
|
||||
.saturating_add(request.options.dsv.null_text.len())
|
||||
.saturating_add(estimated_table_meta_bytes(request.table_meta.as_ref()));
|
||||
if estimated_bytes > DATA_GRID_EXTRACTOR_MAX_INPUT_BYTES {
|
||||
return Err(input_too_large_error());
|
||||
}
|
||||
for row in &request.rows {
|
||||
cell_count = cell_count.saturating_add(row.len());
|
||||
if cell_count > DATA_GRID_EXTRACTOR_MAX_CELLS {
|
||||
return Err(input_too_large_error());
|
||||
}
|
||||
for value in row {
|
||||
estimated_bytes = estimated_bytes.saturating_add(estimated_value_bytes(value));
|
||||
if estimated_bytes > DATA_GRID_EXTRACTOR_MAX_INPUT_BYTES {
|
||||
return Err(input_too_large_error());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn estimated_table_meta_bytes(table_meta: Option<&crate::data_grid_sql::DataGridTableMeta>) -> usize {
|
||||
let Some(table_meta) = table_meta else {
|
||||
return 0;
|
||||
};
|
||||
let mut size = table_meta
|
||||
.table_name
|
||||
.len()
|
||||
.saturating_add(table_meta.catalog.as_deref().map_or(0, str::len))
|
||||
.saturating_add(table_meta.database.as_deref().map_or(0, str::len))
|
||||
.saturating_add(table_meta.schema.as_deref().map_or(0, str::len));
|
||||
for primary_key in &table_meta.primary_keys {
|
||||
size = size.saturating_add(primary_key.len());
|
||||
}
|
||||
for column in table_meta.columns.as_deref().unwrap_or_default() {
|
||||
size = size
|
||||
.saturating_add(column.name.len())
|
||||
.saturating_add(column.data_type.len())
|
||||
.saturating_add(column.column_default.as_deref().map_or(0, str::len))
|
||||
.saturating_add(column.extra.as_deref().map_or(0, str::len));
|
||||
}
|
||||
size
|
||||
}
|
||||
|
||||
fn estimated_value_bytes(value: &Value) -> usize {
|
||||
match value {
|
||||
Value::Null => 4,
|
||||
Value::Bool(_) => 5,
|
||||
Value::Number(_) => 32,
|
||||
Value::String(value) => value.len(),
|
||||
Value::Array(values) => values
|
||||
.iter()
|
||||
.fold(2usize, |size, value| size.saturating_add(1).saturating_add(estimated_value_bytes(value))),
|
||||
Value::Object(values) => values.iter().fold(2usize, |size, (key, value)| {
|
||||
size.saturating_add(key.len()).saturating_add(2).saturating_add(estimated_value_bytes(value))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_too_large_error() -> DataGridExtractError {
|
||||
DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::InputTooLarge,
|
||||
"Selected data is too large for clipboard extraction; export the data to a file instead.",
|
||||
)
|
||||
}
|
||||
|
||||
struct WriteMetadata {
|
||||
mime_type: &'static str,
|
||||
file_extension: &'static str,
|
||||
omitted_columns: Vec<String>,
|
||||
warnings: Vec<DataGridExtractWarning>,
|
||||
}
|
||||
|
||||
fn write_extraction(
|
||||
context: &ExtractContext<'_>,
|
||||
output: &mut dyn Write,
|
||||
) -> Result<WriteMetadata, DataGridExtractError> {
|
||||
let extractor = context.request.extractor;
|
||||
match extractor {
|
||||
DataGridExtractorId::Raw => {
|
||||
if context.request.rows.len() != 1 || context.selected_source_indexes.len() != 1 {
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::InvalidRawSelection,
|
||||
"The raw extractor supports exactly one selected cell.",
|
||||
));
|
||||
}
|
||||
write_raw(context, output)?;
|
||||
Ok(text_metadata("text/plain", "txt"))
|
||||
}
|
||||
DataGridExtractorId::Tsv | DataGridExtractorId::TsvWithHeaders => {
|
||||
let options = DataGridDsvOptions {
|
||||
column_separator: "\t".to_string(),
|
||||
include_column_header: extractor == DataGridExtractorId::TsvWithHeaders,
|
||||
..context.request.options.dsv.clone()
|
||||
};
|
||||
write_dsv(context, output, &options)?;
|
||||
Ok(text_metadata("text/tab-separated-values", "tsv"))
|
||||
}
|
||||
DataGridExtractorId::Csv | DataGridExtractorId::CsvWithHeaders => {
|
||||
let options = DataGridDsvOptions {
|
||||
column_separator: ",".to_string(),
|
||||
include_column_header: extractor == DataGridExtractorId::CsvWithHeaders,
|
||||
..context.request.options.dsv.clone()
|
||||
};
|
||||
write_dsv(context, output, &options)?;
|
||||
Ok(text_metadata("text/csv", "csv"))
|
||||
}
|
||||
DataGridExtractorId::PipeSeparated => {
|
||||
let options =
|
||||
DataGridDsvOptions { column_separator: "|".to_string(), ..context.request.options.dsv.clone() };
|
||||
write_dsv(context, output, &options)?;
|
||||
Ok(text_metadata("text/plain", "txt"))
|
||||
}
|
||||
DataGridExtractorId::Dsv => {
|
||||
write_dsv(context, output, &context.request.options.dsv)?;
|
||||
Ok(text_metadata("text/plain", "txt"))
|
||||
}
|
||||
DataGridExtractorId::OneRow => {
|
||||
write_one_row(context, output)?;
|
||||
Ok(text_metadata("text/csv", "csv"))
|
||||
}
|
||||
DataGridExtractorId::Json => {
|
||||
let warnings = write_json(context, output)?;
|
||||
Ok(WriteMetadata {
|
||||
mime_type: "application/json",
|
||||
file_extension: "json",
|
||||
omitted_columns: Vec::new(),
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
DataGridExtractorId::JsonLines => {
|
||||
let warnings = write_json_lines(context, output)?;
|
||||
Ok(WriteMetadata {
|
||||
mime_type: "application/x-ndjson",
|
||||
file_extension: "jsonl",
|
||||
omitted_columns: Vec::new(),
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
DataGridExtractorId::SqlInList => {
|
||||
write_sql_in_list(context, output)?;
|
||||
Ok(text_metadata("application/sql", "sql"))
|
||||
}
|
||||
DataGridExtractorId::SqlInserts => write_sql_inserts(context, output),
|
||||
DataGridExtractorId::SqlUpdates => write_sql_updates(context, output),
|
||||
DataGridExtractorId::SqlSelect => {
|
||||
write_sql_select(context, output)?;
|
||||
Ok(text_metadata("application/sql", "sql"))
|
||||
}
|
||||
DataGridExtractorId::WhereClause => {
|
||||
write_where_clause(context, output)?;
|
||||
Ok(text_metadata("application/sql", "sql"))
|
||||
}
|
||||
DataGridExtractorId::Markdown => {
|
||||
write_markdown(context, output)?;
|
||||
Ok(text_metadata("text/markdown", "md"))
|
||||
}
|
||||
DataGridExtractorId::Html => {
|
||||
write_html(context, output)?;
|
||||
Ok(text_metadata("text/html", "html"))
|
||||
}
|
||||
DataGridExtractorId::Xml => {
|
||||
write_xml(context, output)?;
|
||||
Ok(text_metadata("application/xml", "xml"))
|
||||
}
|
||||
DataGridExtractorId::Pretty => {
|
||||
write_pretty(context, output)?;
|
||||
Ok(text_metadata("text/plain", "txt"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn text_metadata(mime_type: &'static str, extension: &'static str) -> WriteMetadata {
|
||||
WriteMetadata { mime_type, file_extension: extension, omitted_columns: Vec::new(), warnings: Vec::new() }
|
||||
}
|
||||
|
||||
fn normalized_name_eq(left: &str, right: &str) -> bool {
|
||||
normalized_name(left).eq_ignore_ascii_case(normalized_name(right))
|
||||
}
|
||||
|
||||
fn normalized_name(name: &str) -> &str {
|
||||
name.trim_matches(|character| matches!(character, '`' | '"' | '[' | ']'))
|
||||
}
|
||||
|
||||
fn value_text(value: &Value) -> Cow<'_, str> {
|
||||
match value {
|
||||
Value::Null => Cow::Borrowed("NULL"),
|
||||
Value::String(value) => Cow::Borrowed(value),
|
||||
Value::Bool(value) => Cow::Owned(value.to_string()),
|
||||
Value::Number(value) => Cow::Owned(value.to_string()),
|
||||
Value::Array(_) | Value::Object(_) => Cow::Owned(value.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_bytes(output: &mut dyn Write, bytes: &[u8]) -> Result<(), DataGridExtractError> {
|
||||
output.write_all(bytes).map_err(|error| {
|
||||
DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::EncodingFailed,
|
||||
format!("Failed to write extractor output: {error}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,22 @@
|
||||
use super::{value_text, write_bytes, DataGridExtractError, ExtractContext};
|
||||
use std::io::Write;
|
||||
|
||||
/// Writes selected cell values without DSV escaping. Tabs and newlines retain
|
||||
/// the selection shape, while NULL cells remain empty for spreadsheet-friendly
|
||||
/// clipboard output.
|
||||
pub(super) fn write_raw(context: &ExtractContext<'_>, output: &mut dyn Write) -> Result<(), DataGridExtractError> {
|
||||
for (row_index, row) in context.request.rows.iter().enumerate() {
|
||||
if row_index > 0 {
|
||||
write_bytes(output, b"\n")?;
|
||||
}
|
||||
for (column_index, source_index) in context.selected_source_indexes.iter().enumerate() {
|
||||
if column_index > 0 {
|
||||
write_bytes(output, b"\t")?;
|
||||
}
|
||||
if !row[*source_index].is_null() {
|
||||
write_bytes(output, value_text(&row[*source_index]).as_bytes())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
use super::{
|
||||
estimated_value_bytes, normalized_name_eq, write_bytes, DataGridExtractError, DataGridExtractErrorCode,
|
||||
DataGridExtractWarning, DataGridExtractWarningCode, ExtractContext, WriteMetadata,
|
||||
DATA_GRID_EXTRACTOR_MAX_OUTPUT_BYTES,
|
||||
};
|
||||
use crate::data_grid_sql::{
|
||||
build_column_predicate, build_data_grid_copy_insert_statement, build_data_grid_copy_update_statements,
|
||||
data_grid_qualified_table_name, format_grid_sql_literal_with_identifier_quote, is_auto_generated_column,
|
||||
is_grid_insert_omitted_column, is_non_identity_generated_column, supports_relational_copy_predicates,
|
||||
DataGridCopyInsertStatementOptions, DataGridCopyUpdateStatementOptions, DataGridTableMeta,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::collections::{hash_map::Entry, HashMap, HashSet};
|
||||
use std::io::Write;
|
||||
|
||||
pub(super) fn write_sql_in_list(
|
||||
context: &ExtractContext<'_>,
|
||||
output: &mut dyn Write,
|
||||
) -> Result<(), DataGridExtractError> {
|
||||
write_bytes(output, b"(")?;
|
||||
let mut seen_rows = HashMap::<Vec<u8>, ()>::new();
|
||||
let mut has_output = false;
|
||||
for row in &context.request.rows {
|
||||
let mut tuple = Vec::new();
|
||||
if context.selected_columns.len() > 1 {
|
||||
tuple.push(b'(');
|
||||
}
|
||||
for (column_index, source_index) in context.selected_source_indexes.iter().enumerate() {
|
||||
if column_index > 0 {
|
||||
tuple.extend_from_slice(b", ");
|
||||
}
|
||||
let value = &row[*source_index];
|
||||
let info = context.selected_column_info[column_index];
|
||||
tuple.extend_from_slice(
|
||||
format_grid_sql_literal_with_identifier_quote(
|
||||
value,
|
||||
context.request.database_type,
|
||||
info,
|
||||
context.request.identifier_quote.as_deref(),
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
if context.selected_columns.len() > 1 {
|
||||
tuple.push(b')');
|
||||
}
|
||||
if let Entry::Vacant(entry) = seen_rows.entry(tuple) {
|
||||
if has_output {
|
||||
write_bytes(output, b", ")?;
|
||||
}
|
||||
write_bytes(output, entry.key())?;
|
||||
entry.insert(());
|
||||
has_output = true;
|
||||
}
|
||||
}
|
||||
write_bytes(output, b")")
|
||||
}
|
||||
|
||||
pub(super) fn write_sql_inserts(
|
||||
context: &ExtractContext<'_>,
|
||||
output: &mut dyn Write,
|
||||
) -> Result<WriteMetadata, DataGridExtractError> {
|
||||
ensure_sql_builder_budget(context)?;
|
||||
let data = sql_selected_data(context, false)?;
|
||||
let statement = build_data_grid_copy_insert_statement(DataGridCopyInsertStatementOptions {
|
||||
database_type: context.request.database_type,
|
||||
identifier_quote: context.request.identifier_quote.clone(),
|
||||
table_meta: context.request.table_meta.clone(),
|
||||
columns: data.columns,
|
||||
column_types: Some(data.column_types),
|
||||
source_columns: Some(data.source_columns),
|
||||
rows: data.rows,
|
||||
exclude_primary_keys: context.request.options.sql.exclude_primary_keys_from_insert,
|
||||
include_computed_columns: !context.request.options.sql.skip_computed_columns,
|
||||
insert_mode: context.request.options.sql.insert_mode,
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::NoWritableColumns,
|
||||
"No insertable columns remain after applying extractor column rules.",
|
||||
)
|
||||
})?;
|
||||
write_bytes(output, statement.as_bytes())?;
|
||||
Ok(sql_metadata(data.omitted_columns))
|
||||
}
|
||||
|
||||
pub(super) fn write_sql_updates(
|
||||
context: &ExtractContext<'_>,
|
||||
output: &mut dyn Write,
|
||||
) -> Result<WriteMetadata, DataGridExtractError> {
|
||||
ensure_sql_builder_budget(context)?;
|
||||
let table_meta = context.request.table_meta.as_ref().ok_or_else(|| {
|
||||
DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::MissingTableMetadata,
|
||||
"SQL Updates requires table metadata.",
|
||||
)
|
||||
})?;
|
||||
if table_meta.primary_keys.is_empty() {
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::MissingPrimaryKey,
|
||||
"SQL Updates requires at least one primary key column.",
|
||||
));
|
||||
}
|
||||
let mut data = sql_selected_data(context, true)?;
|
||||
append_missing_primary_keys(context, table_meta, &mut data.columns, &mut data.source_columns, &mut data.rows)?;
|
||||
|
||||
let primary_key_names = table_meta.primary_keys.iter().map(|name| normalize_name(name)).collect::<HashSet<_>>();
|
||||
let writable_count =
|
||||
data.source_columns.iter().flatten().filter(|name| !primary_key_names.contains(&normalize_name(name))).count();
|
||||
if writable_count == 0 {
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::NoWritableColumns,
|
||||
"Select at least one writable non-primary-key column for SQL Updates.",
|
||||
));
|
||||
}
|
||||
let statements = build_data_grid_copy_update_statements(DataGridCopyUpdateStatementOptions {
|
||||
database_type: context.request.database_type,
|
||||
identifier_quote: context.request.identifier_quote.clone(),
|
||||
table_meta: table_meta.clone(),
|
||||
columns: data.columns,
|
||||
source_columns: Some(data.source_columns),
|
||||
rows: data.rows,
|
||||
});
|
||||
if statements.len() != context.request.rows.len() {
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::NullPrimaryKey,
|
||||
"One or more selected rows could not produce SQL Updates.",
|
||||
));
|
||||
}
|
||||
write_bytes(output, statements.join("\n").as_bytes())?;
|
||||
Ok(sql_metadata(data.omitted_columns))
|
||||
}
|
||||
|
||||
fn ensure_sql_builder_budget(context: &ExtractContext<'_>) -> Result<(), DataGridExtractError> {
|
||||
let value_bytes = context
|
||||
.request
|
||||
.rows
|
||||
.iter()
|
||||
.flatten()
|
||||
.fold(0usize, |size, value| size.saturating_add(estimated_value_bytes(value)));
|
||||
let identifier_bytes = context
|
||||
.request
|
||||
.table_meta
|
||||
.as_ref()
|
||||
.map_or(0, |table_meta| {
|
||||
table_meta
|
||||
.table_name
|
||||
.len()
|
||||
.saturating_add(table_meta.catalog.as_deref().map_or(0, str::len))
|
||||
.saturating_add(table_meta.database.as_deref().map_or(0, str::len))
|
||||
.saturating_add(table_meta.schema.as_deref().map_or(0, str::len))
|
||||
.saturating_add(table_meta.primary_keys.iter().fold(0usize, |size, key| size.saturating_add(key.len())))
|
||||
})
|
||||
.saturating_add(context.selected_columns.iter().fold(0usize, |size, column| {
|
||||
size.saturating_add(column.display_name.len())
|
||||
.saturating_add(column.source_name.as_deref().map_or(0, str::len))
|
||||
}));
|
||||
// WHERE-clause output (and SELECT built from a cell selection, which reuses
|
||||
// the same predicate builder) repeats every selected column's identifier
|
||||
// once per row via OR'd row groups, just like SqlUpdates/row-by-row insert.
|
||||
let repeats_identifiers_per_row = context.request.extractor == super::DataGridExtractorId::SqlUpdates
|
||||
|| context.request.options.sql.insert_mode == crate::data_grid_sql::DataGridCopyInsertMode::RowByRow
|
||||
|| context.request.extractor == super::DataGridExtractorId::WhereClause
|
||||
|| (context.request.extractor == super::DataGridExtractorId::SqlSelect
|
||||
&& context.request.selection_kind == super::DataGridSelectionKind::Cells);
|
||||
let statement_overhead = identifier_bytes
|
||||
.saturating_mul(4)
|
||||
.saturating_add(256)
|
||||
.saturating_mul(if repeats_identifiers_per_row { context.request.rows.len() } else { 1 });
|
||||
let estimated_bytes = value_bytes.saturating_mul(6).saturating_add(statement_overhead);
|
||||
if estimated_bytes > DATA_GRID_EXTRACTOR_MAX_OUTPUT_BYTES {
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::OutputTooLarge,
|
||||
"Estimated SQL clipboard output exceeds 32 MiB; export the data to a file instead.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_missing_primary_keys(
|
||||
context: &ExtractContext<'_>,
|
||||
table_meta: &DataGridTableMeta,
|
||||
columns: &mut Vec<String>,
|
||||
source_columns: &mut Vec<Option<String>>,
|
||||
rows: &mut [Vec<Value>],
|
||||
) -> Result<(), DataGridExtractError> {
|
||||
for primary_key in &table_meta.primary_keys {
|
||||
if source_columns.iter().flatten().any(|column| normalized_name_eq(column, primary_key)) {
|
||||
continue;
|
||||
}
|
||||
let column = context
|
||||
.request
|
||||
.columns
|
||||
.iter()
|
||||
.find(|column| {
|
||||
normalized_name_eq(column.source_name.as_deref().unwrap_or(&column.display_name), primary_key)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::MissingPrimaryKey,
|
||||
format!("Primary key column '{primary_key}' is not present in the result set."),
|
||||
)
|
||||
})?;
|
||||
if context.request.rows.iter().any(|row| match row.get(column.source_index) {
|
||||
Some(value) => value.is_null(),
|
||||
None => true,
|
||||
}) {
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::NullPrimaryKey,
|
||||
format!("Primary key column '{primary_key}' contains a NULL value."),
|
||||
));
|
||||
}
|
||||
columns.push(column.display_name.clone());
|
||||
source_columns.push(Some(primary_key.clone()));
|
||||
for (row_index, row) in context.request.rows.iter().enumerate() {
|
||||
let value = row.get(column.source_index).ok_or_else(|| {
|
||||
DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::InvalidColumnMapping,
|
||||
format!("Primary key column '{primary_key}' maps outside row {row_index}."),
|
||||
)
|
||||
})?;
|
||||
rows[row_index].push(value.clone());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct SqlSelectedData {
|
||||
columns: Vec<String>,
|
||||
source_columns: Vec<Option<String>>,
|
||||
column_types: Vec<Option<String>>,
|
||||
rows: Vec<Vec<Value>>,
|
||||
omitted_columns: Vec<String>,
|
||||
}
|
||||
|
||||
fn sql_selected_data(context: &ExtractContext<'_>, for_update: bool) -> Result<SqlSelectedData, DataGridExtractError> {
|
||||
let mut included = Vec::new();
|
||||
let mut omitted = Vec::new();
|
||||
let mut included_indexes = Vec::new();
|
||||
for (index, column) in context.selected_columns.iter().enumerate() {
|
||||
let source_name = column.source_name.as_deref().unwrap_or(&column.display_name);
|
||||
let info = context.selected_column_info[index];
|
||||
let is_primary_key = context.request.table_meta.as_ref().is_some_and(|meta| {
|
||||
meta.primary_keys.iter().any(|primary_key| normalized_name_eq(primary_key, source_name))
|
||||
});
|
||||
let omit = (!for_update
|
||||
&& is_grid_insert_omitted_column(
|
||||
context.request.database_type,
|
||||
info,
|
||||
Some(source_name),
|
||||
!context.request.options.sql.skip_computed_columns,
|
||||
))
|
||||
|| (for_update
|
||||
&& context.request.options.sql.skip_computed_columns
|
||||
&& info.is_some_and(|column| is_non_identity_generated_column(Some(column))))
|
||||
|| (context.request.options.sql.skip_generated_columns
|
||||
&& info.is_some_and(is_auto_generated_column)
|
||||
&& !is_primary_key)
|
||||
|| (for_update && is_primary_key)
|
||||
// Exclude only auto-generated primary keys; manually-assigned key
|
||||
// columns must survive "without primary keys" INSERT copies
|
||||
// (composite keys would otherwise lose NOT NULL data).
|
||||
|| (!for_update
|
||||
&& context.request.options.sql.exclude_primary_keys_from_insert
|
||||
&& is_primary_key
|
||||
&& info.is_some_and(is_auto_generated_column));
|
||||
if omit {
|
||||
omitted.push(source_name.to_string());
|
||||
} else {
|
||||
included.push(*column);
|
||||
included_indexes.push(index);
|
||||
}
|
||||
}
|
||||
if included.is_empty() {
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::NoWritableColumns,
|
||||
"No writable columns remain after applying extractor column rules.",
|
||||
));
|
||||
}
|
||||
let columns = included.iter().map(|column| column.display_name.clone()).collect();
|
||||
let source_columns = included
|
||||
.iter()
|
||||
.map(|column| Some(column.source_name.clone().unwrap_or_else(|| column.display_name.clone())))
|
||||
.collect();
|
||||
let column_types = included_indexes
|
||||
.iter()
|
||||
.map(|index| context.selected_column_info[*index].map(|info| info.data_type.clone()))
|
||||
.collect();
|
||||
let rows = context
|
||||
.request
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| included.iter().map(|column| row[column.source_index].clone()).collect())
|
||||
.collect();
|
||||
Ok(SqlSelectedData { columns, source_columns, column_types, rows, omitted_columns: omitted })
|
||||
}
|
||||
|
||||
pub(super) fn write_where_clause(
|
||||
context: &ExtractContext<'_>,
|
||||
output: &mut dyn Write,
|
||||
) -> Result<(), DataGridExtractError> {
|
||||
// WHERE-clause extraction emits relational SQL predicates and is not meaningful
|
||||
// for graph/document/time-series stores. Unlike `write_sql_updates`, this
|
||||
// intentionally does NOT auto-append primary keys: the predicate reflects the
|
||||
// exact cells the user selected.
|
||||
if !supports_relational_copy_predicates(context.request.database_type) {
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::UnsupportedDatabase,
|
||||
"WHERE-clause extraction is not supported for this database type.",
|
||||
));
|
||||
}
|
||||
for (row_index, row) in context.request.rows.iter().enumerate() {
|
||||
if row_index > 0 {
|
||||
write_bytes(output, b" OR ")?;
|
||||
}
|
||||
if context.request.rows.len() > 1 {
|
||||
write_bytes(output, b"(")?;
|
||||
}
|
||||
for (column_index, source_index) in context.selected_source_indexes.iter().enumerate() {
|
||||
if column_index > 0 {
|
||||
write_bytes(output, b" AND ")?;
|
||||
}
|
||||
let column = context.selected_columns[column_index];
|
||||
let value = &row[*source_index];
|
||||
let source_name = column.source_name.as_deref().unwrap_or(&column.display_name);
|
||||
// Reuse the UPDATE predicate builder so NULL -> IS NULL, MySQL BINARY,
|
||||
// and MySQL JSON CAST(...) stay consistent with copy-as-UPDATE.
|
||||
let predicate = build_column_predicate(
|
||||
context.request.database_type,
|
||||
source_name,
|
||||
value,
|
||||
context.selected_column_info[column_index],
|
||||
true,
|
||||
context.request.identifier_quote.as_deref(),
|
||||
);
|
||||
write_bytes(output, predicate.as_bytes())?;
|
||||
}
|
||||
if context.request.rows.len() > 1 {
|
||||
write_bytes(output, b")")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn write_sql_select(
|
||||
context: &ExtractContext<'_>,
|
||||
output: &mut dyn Write,
|
||||
) -> Result<(), DataGridExtractError> {
|
||||
ensure_sql_builder_budget(context)?;
|
||||
if !supports_relational_copy_predicates(context.request.database_type) {
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::UnsupportedDatabase,
|
||||
"SELECT extraction is not supported for this database type.",
|
||||
));
|
||||
}
|
||||
let table_meta =
|
||||
context.request.table_meta.as_ref().filter(|meta| !meta.table_name.trim().is_empty()).ok_or_else(|| {
|
||||
DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::MissingTableMetadata,
|
||||
"SELECT extraction requires one resolved table target.",
|
||||
)
|
||||
})?;
|
||||
if context.request.rows.is_empty()
|
||||
|| matches!(context.request.selection_kind, super::DataGridSelectionKind::Columns)
|
||||
|| (context.request.selection_kind == super::DataGridSelectionKind::Rows && context.request.rows.len() != 1)
|
||||
{
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::InvalidSelectSelection,
|
||||
"SELECT extraction supports cell selections or exactly one selected row.",
|
||||
));
|
||||
}
|
||||
|
||||
let table = data_grid_qualified_table_name(
|
||||
context.request.database_type,
|
||||
table_meta.catalog.as_deref(),
|
||||
table_meta.schema.as_deref(),
|
||||
table_meta.database.as_deref(),
|
||||
&table_meta.table_name,
|
||||
context.request.identifier_quote.as_deref(),
|
||||
);
|
||||
|
||||
if context.request.selection_kind == super::DataGridSelectionKind::Cells {
|
||||
// A cell-selection SELECT reuses the WHERE-clause predicate builder so a
|
||||
// multi-cell selection (same-row columns AND'd, multi-row selections
|
||||
// OR'd) produces the same predicate as "Copy as WHERE clause" for the
|
||||
// identical selection — including its fallback to display_name when a
|
||||
// column has no resolved source_name. build_context() already rejects
|
||||
// an empty column selection before this function runs.
|
||||
write_bytes(output, format!("SELECT * FROM {table} WHERE ").as_bytes())?;
|
||||
write_where_clause(context, output)?;
|
||||
return write_bytes(output, b";");
|
||||
}
|
||||
|
||||
// Only the single-row identity path (row-checkbox selection) reaches here;
|
||||
// cell selections returned above via the write_where_clause path.
|
||||
let row = &context.request.rows[0];
|
||||
let identity = table_meta
|
||||
.primary_keys
|
||||
.iter()
|
||||
.map(|primary_key| {
|
||||
context
|
||||
.request
|
||||
.columns
|
||||
.iter()
|
||||
.find(|column| {
|
||||
normalized_name_eq(column.source_name.as_deref().unwrap_or(&column.display_name), primary_key)
|
||||
})
|
||||
.filter(|column| row.get(column.source_index).is_some_and(|value| !value.is_null()))
|
||||
})
|
||||
.collect::<Option<Vec<_>>>();
|
||||
let predicate_columns =
|
||||
identity.filter(|columns| !columns.is_empty()).unwrap_or_else(|| context.request.columns.iter().collect());
|
||||
if predicate_columns.is_empty() {
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::InvalidColumnMapping,
|
||||
"SELECT extraction has no source columns for its predicate.",
|
||||
));
|
||||
}
|
||||
|
||||
let mut predicates = Vec::with_capacity(predicate_columns.len());
|
||||
for column in predicate_columns {
|
||||
let value = row.get(column.source_index).ok_or_else(|| {
|
||||
DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::InvalidColumnMapping,
|
||||
format!("Column '{}' maps to missing row index {}.", column.display_name, column.source_index),
|
||||
)
|
||||
})?;
|
||||
let source_name = column.source_name.as_deref().filter(|name| !name.trim().is_empty()).ok_or_else(|| {
|
||||
DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::InvalidColumnMapping,
|
||||
format!("Column '{}' has no resolved source-column mapping.", column.display_name),
|
||||
)
|
||||
})?;
|
||||
let column_info = table_meta
|
||||
.columns
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.find(|info| normalized_name_eq(&info.name, source_name));
|
||||
predicates.push(build_column_predicate(
|
||||
context.request.database_type,
|
||||
source_name,
|
||||
value,
|
||||
column_info,
|
||||
true,
|
||||
context.request.identifier_quote.as_deref(),
|
||||
));
|
||||
}
|
||||
write_bytes(output, format!("SELECT * FROM {table} WHERE {};", predicates.join(" AND ")).as_bytes())
|
||||
}
|
||||
|
||||
fn sql_metadata(omitted_columns: Vec<String>) -> WriteMetadata {
|
||||
let warnings = if omitted_columns.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![DataGridExtractWarning {
|
||||
code: DataGridExtractWarningCode::OmittedColumns,
|
||||
message: format!("Omitted columns: {}", omitted_columns.join(", ")),
|
||||
}]
|
||||
};
|
||||
WriteMetadata { mime_type: "application/sql", file_extension: "sql", omitted_columns, warnings }
|
||||
}
|
||||
|
||||
fn normalize_name(name: &str) -> String {
|
||||
name.trim_matches(|character| matches!(character, '`' | '"' | '[' | ']')).to_ascii_uppercase()
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,424 @@
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::*;
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
pub(super) fn uses_iotdb_table_model_save(options: &DataGridSaveStatementOptions) -> bool {
|
||||
options.database_type == Some(DatabaseType::Iotdb) && !options.table_meta.primary_keys.is_empty()
|
||||
}
|
||||
|
||||
pub(super) fn build_iotdb_data_grid_save_statements(options: &DataGridSaveStatementOptions) -> Vec<String> {
|
||||
let save_columns = effective_columns(options);
|
||||
let mut statements = Vec::new();
|
||||
|
||||
for (row_index, changes) in &options.dirty_rows {
|
||||
let Some(row) = options.rows.get(*row_index) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(statement) = build_iotdb_changed_row_insert(options, &save_columns, row, changes, true) {
|
||||
statements.push(statement);
|
||||
}
|
||||
}
|
||||
|
||||
for row_index in &options.deleted_rows {
|
||||
let Some(row) = options.rows.get(*row_index) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(statement) = build_iotdb_delete_statement(options, &save_columns, row) {
|
||||
statements.push(statement);
|
||||
}
|
||||
}
|
||||
|
||||
for row in &options.new_rows {
|
||||
if let Some(statement) = build_iotdb_full_row_insert(options, &save_columns, row) {
|
||||
statements.push(statement);
|
||||
}
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
pub(super) fn build_iotdb_data_grid_rollback_statements(options: &DataGridSaveStatementOptions) -> Vec<String> {
|
||||
let save_columns = effective_columns(options);
|
||||
let mut statements = Vec::new();
|
||||
|
||||
for row in &options.new_rows {
|
||||
if let Some(statement) = build_iotdb_delete_statement(options, &save_columns, row) {
|
||||
statements.push(statement);
|
||||
}
|
||||
}
|
||||
|
||||
for row_index in &options.deleted_rows {
|
||||
let Some(row) = options.rows.get(*row_index) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(statement) = build_iotdb_full_row_insert(options, &save_columns, row) {
|
||||
statements.push(statement);
|
||||
}
|
||||
}
|
||||
|
||||
for (row_index, changes) in &options.dirty_rows {
|
||||
let Some(row) = options.rows.get(*row_index) else {
|
||||
continue;
|
||||
};
|
||||
let requires_full_restore = changes.iter().any(|(column_index, _)| {
|
||||
let value = row.get(*column_index).unwrap_or(&Value::Null);
|
||||
let column = save_columns.get(*column_index).and_then(Option::as_deref);
|
||||
value.is_null()
|
||||
|| column.is_some_and(|column| {
|
||||
empty_string_saves_as_null(value, column_info_for(iotdb_column_info(options), column))
|
||||
})
|
||||
});
|
||||
if requires_full_restore {
|
||||
if let Some(statement) = build_iotdb_delete_statement(options, &save_columns, row) {
|
||||
statements.push(statement);
|
||||
}
|
||||
if let Some(statement) = build_iotdb_full_row_insert(options, &save_columns, row) {
|
||||
statements.push(statement);
|
||||
}
|
||||
} else if let Some(statement) = build_iotdb_changed_row_insert(options, &save_columns, row, changes, false) {
|
||||
statements.push(statement);
|
||||
}
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
pub(super) fn validate_iotdb_existing_rows(options: &DataGridSaveStatementOptions) -> Option<String> {
|
||||
if !uses_iotdb_table_model_save(options) || options.dirty_rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let save_columns = effective_columns(options);
|
||||
let primary_keys =
|
||||
options.table_meta.primary_keys.iter().map(|column| normalize_column_name(column)).collect::<HashSet<_>>();
|
||||
for (_, changes) in &options.dirty_rows {
|
||||
for (column_index, value) in changes {
|
||||
let Some(column) = save_columns.get(*column_index).and_then(Option::as_deref) else {
|
||||
continue;
|
||||
};
|
||||
if primary_keys.contains(&normalize_column_name(column)) {
|
||||
return Some("IoTDB row identifier columns (time and TAG) cannot be edited.".to_string());
|
||||
}
|
||||
if value.is_null() || empty_string_saves_as_null(value, column_info_for(iotdb_column_info(options), column))
|
||||
{
|
||||
return Some(
|
||||
"IoTDB Table model cannot clear a FIELD or ATTRIBUTE value without deleting the row. Enter a non-NULL value or delete the row instead."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn build_iotdb_changed_row_insert(
|
||||
options: &DataGridSaveStatementOptions,
|
||||
save_columns: &[Option<String>],
|
||||
row: &[Value],
|
||||
changes: &[(usize, Value)],
|
||||
use_changed_values: bool,
|
||||
) -> Option<String> {
|
||||
let changed_indexes = changes.iter().map(|(index, _)| *index).collect::<HashSet<_>>();
|
||||
let primary_keys =
|
||||
options.table_meta.primary_keys.iter().map(|column| normalize_column_name(column)).collect::<HashSet<_>>();
|
||||
let insert_pairs = save_columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, column)| {
|
||||
let column = column.as_deref()?;
|
||||
let is_primary_key = primary_keys.contains(&normalize_column_name(column));
|
||||
if !is_primary_key && !changed_indexes.contains(&index) {
|
||||
return None;
|
||||
}
|
||||
let value = if use_changed_values && !is_primary_key {
|
||||
changes.iter().find(|(changed_index, _)| *changed_index == index).map(|(_, value)| value)?
|
||||
} else {
|
||||
row.get(index).unwrap_or(&Value::Null)
|
||||
};
|
||||
Some((column, value))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
build_iotdb_insert_statement(options, insert_pairs)
|
||||
}
|
||||
|
||||
fn build_iotdb_full_row_insert(
|
||||
options: &DataGridSaveStatementOptions,
|
||||
save_columns: &[Option<String>],
|
||||
row: &[Value],
|
||||
) -> Option<String> {
|
||||
let insert_pairs = save_columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, column)| Some((column.as_deref()?, row.get(index).unwrap_or(&Value::Null))))
|
||||
.filter(|(_, value)| !value.is_null())
|
||||
.collect::<Vec<_>>();
|
||||
build_iotdb_insert_statement(options, insert_pairs)
|
||||
}
|
||||
|
||||
fn build_iotdb_insert_statement(
|
||||
options: &DataGridSaveStatementOptions,
|
||||
insert_pairs: Vec<(&str, &Value)>,
|
||||
) -> Option<String> {
|
||||
if insert_pairs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let columns = insert_pairs
|
||||
.iter()
|
||||
.map(|(column, _)| data_grid_identifier(Some(DatabaseType::Iotdb), column, options.identifier_quote.as_deref()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let values = insert_pairs
|
||||
.iter()
|
||||
.map(|(column, value)| {
|
||||
format_grid_save_sql_literal(
|
||||
value,
|
||||
Some(DatabaseType::Iotdb),
|
||||
column_info_for(iotdb_column_info(options), column),
|
||||
options.identifier_quote.as_deref(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Some(data_grid_statement(
|
||||
Some(DatabaseType::Iotdb),
|
||||
format!("INSERT INTO {} ({columns}) VALUES ({values})", iotdb_table(options)),
|
||||
))
|
||||
}
|
||||
|
||||
fn build_iotdb_delete_statement(
|
||||
options: &DataGridSaveStatementOptions,
|
||||
save_columns: &[Option<String>],
|
||||
row: &[Value],
|
||||
) -> Option<String> {
|
||||
let where_clause = build_primary_key_where(
|
||||
Some(DatabaseType::Iotdb),
|
||||
&options.table_meta.primary_keys,
|
||||
save_columns,
|
||||
row,
|
||||
iotdb_column_info(options),
|
||||
options.identifier_quote.as_deref(),
|
||||
);
|
||||
if where_clause.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(data_grid_statement(
|
||||
Some(DatabaseType::Iotdb),
|
||||
data_grid_delete_sql(Some(DatabaseType::Iotdb), &iotdb_table(options), &where_clause),
|
||||
))
|
||||
}
|
||||
|
||||
fn iotdb_table(options: &DataGridSaveStatementOptions) -> String {
|
||||
data_grid_qualified_table_name(
|
||||
Some(DatabaseType::Iotdb),
|
||||
options.table_meta.catalog.as_deref(),
|
||||
options.table_meta.schema.as_deref(),
|
||||
options.table_meta.database.as_deref(),
|
||||
&options.table_meta.table_name,
|
||||
options.identifier_quote.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn iotdb_column_info(options: &DataGridSaveStatementOptions) -> &[DataGridColumnInfo] {
|
||||
options.table_meta.columns.as_deref().unwrap_or(&[])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn column(name: &str, data_type: &str, nullable: bool, category: &str) -> DataGridColumnInfo {
|
||||
DataGridColumnInfo {
|
||||
name: name.to_string(),
|
||||
data_type: data_type.to_string(),
|
||||
is_nullable: nullable,
|
||||
is_primary_key: matches!(category, "TIME" | "TAG"),
|
||||
column_default: None,
|
||||
extra: Some(category.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn table_options() -> DataGridSaveStatementOptions {
|
||||
DataGridSaveStatementOptions {
|
||||
database_type: Some(DatabaseType::Iotdb),
|
||||
identifier_quote: None,
|
||||
table_meta: DataGridTableMeta {
|
||||
catalog: None,
|
||||
database: None,
|
||||
schema: Some("dbx_edit_preview".to_string()),
|
||||
table_name: "events".to_string(),
|
||||
primary_keys: vec!["time".to_string(), "device".to_string()],
|
||||
columns: Some(vec![
|
||||
column("time", "TIMESTAMP", false, "TIME"),
|
||||
column("device", "STRING", false, "TAG"),
|
||||
column("event_time", "TIMESTAMP", true, "FIELD"),
|
||||
column("temperature", "DOUBLE", true, "FIELD"),
|
||||
]),
|
||||
},
|
||||
columns: vec![
|
||||
"time".to_string(),
|
||||
"device".to_string(),
|
||||
"event_time".to_string(),
|
||||
"temperature".to_string(),
|
||||
],
|
||||
source_columns: None,
|
||||
rows: vec![vec![json!("1786954706123"), json!("sensor-a"), json!("1786954706123"), json!(23.5)]],
|
||||
dirty_rows: Vec::new(),
|
||||
deleted_rows: Vec::new(),
|
||||
new_rows: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepares_iotdb_timestamp_field_overwrite_and_rollback() {
|
||||
let mut options = table_options();
|
||||
options.dirty_rows = vec![(0, vec![(2, json!("2026-08-17 17:18:27.123"))])];
|
||||
|
||||
let result = prepare_data_grid_save(options);
|
||||
|
||||
assert_eq!(result.validation_error, None);
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec!["INSERT INTO dbx_edit_preview.events (time, device, event_time) VALUES (1786954706123, 'sensor-a', '2026-08-17 17:18:27.123');"]
|
||||
);
|
||||
assert_eq!(
|
||||
result.rollback_statements,
|
||||
vec!["INSERT INTO dbx_edit_preview.events (time, device, event_time) VALUES (1786954706123, 'sensor-a', 1786954706123);"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepares_iotdb_only_with_changed_fields() {
|
||||
let mut options = table_options();
|
||||
options.dirty_rows = vec![(0, vec![(2, json!("1786958307123")), (3, json!(24.75))])];
|
||||
|
||||
let result = prepare_data_grid_save(options);
|
||||
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec!["INSERT INTO dbx_edit_preview.events (time, device, event_time, temperature) VALUES (1786954706123, 'sensor-a', 1786958307123, 24.75);"]
|
||||
);
|
||||
assert_eq!(
|
||||
result.rollback_statements,
|
||||
vec!["INSERT INTO dbx_edit_preview.events (time, device, event_time, temperature) VALUES (1786954706123, 'sensor-a', 1786954706123, 23.5);"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepares_iotdb_insert_delete_and_rollback() {
|
||||
let mut options = table_options();
|
||||
options.deleted_rows = vec![0];
|
||||
options.new_rows = vec![vec![json!("1786961906789"), json!("sensor-b"), json!("1786961906789"), json!(22.75)]];
|
||||
|
||||
let result = prepare_data_grid_save(options);
|
||||
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec![
|
||||
"DELETE FROM dbx_edit_preview.events WHERE time = 1786954706123 AND device = 'sensor-a';",
|
||||
"INSERT INTO dbx_edit_preview.events (time, device, event_time, temperature) VALUES (1786961906789, 'sensor-b', 1786961906789, 22.75);",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
result.rollback_statements,
|
||||
vec![
|
||||
"DELETE FROM dbx_edit_preview.events WHERE time = 1786961906789 AND device = 'sensor-b';",
|
||||
"INSERT INTO dbx_edit_preview.events (time, device, event_time, temperature) VALUES (1786954706123, 'sensor-a', 1786954706123, 23.5);",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_iotdb_row_identifier_edits() {
|
||||
let mut options = table_options();
|
||||
options.dirty_rows = vec![(0, vec![(1, json!("sensor-b"))])];
|
||||
|
||||
let result = prepare_data_grid_save(options);
|
||||
|
||||
assert_eq!(
|
||||
result.validation_error,
|
||||
Some("IoTDB row identifier columns (time and TAG) cannot be edited.".to_string())
|
||||
);
|
||||
assert!(result.statements.is_empty());
|
||||
assert!(result.rollback_statements.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_iotdb_null_field_edits_that_insert_cannot_clear() {
|
||||
let mut options = table_options();
|
||||
options.dirty_rows = vec![(0, vec![(2, Value::Null)])];
|
||||
|
||||
let result = prepare_data_grid_save(options);
|
||||
|
||||
assert_eq!(
|
||||
result.validation_error,
|
||||
Some(
|
||||
"IoTDB Table model cannot clear a FIELD or ATTRIBUTE value without deleting the row. Enter a non-NULL value or delete the row instead."
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
assert!(result.statements.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restores_iotdb_original_null_with_delete_and_full_row_insert() {
|
||||
let mut options = table_options();
|
||||
options.rows[0][2] = Value::Null;
|
||||
options.dirty_rows = vec![(0, vec![(2, json!("1786958307123"))])];
|
||||
|
||||
let result = prepare_data_grid_save(options);
|
||||
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec!["INSERT INTO dbx_edit_preview.events (time, device, event_time) VALUES (1786954706123, 'sensor-a', 1786958307123);"]
|
||||
);
|
||||
assert_eq!(
|
||||
result.rollback_statements,
|
||||
vec![
|
||||
"DELETE FROM dbx_edit_preview.events WHERE time = 1786954706123 AND device = 'sensor-a';",
|
||||
"INSERT INTO dbx_edit_preview.events (time, device, temperature) VALUES (1786954706123, 'sensor-a', 23.5);",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_timestamp_precision_strings_as_unquoted_integer_literals() {
|
||||
for (original, changed) in [
|
||||
("1786954706123", "1786958307123"),
|
||||
("1786954706123456", "1786958307123456"),
|
||||
("1786954706123456789", "1786958307123456789"),
|
||||
] {
|
||||
let mut options = table_options();
|
||||
options.rows[0][0] = json!(original);
|
||||
options.rows[0][2] = json!(original);
|
||||
options.dirty_rows = vec![(0, vec![(2, json!(changed))])];
|
||||
|
||||
let result = prepare_data_grid_save(options);
|
||||
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec![format!(
|
||||
"INSERT INTO dbx_edit_preview.events (time, device, event_time) VALUES ({original}, 'sensor-a', {changed});"
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
result.rollback_statements,
|
||||
vec![format!(
|
||||
"INSERT INTO dbx_edit_preview.events (time, device, event_time) VALUES ({original}, 'sensor-a', {original});"
|
||||
)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_iotdb_tree_model_on_the_existing_sql_path() {
|
||||
let mut options = table_options();
|
||||
options.table_meta.primary_keys.clear();
|
||||
options.table_meta.schema = Some("root.dbx".to_string());
|
||||
options.table_meta.table_name = "d1".to_string();
|
||||
|
||||
assert!(!uses_iotdb_table_model_save(&options));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use super::*;
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
pub(super) fn build_neo4j_data_grid_save_statements(options: &DataGridSaveStatementOptions) -> Vec<String> {
|
||||
let label = quote_ident(Some(DatabaseType::Neo4j), &options.table_meta.table_name);
|
||||
let mut statements = Vec::new();
|
||||
|
||||
for (row_index, changes) in &options.dirty_rows {
|
||||
let Some(row) = options.rows.get(*row_index) else {
|
||||
continue;
|
||||
};
|
||||
let sets = changes
|
||||
.iter()
|
||||
.filter_map(|(column_index, value)| {
|
||||
let column = options.columns.get(*column_index)?;
|
||||
if is_neo4j_element_id(Some(DatabaseType::Neo4j), Some(column)) {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"n.{} = {}",
|
||||
quote_ident(Some(DatabaseType::Neo4j), column),
|
||||
format_grid_sql_literal(value, Some(DatabaseType::Neo4j), None)
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if sets.is_empty() {
|
||||
continue;
|
||||
}
|
||||
statements
|
||||
.push(format!("MATCH (n:{label}) WHERE {} SET {sets};", neo4j_element_id_predicate(&options.columns, row)));
|
||||
}
|
||||
|
||||
for row_index in &options.deleted_rows {
|
||||
let Some(row) = options.rows.get(*row_index) else {
|
||||
continue;
|
||||
};
|
||||
statements.push(format!(
|
||||
"MATCH (n:{label}) WHERE {} DETACH DELETE n;",
|
||||
neo4j_element_id_predicate(&options.columns, row)
|
||||
));
|
||||
}
|
||||
|
||||
for row in &options.new_rows {
|
||||
let props = options
|
||||
.columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, column)| !is_neo4j_element_id(Some(DatabaseType::Neo4j), Some(column)))
|
||||
.filter_map(|(index, column)| {
|
||||
let value = row.get(index).unwrap_or(&Value::Null);
|
||||
if value.is_null() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"{}: {}",
|
||||
quote_ident(Some(DatabaseType::Neo4j), column),
|
||||
format_grid_sql_literal(value, Some(DatabaseType::Neo4j), None)
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
statements.push(if props.is_empty() {
|
||||
format!("CREATE (n:{label});")
|
||||
} else {
|
||||
format!("CREATE (n:{label} {{{props}}});")
|
||||
});
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
pub(super) fn build_neo4j_data_grid_rollback_statements(options: &DataGridSaveStatementOptions) -> Vec<String> {
|
||||
let label = quote_ident(Some(DatabaseType::Neo4j), &options.table_meta.table_name);
|
||||
let mut statements = Vec::new();
|
||||
|
||||
for row in &options.new_rows {
|
||||
let where_clause = neo4j_row_property_where(&options.columns, row);
|
||||
statements.push(if where_clause.is_empty() {
|
||||
format!("MATCH (n:{label}) DETACH DELETE n;")
|
||||
} else {
|
||||
format!("MATCH (n:{label}) WHERE {where_clause} DETACH DELETE n;")
|
||||
});
|
||||
}
|
||||
|
||||
for row_index in &options.deleted_rows {
|
||||
let Some(row) = options.rows.get(*row_index) else {
|
||||
continue;
|
||||
};
|
||||
let props = options
|
||||
.columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, column)| !is_neo4j_element_id(Some(DatabaseType::Neo4j), Some(column)))
|
||||
.filter_map(|(index, column)| {
|
||||
let value = row.get(index).unwrap_or(&Value::Null);
|
||||
if value.is_null() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"{}: {}",
|
||||
quote_ident(Some(DatabaseType::Neo4j), column),
|
||||
format_grid_sql_literal(value, Some(DatabaseType::Neo4j), None)
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
statements.push(if props.is_empty() {
|
||||
format!("CREATE (n:{label});")
|
||||
} else {
|
||||
format!("CREATE (n:{label} {{{props}}});")
|
||||
});
|
||||
}
|
||||
|
||||
for (row_index, changes) in &options.dirty_rows {
|
||||
let Some(row) = options.rows.get(*row_index) else {
|
||||
continue;
|
||||
};
|
||||
let sets = changes
|
||||
.iter()
|
||||
.filter_map(|(column_index, _)| {
|
||||
let column = options.columns.get(*column_index)?;
|
||||
if is_neo4j_element_id(Some(DatabaseType::Neo4j), Some(column)) {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"n.{} = {}",
|
||||
quote_ident(Some(DatabaseType::Neo4j), column),
|
||||
format_grid_sql_literal(
|
||||
row.get(*column_index).unwrap_or(&Value::Null),
|
||||
Some(DatabaseType::Neo4j),
|
||||
None
|
||||
)
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if sets.is_empty() {
|
||||
continue;
|
||||
}
|
||||
statements
|
||||
.push(format!("MATCH (n:{label}) WHERE {} SET {sets};", neo4j_element_id_predicate(&options.columns, row)));
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
fn neo4j_element_id_predicate(columns: &[String], row: &[Value]) -> String {
|
||||
let index = columns.iter().position(|column| column == DBX_NEO4J_ELEMENT_ID_COLUMN).unwrap_or(usize::MAX);
|
||||
format!(
|
||||
"elementId(n) = {}",
|
||||
format_grid_sql_literal(row.get(index).unwrap_or(&Value::Null), Some(DatabaseType::Neo4j), None)
|
||||
)
|
||||
}
|
||||
|
||||
fn neo4j_row_property_where(columns: &[String], row: &[Value]) -> String {
|
||||
columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, column)| {
|
||||
if is_neo4j_element_id(Some(DatabaseType::Neo4j), Some(column)) {
|
||||
return None;
|
||||
}
|
||||
let value = row.get(index).unwrap_or(&Value::Null);
|
||||
let ident = format!("n.{}", quote_ident(Some(DatabaseType::Neo4j), column));
|
||||
if value.is_null() {
|
||||
Some(format!("{ident} IS NULL"))
|
||||
} else {
|
||||
Some(format!("{ident} = {}", format_grid_sql_literal(value, Some(DatabaseType::Neo4j), None)))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" AND ")
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,303 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use super::*;
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
pub(super) fn build_tdengine_data_grid_save_statements(options: &DataGridSaveStatementOptions) -> Vec<String> {
|
||||
let save_columns = effective_columns(options);
|
||||
let mut statements = Vec::new();
|
||||
let has_tbname = save_columns
|
||||
.iter()
|
||||
.any(|column| column.as_deref().is_some_and(|column| column.eq_ignore_ascii_case(DBX_TDENGINE_TBNAME_COLUMN)));
|
||||
let requires_tbname = options
|
||||
.table_meta
|
||||
.primary_keys
|
||||
.iter()
|
||||
.any(|primary_key| primary_key.eq_ignore_ascii_case(DBX_TDENGINE_TBNAME_COLUMN));
|
||||
|
||||
if has_tbname || !requires_tbname {
|
||||
for (row_index, changes) in &options.dirty_rows {
|
||||
let Some(row) = options.rows.get(*row_index) else {
|
||||
continue;
|
||||
};
|
||||
let mut after_row = row.clone();
|
||||
for (column_index, value) in changes {
|
||||
if *column_index < after_row.len() {
|
||||
after_row[*column_index] = value.clone();
|
||||
}
|
||||
}
|
||||
if let Some(statement) = build_tdengine_insert_statement(options, &save_columns, &after_row) {
|
||||
statements.push(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for row_index in &options.deleted_rows {
|
||||
let Some(row) = options.rows.get(*row_index) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(statement) = build_tdengine_delete_statement(options, &save_columns, row) {
|
||||
statements.push(statement);
|
||||
}
|
||||
}
|
||||
|
||||
for row in &options.new_rows {
|
||||
if let Some(statement) = build_tdengine_insert_statement(options, &save_columns, row) {
|
||||
statements.push(statement);
|
||||
}
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
pub(super) fn build_tdengine_data_grid_rollback_statements(options: &DataGridSaveStatementOptions) -> Vec<String> {
|
||||
if has_tdengine_composite_primary_key(options) && !options.new_rows.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let save_columns = effective_columns(options);
|
||||
let mut statements = Vec::new();
|
||||
|
||||
for row in &options.new_rows {
|
||||
if let Some(statement) = build_tdengine_delete_statement(options, &save_columns, row) {
|
||||
statements.push(statement);
|
||||
}
|
||||
}
|
||||
|
||||
for row_index in &options.deleted_rows {
|
||||
let Some(row) = options.rows.get(*row_index) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(statement) = build_tdengine_insert_statement(options, &save_columns, row) {
|
||||
statements.push(statement);
|
||||
}
|
||||
}
|
||||
|
||||
for (row_index, _) in &options.dirty_rows {
|
||||
let Some(row) = options.rows.get(*row_index) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(statement) = build_tdengine_insert_statement(options, &save_columns, row) {
|
||||
statements.push(statement);
|
||||
}
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
pub(super) fn validate_tdengine_inserted_rows(options: &DataGridSaveStatementOptions) -> Option<String> {
|
||||
if options.database_type != Some(DatabaseType::Tdengine) || options.new_rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let requires_tbname = options
|
||||
.table_meta
|
||||
.primary_keys
|
||||
.iter()
|
||||
.any(|primary_key| primary_key.eq_ignore_ascii_case(DBX_TDENGINE_TBNAME_COLUMN));
|
||||
if !requires_tbname {
|
||||
return None;
|
||||
}
|
||||
|
||||
let save_columns = effective_columns(options);
|
||||
if options
|
||||
.new_rows
|
||||
.iter()
|
||||
.any(|row| tdengine_tbname_value(&save_columns, row).is_none_or(|tbname| tbname.trim().is_empty()))
|
||||
{
|
||||
return Some(tdengine_insert_identity_error());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn validate_tdengine_existing_rows(options: &DataGridSaveStatementOptions) -> Option<String> {
|
||||
if options.database_type != Some(DatabaseType::Tdengine)
|
||||
|| (options.deleted_rows.is_empty() && options.dirty_rows.is_empty())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if has_tdengine_composite_primary_key(options) && !options.deleted_rows.is_empty() {
|
||||
return Some(tdengine_composite_key_delete_error());
|
||||
}
|
||||
|
||||
let save_columns = effective_columns(options);
|
||||
let primary_keys = tdengine_row_primary_keys(options);
|
||||
let requires_tbname = options
|
||||
.table_meta
|
||||
.primary_keys
|
||||
.iter()
|
||||
.any(|primary_key| primary_key.eq_ignore_ascii_case(DBX_TDENGINE_TBNAME_COLUMN));
|
||||
if (requires_tbname
|
||||
&& !save_columns.iter().any(|column| {
|
||||
column.as_deref().is_some_and(|column| column.eq_ignore_ascii_case(DBX_TDENGINE_TBNAME_COLUMN))
|
||||
}))
|
||||
|| primary_keys.is_empty()
|
||||
|| primary_keys
|
||||
.iter()
|
||||
.any(|primary_key| find_column_index(options.database_type, &save_columns, primary_key).is_none())
|
||||
{
|
||||
return Some(tdengine_row_identity_error());
|
||||
}
|
||||
|
||||
if options.dirty_rows.iter().any(|(_, changes)| {
|
||||
changes.iter().any(|(column_index, _)| {
|
||||
save_columns.get(*column_index).and_then(Option::as_deref).is_some_and(|column| {
|
||||
options.table_meta.primary_keys.iter().any(|primary_key| primary_key.eq_ignore_ascii_case(column))
|
||||
})
|
||||
})
|
||||
}) {
|
||||
return Some(tdengine_row_identity_readonly_error());
|
||||
}
|
||||
|
||||
for row_index in
|
||||
options.deleted_rows.iter().copied().chain(options.dirty_rows.iter().map(|(row_index, _)| *row_index))
|
||||
{
|
||||
let Some(row) = options.rows.get(row_index) else {
|
||||
return Some(tdengine_row_identity_error());
|
||||
};
|
||||
if (requires_tbname && tdengine_tbname_value(&save_columns, row).is_none_or(|tbname| tbname.trim().is_empty()))
|
||||
|| primary_keys.iter().any(|primary_key| {
|
||||
find_column_index(options.database_type, &save_columns, primary_key)
|
||||
.and_then(|index| row.get(index))
|
||||
.is_none_or(Value::is_null)
|
||||
})
|
||||
{
|
||||
return Some(tdengine_row_identity_error());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn build_tdengine_delete_statement(
|
||||
options: &DataGridSaveStatementOptions,
|
||||
save_columns: &[Option<String>],
|
||||
row: &[Value],
|
||||
) -> Option<String> {
|
||||
if has_tdengine_composite_primary_key(options) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let table_name = tdengine_tbname_value(save_columns, row).unwrap_or_else(|| options.table_meta.table_name.clone());
|
||||
let primary_keys = tdengine_row_primary_keys(options);
|
||||
let where_clause = build_primary_key_where(
|
||||
Some(DatabaseType::Tdengine),
|
||||
&primary_keys,
|
||||
save_columns,
|
||||
row,
|
||||
options.table_meta.columns.as_deref().unwrap_or(&[]),
|
||||
None,
|
||||
);
|
||||
if where_clause.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let table = qualified_table_name(Some(DatabaseType::Tdengine), options.table_meta.schema.as_deref(), &table_name);
|
||||
Some(data_grid_statement(
|
||||
Some(DatabaseType::Tdengine),
|
||||
data_grid_delete_sql(Some(DatabaseType::Tdengine), &table, &where_clause),
|
||||
))
|
||||
}
|
||||
|
||||
fn tdengine_row_primary_keys(options: &DataGridSaveStatementOptions) -> Vec<String> {
|
||||
options
|
||||
.table_meta
|
||||
.primary_keys
|
||||
.iter()
|
||||
.filter(|primary_key| !primary_key.eq_ignore_ascii_case(DBX_TDENGINE_TBNAME_COLUMN))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn has_tdengine_composite_primary_key(options: &DataGridSaveStatementOptions) -> bool {
|
||||
tdengine_row_primary_keys(options).len() > 1
|
||||
}
|
||||
|
||||
fn tdengine_row_identity_error() -> String {
|
||||
"TDengine row editing requires all row identifier columns in the result.".to_string()
|
||||
}
|
||||
|
||||
fn tdengine_row_identity_readonly_error() -> String {
|
||||
"TDengine row identifier columns cannot be edited.".to_string()
|
||||
}
|
||||
|
||||
fn tdengine_composite_key_delete_error() -> String {
|
||||
"TDengine tables with composite keys do not support row deletion.".to_string()
|
||||
}
|
||||
|
||||
fn tdengine_insert_identity_error() -> String {
|
||||
"TDengine STABLE inserts require a child table name (tbname).".to_string()
|
||||
}
|
||||
|
||||
fn build_tdengine_insert_statement(
|
||||
options: &DataGridSaveStatementOptions,
|
||||
save_columns: &[Option<String>],
|
||||
row: &[Value],
|
||||
) -> Option<String> {
|
||||
let tbname = tdengine_tbname_value(save_columns, row);
|
||||
let table = qualified_table_name(
|
||||
Some(DatabaseType::Tdengine),
|
||||
options.table_meta.schema.as_deref(),
|
||||
&options.table_meta.table_name,
|
||||
);
|
||||
let tag_columns = tdengine_tag_columns(options.table_meta.columns.as_deref());
|
||||
let insert_pairs: Vec<(&str, &Value)> = save_columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, column)| Some((column.as_deref()?, row.get(index).unwrap_or(&Value::Null))))
|
||||
.filter(|(_, value)| !value.is_null())
|
||||
.filter(|(column, _)| {
|
||||
tdengine_can_insert_column(column, &options.table_meta.table_name, tbname.as_deref(), &tag_columns)
|
||||
})
|
||||
.collect();
|
||||
if insert_pairs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let columns = insert_pairs
|
||||
.iter()
|
||||
.map(|(column, _)| quote_ident(Some(DatabaseType::Tdengine), column))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let values = insert_pairs
|
||||
.iter()
|
||||
.map(|(_, value)| format_grid_sql_literal(value, Some(DatabaseType::Tdengine), None))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Some(format!("INSERT INTO {table} ({columns}) VALUES ({values});"))
|
||||
}
|
||||
|
||||
fn tdengine_tbname_value(save_columns: &[Option<String>], row: &[Value]) -> Option<String> {
|
||||
let index = save_columns.iter().position(|column| {
|
||||
column.as_deref().is_some_and(|column| column.eq_ignore_ascii_case(DBX_TDENGINE_TBNAME_COLUMN))
|
||||
})?;
|
||||
let value = row.get(index)?;
|
||||
if value.is_null() {
|
||||
None
|
||||
} else if let Some(value) = value.as_str() {
|
||||
Some(value.to_string())
|
||||
} else {
|
||||
Some(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn tdengine_can_insert_column(column: &str, table_name: &str, tbname: Option<&str>, tag_columns: &[String]) -> bool {
|
||||
let normalized = column.to_ascii_lowercase();
|
||||
let target_is_child_table = tbname.is_none_or(|tbname| tbname == table_name);
|
||||
if !target_is_child_table {
|
||||
return true;
|
||||
}
|
||||
if normalized == DBX_TDENGINE_TBNAME_COLUMN {
|
||||
return false;
|
||||
}
|
||||
!tag_columns.iter().any(|tag| tag == &normalized)
|
||||
}
|
||||
|
||||
fn tdengine_tag_columns(columns: Option<&[DataGridColumnInfo]>) -> Vec<String> {
|
||||
columns
|
||||
.unwrap_or(&[])
|
||||
.iter()
|
||||
.filter(|column| column.extra.as_deref().unwrap_or("").to_ascii_lowercase().contains("tag"))
|
||||
.map(|column| column.name.to_ascii_lowercase())
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::sql_dialect::{
|
||||
firebird_rows_clause, pagination_strategy, qualified_table_name, quote_table_identifier, PaginationContext,
|
||||
TablePaginationStrategy,
|
||||
};
|
||||
|
||||
#[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 driver_profile: Option<String>,
|
||||
#[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, options.driver_profile.as_deref(), &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 = match pagination_strategy(options.database_type, PaginationContext::BoundedRead) {
|
||||
TablePaginationStrategy::SqlServerTop => format!("SELECT TOP {limit} * FROM {table} WHERE ({where_clause})"),
|
||||
TablePaginationStrategy::IrisTop => format!("SELECT TOP {limit} * FROM {table} WHERE ({where_clause})"),
|
||||
TablePaginationStrategy::InformixFirst => format!("SELECT FIRST {limit} * FROM {table} WHERE ({where_clause})"),
|
||||
TablePaginationStrategy::FirebirdRows => {
|
||||
let rows = firebird_rows_clause(limit, 0);
|
||||
format!("SELECT * FROM {table} WHERE ({where_clause}) {rows}")
|
||||
}
|
||||
TablePaginationStrategy::Db2FetchFirst | TablePaginationStrategy::FetchFirst => {
|
||||
format!("SELECT * FROM {table} WHERE ({where_clause}) FETCH FIRST {limit} ROWS ONLY")
|
||||
}
|
||||
TablePaginationStrategy::Rownum => {
|
||||
format!("SELECT * FROM (SELECT * FROM {table} WHERE ({where_clause})) WHERE ROWNUM <= {limit}")
|
||||
}
|
||||
TablePaginationStrategy::AgentMaxRows => format!("SELECT * FROM {table} WHERE ({where_clause});"),
|
||||
TablePaginationStrategy::QuestDbLimit | TablePaginationStrategy::LimitOffset => {
|
||||
format!("SELECT * FROM {table} WHERE ({where_clause}) LIMIT {limit};")
|
||||
}
|
||||
TablePaginationStrategy::Unbounded => format!("SELECT * FROM {table} WHERE ({where_clause})"),
|
||||
};
|
||||
|
||||
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>, driver_profile: Option<&str>, identifier: &str) -> String {
|
||||
match database_type {
|
||||
Some(DatabaseType::Mysql) => format!("LOWER(CAST({identifier} AS CHAR))"),
|
||||
Some(DatabaseType::SqlServer)
|
||||
if driver_profile.is_some_and(|profile| profile.trim().eq_ignore_ascii_case("sqlserver-legacy")) =>
|
||||
{
|
||||
format!("LOWER(CAST({identifier} AS NVARCHAR(4000)))")
|
||||
}
|
||||
Some(DatabaseType::SqlServer) => format!("LOWER(CAST({identifier} AS NVARCHAR(MAX)))"),
|
||||
Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle) => {
|
||||
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),
|
||||
driver_profile: None,
|
||||
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 uses_legacy_sqlserver_cast_without_max_type() {
|
||||
let query = build_database_search_sql(DatabaseSearchSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
driver_profile: Some(" SQLSERVER-LEGACY ".to_string()),
|
||||
schema: Some("dbo".to_string()),
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![col("name", "nvarchar", false)],
|
||||
term: "alice".to_string(),
|
||||
limit: Some(20),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
query.sql,
|
||||
"SELECT TOP 20 * FROM [dbo].[users] WHERE (LOWER(CAST([name] AS NVARCHAR(4000))) LIKE '%alice%' ESCAPE '~')"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adds_numeric_predicates_only_for_numeric_terms() {
|
||||
let query = build_database_search_sql(DatabaseSearchSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
driver_profile: None,
|
||||
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 builds_oceanbase_oracle_search_query_with_rownum_limit() {
|
||||
let query = build_database_search_sql(DatabaseSearchSqlOptions {
|
||||
database_type: Some(DatabaseType::OceanbaseOracle),
|
||||
driver_profile: None,
|
||||
schema: Some("APP".to_string()),
|
||||
table_name: "USERS".to_string(),
|
||||
columns: vec![col("NAME", "varchar2", false)],
|
||||
term: "alice".to_string(),
|
||||
limit: Some(20),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(query.searchable_columns, vec!["NAME"]);
|
||||
assert_eq!(
|
||||
query.sql,
|
||||
"SELECT * FROM (SELECT * FROM \"APP\".\"USERS\" WHERE (LOWER(CAST(\"NAME\" AS VARCHAR2(4000))) LIKE '%alice%' ESCAPE '~')) WHERE ROWNUM <= 20"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_tables_without_searchable_columns() {
|
||||
assert_eq!(
|
||||
build_database_search_sql(DatabaseSearchSqlOptions {
|
||||
database_type: Some(DatabaseType::Sqlite),
|
||||
driver_profile: None,
|
||||
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");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,409 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::RwLock;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::sql_dialect::descriptor::DialectKind;
|
||||
use crate::sql_dialect::dialect_yaml::DialectYaml;
|
||||
use crate::sql_dialect::inference::{ColumnType, DefaultTypeInferenceEngine, TypeInferenceEngine};
|
||||
|
||||
// ============================================================================
|
||||
// DML Clean Rules — lossy type mapping → pre-transform DML template
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DmlCleanRulesFile {
|
||||
pub rules: Vec<DmlCleanRule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DmlCleanRule {
|
||||
pub name: String,
|
||||
pub source_type: String,
|
||||
pub target_type: String,
|
||||
pub max_fidelity: f64,
|
||||
#[serde(default)]
|
||||
pub pre_transform_sql: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl DmlCleanRule {
|
||||
pub fn transform_sql(&self, table: &str, column: &str) -> Option<String> {
|
||||
self.pre_transform_sql.as_ref().map(|tmpl| tmpl.replace("{table}", table).replace("{column}", column))
|
||||
}
|
||||
|
||||
/// Check if this rule matches the source → target type pair.
|
||||
pub fn matches(&self, source_type: &str, target_type: &str) -> bool {
|
||||
let src_norm = normalize_type(source_type);
|
||||
let tgt_norm = normalize_type(target_type);
|
||||
let rule_src = normalize_type(&self.source_type);
|
||||
let rule_tgt = normalize_type(&self.target_type);
|
||||
src_norm == rule_src && tgt_norm == rule_tgt
|
||||
}
|
||||
|
||||
fn applies(&self, source_type: &str, target_type: &str, fidelity: f64) -> bool {
|
||||
self.matches(source_type, target_type) && fidelity < self.max_fidelity
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_type(t: &str) -> String {
|
||||
t.trim().to_ascii_uppercase().split('(').next().unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DmlCleanRuleRegistry — global singleton, loads from YAML
|
||||
// ============================================================================
|
||||
|
||||
pub struct DmlCleanRuleRegistry {
|
||||
pub rules: Vec<DmlCleanRule>,
|
||||
}
|
||||
|
||||
impl DmlCleanRuleRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self { rules: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn global() -> &'static RwLock<Self> {
|
||||
use std::sync::OnceLock;
|
||||
static INSTANCE: OnceLock<RwLock<DmlCleanRuleRegistry>> = OnceLock::new();
|
||||
INSTANCE.get_or_init(|| RwLock::new(Self::new()))
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<Vec<DmlCleanRule>, String> {
|
||||
let content = std::fs::read_to_string(path).map_err(|e| format!("Cannot read {}: {e}", path.display()))?;
|
||||
let parsed: DmlCleanRulesFile =
|
||||
serde_yaml::from_str(&content).map_err(|e| format!("YAML parse error in {}: {e}", path.display()))?;
|
||||
Ok(parsed.rules)
|
||||
}
|
||||
|
||||
pub fn load_default() -> Result<(), String> {
|
||||
let paths = [
|
||||
"plugins/mappings/rules/dml_clean_rules.yaml",
|
||||
"../plugins/mappings/rules/dml_clean_rules.yaml",
|
||||
"../../plugins/mappings/rules/dml_clean_rules.yaml",
|
||||
];
|
||||
for path in &paths {
|
||||
let p = Path::new(path);
|
||||
if p.exists() {
|
||||
let rules = Self::load(p)?;
|
||||
if let Ok(mut registry) = Self::global().write() {
|
||||
registry.rules = rules;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn find_rule(&self, source_type: &str, target_type: &str) -> Option<&DmlCleanRule> {
|
||||
self.rules.iter().find(|r| r.matches(source_type, target_type))
|
||||
}
|
||||
|
||||
pub fn all_rules(&self) -> &[DmlCleanRule] {
|
||||
&self.rules
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DmlCleanRuleRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BindingEngine — auto-binds DML clean SQL to lossy type mappings
|
||||
// ============================================================================
|
||||
|
||||
pub struct BindingEngine;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BindingResult {
|
||||
pub source_type: String,
|
||||
pub target_type: String,
|
||||
pub fidelity: f64,
|
||||
pub pre_transform_sql: Option<String>,
|
||||
pub rule_name: Option<String>,
|
||||
}
|
||||
|
||||
impl BindingEngine {
|
||||
/// Compute fidelity for a single type mapping and auto-bind DML if lossy.
|
||||
pub fn bind(source_type: &str, target_type: &str, table: &str, column: &str, threshold: f64) -> BindingResult {
|
||||
let engine = DefaultTypeInferenceEngine;
|
||||
let src_parsed = ColumnType::parse(source_type);
|
||||
let tgt_parsed = ColumnType::parse(target_type);
|
||||
let fidelity = engine.type_compatibility_score(&src_parsed, &tgt_parsed);
|
||||
|
||||
if let Ok(registry) = DmlCleanRuleRegistry::global().read() {
|
||||
if let Some(rule) = registry.find_rule(source_type, target_type) {
|
||||
// A pair-specific rule owns its fidelity boundary; the caller
|
||||
// threshold remains the fallback for mappings without a rule.
|
||||
if rule.applies(source_type, target_type, fidelity) {
|
||||
return BindingResult {
|
||||
source_type: source_type.to_string(),
|
||||
target_type: target_type.to_string(),
|
||||
fidelity,
|
||||
pre_transform_sql: rule.transform_sql(table, column),
|
||||
rule_name: Some(rule.name.clone()),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fidelity >= threshold {
|
||||
return BindingResult {
|
||||
source_type: source_type.to_string(),
|
||||
target_type: target_type.to_string(),
|
||||
fidelity,
|
||||
pre_transform_sql: None,
|
||||
rule_name: None,
|
||||
};
|
||||
}
|
||||
|
||||
BindingResult {
|
||||
source_type: source_type.to_string(),
|
||||
target_type: target_type.to_string(),
|
||||
fidelity,
|
||||
pre_transform_sql: None,
|
||||
rule_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind for a full set of column mappings.
|
||||
pub fn bind_columns(
|
||||
source_types: &[(String, String)], // (col_name, type)
|
||||
target_types: &[(String, String)],
|
||||
table: &str,
|
||||
threshold: f64,
|
||||
) -> Vec<BindingResult> {
|
||||
let source_map: HashMap<&str, &str> = source_types.iter().map(|(n, t)| (n.as_str(), t.as_str())).collect();
|
||||
|
||||
target_types
|
||||
.iter()
|
||||
.map(|(col_name, target_type)| {
|
||||
let source_type = source_map.get(col_name.as_str()).copied().unwrap_or(target_type);
|
||||
Self::bind(source_type, target_type, table, col_name, threshold)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MappingCache — cache derivation results to plugins/mappings/base/
|
||||
// ============================================================================
|
||||
|
||||
const MAPPING_BASE_DIR: &str = "plugins/mappings/base";
|
||||
const MAPPING_CUSTOM_DIR: &str = "plugins/mappings/custom";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MappingCacheFile {
|
||||
pub status: String, // "UNVERIFIED" | "VERIFIED" | "REJECTED"
|
||||
pub generated_at: String,
|
||||
pub derived_from: MappingDerivationInfo,
|
||||
pub transformations: Vec<CachedTransformation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MappingDerivationInfo {
|
||||
pub source_dialect: String,
|
||||
pub target_dialect: String,
|
||||
pub source_type_count: usize,
|
||||
pub target_type_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CachedTransformation {
|
||||
pub source_type: String,
|
||||
pub target_type: String,
|
||||
pub fidelity: f64,
|
||||
pub pre_transform_sql: Option<String>,
|
||||
pub rule_name: Option<String>,
|
||||
}
|
||||
|
||||
impl MappingCacheFile {
|
||||
pub fn cache_path(source: DialectKind, target: DialectKind) -> PathBuf {
|
||||
let dir = Path::new(MAPPING_BASE_DIR);
|
||||
let name = format!("{}_to_{}.base.yaml", source.label(), target.label());
|
||||
dir.join(name)
|
||||
}
|
||||
|
||||
pub fn custom_path(source: DialectKind, target: DialectKind, env: &str) -> PathBuf {
|
||||
let dir = Path::new(MAPPING_CUSTOM_DIR);
|
||||
let name = format!("{}_to_{}.{}.yaml", source.label(), target.label(), env);
|
||||
dir.join(name)
|
||||
}
|
||||
|
||||
/// Load from cache if exists. Checks custom override first, then base cache.
|
||||
pub fn load(source: DialectKind, target: DialectKind, env: &str) -> Option<Self> {
|
||||
let custom = Self::custom_path(source, target, env);
|
||||
if custom.exists() {
|
||||
let content = std::fs::read_to_string(&custom).ok()?;
|
||||
return serde_yaml::from_str(&content).ok();
|
||||
}
|
||||
|
||||
let base = Self::cache_path(source, target);
|
||||
if base.exists() {
|
||||
let content = std::fs::read_to_string(&base).ok()?;
|
||||
return serde_yaml::from_str(&content).ok();
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Save derivation result to base cache (UNVERIFIED).
|
||||
pub fn save(source: DialectKind, target: DialectKind, bindings: &[BindingResult]) -> Result<(), String> {
|
||||
let dir = Path::new(MAPPING_BASE_DIR);
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("Cannot create mappings dir: {e}"))?;
|
||||
|
||||
let source_yaml = DialectYaml::from_descriptor(source);
|
||||
let target_yaml = DialectYaml::from_descriptor(target);
|
||||
|
||||
let cache = MappingCacheFile {
|
||||
status: "UNVERIFIED".to_string(),
|
||||
generated_at: chrono::Utc::now().to_rfc3339(),
|
||||
derived_from: MappingDerivationInfo {
|
||||
source_dialect: source.label().to_string(),
|
||||
target_dialect: target.label().to_string(),
|
||||
source_type_count: source_yaml.types.len(),
|
||||
target_type_count: target_yaml.types.len(),
|
||||
},
|
||||
transformations: bindings
|
||||
.iter()
|
||||
.map(|b| CachedTransformation {
|
||||
source_type: b.source_type.clone(),
|
||||
target_type: b.target_type.clone(),
|
||||
fidelity: b.fidelity,
|
||||
pre_transform_sql: b.pre_transform_sql.clone(),
|
||||
rule_name: b.rule_name.clone(),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let path = Self::cache_path(source, target);
|
||||
let yaml_str = serde_yaml::to_string(&cache).map_err(|e| format!("YAML serialize: {e}"))?;
|
||||
std::fs::write(&path, &yaml_str).map_err(|e| format!("Write cache {}: {e}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rule_matches_exact() {
|
||||
let rule = DmlCleanRule {
|
||||
name: "test".to_string(),
|
||||
source_type: "BIGINT".to_string(),
|
||||
target_type: "INT".to_string(),
|
||||
max_fidelity: 0.8,
|
||||
pre_transform_sql: Some("UPDATE {table} SET {column} = NULL".to_string()),
|
||||
description: None,
|
||||
};
|
||||
assert!(rule.matches("BIGINT", "INT"));
|
||||
assert!(!rule.matches("INT", "BIGINT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_matches_normalized() {
|
||||
let rule = DmlCleanRule {
|
||||
name: "test".to_string(),
|
||||
source_type: "BIGINT".to_string(),
|
||||
target_type: "INT".to_string(),
|
||||
max_fidelity: 0.8,
|
||||
pre_transform_sql: None,
|
||||
description: None,
|
||||
};
|
||||
assert!(rule.matches("BIGINT(20)", "INT"));
|
||||
assert!(rule.matches("bigint", "int"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_transform_sql() {
|
||||
let rule = DmlCleanRule {
|
||||
name: "test".to_string(),
|
||||
source_type: "BIGINT".to_string(),
|
||||
target_type: "INT".to_string(),
|
||||
max_fidelity: 0.8,
|
||||
pre_transform_sql: Some("UPDATE {table} SET {column} = NULL WHERE {column} > 2147483647".to_string()),
|
||||
description: None,
|
||||
};
|
||||
let sql = rule.transform_sql("users", "age").unwrap();
|
||||
assert_eq!(sql, "UPDATE users SET age = NULL WHERE age > 2147483647");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binding_engine_high_fidelity_no_dml() {
|
||||
let result = BindingEngine::bind("INT", "INTEGER", "t", "c", 0.7);
|
||||
assert!(result.pre_transform_sql.is_none());
|
||||
assert!((result.fidelity - 0.9).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binding_engine_low_fidelity_with_dml() {
|
||||
let _registry = DmlCleanRuleRegistry::new();
|
||||
if let Ok(mut reg) = DmlCleanRuleRegistry::global().write() {
|
||||
reg.rules = vec![DmlCleanRule {
|
||||
name: "bigint_to_int".to_string(),
|
||||
source_type: "BIGINT".to_string(),
|
||||
target_type: "INT".to_string(),
|
||||
max_fidelity: 0.9,
|
||||
pre_transform_sql: Some("UPDATE {table} SET {column} = NULL WHERE {column} > 2147483647".to_string()),
|
||||
description: None,
|
||||
}];
|
||||
}
|
||||
|
||||
let result = BindingEngine::bind("BIGINT", "INT", "users", "id", 0.4);
|
||||
assert!(result.pre_transform_sql.is_some());
|
||||
assert!(result.pre_transform_sql.unwrap().contains("2147483647"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_max_fidelity_boundary_is_exclusive() {
|
||||
let rule = DmlCleanRule {
|
||||
name: "boundary".to_string(),
|
||||
source_type: "BIGINT".to_string(),
|
||||
target_type: "INT".to_string(),
|
||||
max_fidelity: 0.5,
|
||||
pre_transform_sql: Some("SELECT 1".to_string()),
|
||||
description: None,
|
||||
};
|
||||
|
||||
assert!(rule.applies("BIGINT", "INT", 0.49));
|
||||
assert!(!rule.applies("BIGINT", "INT", 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_rules_honor_configured_max_fidelity() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/mappings/rules/dml_clean_rules.yaml");
|
||||
let rules = DmlCleanRuleRegistry::load(&path).unwrap();
|
||||
let rule = rules.iter().find(|rule| rule.matches("BIGINT", "INT")).unwrap();
|
||||
|
||||
assert!(rule.applies("BIGINT", "INT", rule.max_fidelity - f64::EPSILON));
|
||||
assert!(!rule.applies("BIGINT", "INT", rule.max_fidelity));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binding_engine_no_rule_found() {
|
||||
let result = BindingEngine::bind("GEOGRAPHY", "INT", "t", "c", 0.5);
|
||||
assert!(result.pre_transform_sql.is_none());
|
||||
assert!((result.fidelity - 0.3).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_cache_path() {
|
||||
let path = MappingCacheFile::cache_path(DialectKind::Mysql, DialectKind::Postgres);
|
||||
assert!(path.to_string_lossy().contains("mysql_to_postgres"));
|
||||
let custom = MappingCacheFile::custom_path(DialectKind::Mysql, DialectKind::Postgres, "prod");
|
||||
assert!(custom.to_string_lossy().contains("prod"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
//! 「预览变更」SQL 生成:把 SQL 编辑器里的一条 DML(UPDATE / INSERT / DELETE)
|
||||
//! 改写成一条只读 SELECT,用于在执行前干跑预览受影响的数据。
|
||||
//!
|
||||
//! 思路:不手工重建 sqlparser AST,而是复用 sqlparser 对表达式 / 表 / WHERE 的
|
||||
//! 方言正确渲染(`Display` round-trip),只在外层做字符串装配:
|
||||
//! UPDATE t SET a = 123 WHERE id = 1
|
||||
//! → SELECT *, 123 AS "a (new)" FROM t WHERE id = 1
|
||||
//! `SELECT *` 展开受影响行全部当前值,新值列(别名后缀 ` (new)`)追加在末尾;
|
||||
//! SET 表达式直接搬运(在行上下文中按旧值求值即得新值,`SET a = a + 1` 也能算对)。
|
||||
//! 注意:SET / VALUES 表达式会在预览 SELECT 中重新求值,带副作用的表达式
|
||||
//! (推进序列、volatile 函数)在预览中同样生效,非确定性函数的预览值可能与实际执行不同。
|
||||
//!
|
||||
//! 无法安全改写的语句返回 Err(错误信息为英文,前端直接以 toast 展示,仍可正常执行原语句)。
|
||||
|
||||
use sqlparser::ast::{AssignmentTarget, Expr, Ident, ObjectNamePart, SetExpr, Statement, TableWithJoins};
|
||||
use sqlparser::dialect::{
|
||||
ClickHouseDialect, DuckDbDialect, GenericDialect, MsSqlDialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect,
|
||||
SparkSqlDialect,
|
||||
};
|
||||
use sqlparser::parser::Parser;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DmlChangePreviewSqlOptions {
|
||||
/// 用户写的一条 DML 语句(通常是当前语句)。
|
||||
pub sql: String,
|
||||
/// 连接类型(决定解析方言 / 默认标识符引号)。
|
||||
pub database_type: Option<String>,
|
||||
/// 连接的标识符引号字符(如 `"`、`` ` ``、`[`),None 时按方言回退。
|
||||
pub identifier_quote: Option<String>,
|
||||
/// 目标表的列(按 ordinal 顺序)。提供且为单表 UPDATE 时,新值列紧跟其原值列。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub columns: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DmlChangePreviewTableRef {
|
||||
pub catalog: Option<String>,
|
||||
pub database: Option<String>,
|
||||
pub schema: Option<String>,
|
||||
pub table: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DmlChangePreviewSqlResult {
|
||||
/// 生成的只读 SELECT。
|
||||
pub sql: String,
|
||||
/// 语句类型:update / insert / delete。
|
||||
pub operation: String,
|
||||
/// 是否有「新值」列(仅 UPDATE 有,INSERT 的列本身即新值)。
|
||||
pub has_new_value_columns: bool,
|
||||
/// 语句目标表引用(供前端拉取列元数据以交错展示新值列)。
|
||||
pub tables: Vec<DmlChangePreviewTableRef>,
|
||||
}
|
||||
|
||||
fn normalize_dialect(database_type: Option<&str>) -> &'static str {
|
||||
match database_type.unwrap_or("generic").to_ascii_lowercase().as_str() {
|
||||
"postgres" | "postgresql" | "redshift" | "opengauss" | "gaussdb" | "highgo" | "uxdb" | "questdb" => "postgres",
|
||||
"mysql" | "mariadb" | "doris" | "starrocks" | "manticoresearch" | "oceanbase" | "tidb" => "mysql",
|
||||
"sqlite" => "sqlite",
|
||||
"sqlserver" | "mssql" => "sqlserver",
|
||||
"clickhouse" => "clickhouse",
|
||||
"duckdb" => "duckdb",
|
||||
"spark" | "sparksql" => "spark",
|
||||
_ => "generic",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_statements(sql: &str, dialect_key: &str) -> Result<Vec<Statement>, String> {
|
||||
match dialect_key {
|
||||
"postgres" => Parser::parse_sql(&PostgreSqlDialect {}, sql),
|
||||
"mysql" => Parser::parse_sql(&MySqlDialect {}, sql),
|
||||
"sqlite" => Parser::parse_sql(&SQLiteDialect {}, sql),
|
||||
"sqlserver" => Parser::parse_sql(&MsSqlDialect {}, sql),
|
||||
"clickhouse" => Parser::parse_sql(&ClickHouseDialect {}, sql),
|
||||
"duckdb" => Parser::parse_sql(&DuckDbDialect {}, sql),
|
||||
"spark" => Parser::parse_sql(&SparkSqlDialect {}, sql),
|
||||
_ => Parser::parse_sql(&GenericDialect {}, sql),
|
||||
}
|
||||
.map_err(|error| format!("Failed to parse the statement: {error}"))
|
||||
}
|
||||
|
||||
fn quote_identifier(name: &str, quote: Option<&str>, dialect_key: &str) -> String {
|
||||
if let Some(value) = quote {
|
||||
if value == "`" || value == "\"" || value == "'" {
|
||||
return format!("{value}{name}{value}");
|
||||
}
|
||||
if value == "[" {
|
||||
return format!("[{name}]");
|
||||
}
|
||||
}
|
||||
match dialect_key {
|
||||
"mysql" => format!("`{name}`"),
|
||||
_ => format!("\"{name}\""),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从赋值目标里取出列名(`t.a` → `a`)。
|
||||
fn assignment_target_column(target: &AssignmentTarget) -> Option<&Ident> {
|
||||
match target {
|
||||
AssignmentTarget::ColumnName(object_name) => object_name.0.last().and_then(ObjectNamePart::as_ident),
|
||||
AssignmentTarget::Tuple(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported(reason: &str) -> String {
|
||||
format!("Preview is not supported: {reason}")
|
||||
}
|
||||
|
||||
/// UPDATE → SELECT <列交错展开> FROM … WHERE …
|
||||
///
|
||||
/// 提供目标表列清单且为单表时,按「原列, 原列 (new)」逐列交错展开,使新值列紧跟
|
||||
/// 其原值列;否则退化为 `SELECT *, <SET 表达式> AS "列 (new)", …`(追加在末尾)。
|
||||
fn build_update_preview(
|
||||
update: &sqlparser::ast::Update,
|
||||
dialect_key: &str,
|
||||
quote: Option<&str>,
|
||||
columns: Option<&[String]>,
|
||||
) -> Result<String, String> {
|
||||
// 收集 SET 新值表达式:同列重复赋值取最后一次,但保持首次出现的列序。
|
||||
#[derive(Clone)]
|
||||
struct SetItem {
|
||||
column: String,
|
||||
expr: String,
|
||||
}
|
||||
let mut set_items: Vec<SetItem> = Vec::new();
|
||||
let mut set_order: Vec<String> = Vec::new(); // 小写列名 → 顺序
|
||||
for assignment in &update.assignments {
|
||||
let Some(column) = assignment_target_column(&assignment.target) else {
|
||||
return Err(unsupported("UPDATE assignment target is not a single column"));
|
||||
};
|
||||
let column_name = column.value.clone();
|
||||
let key = column_name.to_lowercase();
|
||||
if let Some(position) = set_order.iter().position(|existing| *existing == key) {
|
||||
set_order.remove(position);
|
||||
set_items.remove(position);
|
||||
}
|
||||
set_order.push(key);
|
||||
set_items.push(SetItem { column: column_name, expr: assignment.value.to_string() });
|
||||
}
|
||||
|
||||
// Postgres `UPDATE … FROM …`:同一目标行可能匹配多行来源,真实 UPDATE 只更新一次,
|
||||
// 而改写出的 SELECT 会把该行显示多次(跨连接重复计数),直接拒绝预览。
|
||||
if let Some(from_kind) = &update.from {
|
||||
let extra_count = match from_kind {
|
||||
sqlparser::ast::UpdateTableFromKind::BeforeSet(list)
|
||||
| sqlparser::ast::UpdateTableFromKind::AfterSet(list) => list.len(),
|
||||
};
|
||||
if extra_count > 0 {
|
||||
return Err(unsupported("UPDATE ... FROM"));
|
||||
}
|
||||
}
|
||||
let from_sql = update.table.to_string();
|
||||
|
||||
let render_new_value = |item: &SetItem| -> String {
|
||||
format!("{} AS {}", item.expr, quote_identifier(&format!("{} (new)", item.column), quote, dialect_key))
|
||||
};
|
||||
let append_items: Vec<String> = set_items.iter().map(render_new_value).collect();
|
||||
|
||||
// 单表 + 提供了列清单 → 交错展开(新值列紧跟原值列)。
|
||||
let single_table = update.table.joins.is_empty() && update.from.is_none();
|
||||
let select_items = if single_table {
|
||||
match columns {
|
||||
Some(column_list) if !column_list.is_empty() => {
|
||||
let mut projection: Vec<String> = Vec::new();
|
||||
let mut placed: Vec<String> = Vec::new();
|
||||
for column in column_list {
|
||||
projection.push(quote_identifier(column, quote, dialect_key));
|
||||
let key = column.to_lowercase();
|
||||
if let Some(position) = set_order.iter().position(|existing| *existing == key) {
|
||||
projection.push(render_new_value(&set_items[position]));
|
||||
placed.push(key);
|
||||
}
|
||||
}
|
||||
// SET 列未在列清单中(大小写差异 / 虚拟列等)追加到末尾,避免遗漏。
|
||||
for (index, key) in set_order.iter().enumerate() {
|
||||
if !placed.contains(key) {
|
||||
projection.push(render_new_value(&set_items[index]));
|
||||
}
|
||||
}
|
||||
projection.join(", ")
|
||||
}
|
||||
_ => {
|
||||
if append_items.is_empty() {
|
||||
"*".to_string()
|
||||
} else {
|
||||
format!("*, {}", append_items.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if append_items.is_empty() {
|
||||
"*".to_string()
|
||||
} else {
|
||||
format!("*, {}", append_items.join(", "))
|
||||
};
|
||||
|
||||
let mut sql = format!("SELECT {select_items} FROM {from_sql}");
|
||||
if let Some(selection) = &update.selection {
|
||||
sql.push_str(&format!(" WHERE {selection}"));
|
||||
}
|
||||
if !update.order_by.is_empty() {
|
||||
let order_by = update.order_by.iter().map(|item| item.to_string()).collect::<Vec<_>>().join(", ");
|
||||
sql.push_str(&format!(" ORDER BY {order_by}"));
|
||||
}
|
||||
if let Some(limit) = &update.limit {
|
||||
sql.push_str(&format!(" LIMIT {limit}"));
|
||||
}
|
||||
Ok(sql)
|
||||
}
|
||||
|
||||
/// INSERT … VALUES → SELECT 值行 UNION ALL …(列名来自插入列,直观展示即将插入的行)。
|
||||
fn build_insert_preview(
|
||||
insert: &sqlparser::ast::Insert,
|
||||
dialect_key: &str,
|
||||
quote: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
// MySQL `INSERT INTO t SET a=1, b=2`
|
||||
if !insert.assignments.is_empty() {
|
||||
let items: Vec<String> = insert
|
||||
.assignments
|
||||
.iter()
|
||||
.map(|assignment| {
|
||||
let col = assignment_target_column(&assignment.target)
|
||||
.ok_or_else(|| unsupported("INSERT ... SET assignment target is not a single column"))?;
|
||||
Ok(format!("{} AS {}", assignment.value, quote_identifier(&col.value, quote, dialect_key)))
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
if items.is_empty() {
|
||||
return Err(unsupported("INSERT ... SET has no assignments"));
|
||||
}
|
||||
return Ok(format!("SELECT {}", items.join(", ")));
|
||||
}
|
||||
|
||||
let Some(source) = &insert.source else {
|
||||
return Err(unsupported("INSERT has no source"));
|
||||
};
|
||||
|
||||
match source.body.as_ref() {
|
||||
SetExpr::Values(values) => {
|
||||
let column_names: Vec<String> = insert
|
||||
.columns
|
||||
.iter()
|
||||
.filter_map(|column| {
|
||||
column.0.last().and_then(ObjectNamePart::as_ident).map(|ident| ident.value.clone())
|
||||
})
|
||||
.collect();
|
||||
if !column_names.is_empty() && values.rows.iter().any(|row| row.content.len() != column_names.len()) {
|
||||
return Err(unsupported("INSERT VALUES row length does not match the column list"));
|
||||
}
|
||||
let render_row = |row_exprs: &[Expr]| -> String {
|
||||
let cells: Vec<String> = row_exprs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, expr)| {
|
||||
let expr_text = expr.to_string();
|
||||
if column_names.is_empty() {
|
||||
expr_text
|
||||
} else {
|
||||
format!("{expr_text} AS {}", quote_identifier(&column_names[index], quote, dialect_key))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
format!("SELECT {}", cells.join(", "))
|
||||
};
|
||||
let rows = values.rows.iter().collect::<Vec<_>>();
|
||||
let Some(first) = rows.first() else {
|
||||
return Err(unsupported("INSERT VALUES is empty"));
|
||||
};
|
||||
let mut sql = render_row(&first.content);
|
||||
for row in rows.iter().skip(1) {
|
||||
sql.push_str(" UNION ALL ");
|
||||
sql.push_str(&render_row(&row.content));
|
||||
}
|
||||
Ok(sql)
|
||||
}
|
||||
_ => {
|
||||
// INSERT … SELECT:预览 = 源查询(即将会插入的行)。
|
||||
Ok(source.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE → SELECT * FROM … WHERE …(标明将删除的行;无「新值」列)。
|
||||
fn build_delete_preview(
|
||||
delete: &sqlparser::ast::Delete,
|
||||
_dialect_key: &str,
|
||||
_quote: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let from_sql = match &delete.from {
|
||||
sqlparser::ast::FromTable::WithFromKeyword(tables) | sqlparser::ast::FromTable::WithoutKeyword(tables) => {
|
||||
tables.iter().map(|table: &TableWithJoins| table.to_string()).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
};
|
||||
if from_sql.is_empty() {
|
||||
return Err(unsupported("DELETE has no FROM"));
|
||||
}
|
||||
let mut sql = format!("SELECT * FROM {from_sql}");
|
||||
if let Some(using) = &delete.using {
|
||||
if !using.is_empty() {
|
||||
let using_sql = using.iter().map(|table| table.to_string()).collect::<Vec<_>>().join(", ");
|
||||
sql.push_str(&format!(", {using_sql}"));
|
||||
}
|
||||
}
|
||||
if let Some(selection) = &delete.selection {
|
||||
sql.push_str(&format!(" WHERE {selection}"));
|
||||
}
|
||||
if !delete.order_by.is_empty() {
|
||||
let order_by = delete.order_by.iter().map(|item| item.to_string()).collect::<Vec<_>>().join(", ");
|
||||
sql.push_str(&format!(" ORDER BY {order_by}"));
|
||||
}
|
||||
if let Some(limit) = &delete.limit {
|
||||
sql.push_str(&format!(" LIMIT {limit}"));
|
||||
}
|
||||
Ok(sql)
|
||||
}
|
||||
|
||||
pub fn build_dml_change_preview_sql(options: DmlChangePreviewSqlOptions) -> Result<DmlChangePreviewSqlResult, String> {
|
||||
let dialect_key = normalize_dialect(options.database_type.as_deref());
|
||||
let statements = parse_statements(&options.sql, dialect_key)?;
|
||||
let statement = match statements.len() {
|
||||
0 => return Err(unsupported("no statement to preview")),
|
||||
1 => &statements[0],
|
||||
_ => return Err(unsupported("only one statement can be previewed at a time")),
|
||||
};
|
||||
let quote = options.identifier_quote.as_deref();
|
||||
let columns = options.columns.as_deref();
|
||||
match statement {
|
||||
Statement::Update(update) => Ok(DmlChangePreviewSqlResult {
|
||||
sql: build_update_preview(update, dialect_key, quote, columns)?,
|
||||
operation: "update".to_string(),
|
||||
has_new_value_columns: true,
|
||||
tables: vec![update_primary_table_ref(update)],
|
||||
}),
|
||||
Statement::Insert(insert) => Ok(DmlChangePreviewSqlResult {
|
||||
sql: build_insert_preview(insert, dialect_key, quote)?,
|
||||
operation: "insert".to_string(),
|
||||
has_new_value_columns: true,
|
||||
tables: vec![insert_table_ref(insert)],
|
||||
}),
|
||||
Statement::Delete(delete) => Ok(DmlChangePreviewSqlResult {
|
||||
sql: build_delete_preview(delete, dialect_key, quote)?,
|
||||
operation: "delete".to_string(),
|
||||
has_new_value_columns: false,
|
||||
tables: vec![delete_table_ref(delete)],
|
||||
}),
|
||||
_ => Err(unsupported("only UPDATE / INSERT / DELETE statements can be previewed")),
|
||||
}
|
||||
}
|
||||
|
||||
fn object_name_table_ref(name: &sqlparser::ast::ObjectName) -> DmlChangePreviewTableRef {
|
||||
let names: Vec<String> =
|
||||
name.0.iter().filter_map(|part| part.as_ident().map(|ident| ident.value.clone())).collect();
|
||||
match names.len() {
|
||||
4 => DmlChangePreviewTableRef {
|
||||
catalog: Some(names[0].clone()),
|
||||
database: Some(names[1].clone()),
|
||||
schema: Some(names[2].clone()),
|
||||
table: Some(names[3].clone()),
|
||||
},
|
||||
3 => DmlChangePreviewTableRef {
|
||||
catalog: None,
|
||||
database: Some(names[0].clone()),
|
||||
schema: Some(names[1].clone()),
|
||||
table: Some(names[2].clone()),
|
||||
},
|
||||
2 => DmlChangePreviewTableRef {
|
||||
catalog: None,
|
||||
database: None,
|
||||
schema: Some(names[0].clone()),
|
||||
table: Some(names[1].clone()),
|
||||
},
|
||||
1 => DmlChangePreviewTableRef { catalog: None, database: None, schema: None, table: names.into_iter().next() },
|
||||
_ => DmlChangePreviewTableRef { catalog: None, database: None, schema: None, table: None },
|
||||
}
|
||||
}
|
||||
|
||||
fn update_primary_table_ref(update: &sqlparser::ast::Update) -> DmlChangePreviewTableRef {
|
||||
match &update.table.relation {
|
||||
sqlparser::ast::TableFactor::Table { name, .. } => object_name_table_ref(name),
|
||||
_ => DmlChangePreviewTableRef { catalog: None, database: None, schema: None, table: None },
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_table_ref(insert: &sqlparser::ast::Insert) -> DmlChangePreviewTableRef {
|
||||
match &insert.table {
|
||||
sqlparser::ast::TableObject::TableName(name) => object_name_table_ref(name),
|
||||
_ => DmlChangePreviewTableRef { catalog: None, database: None, schema: None, table: None },
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_table_ref(delete: &sqlparser::ast::Delete) -> DmlChangePreviewTableRef {
|
||||
if let Some(first) = delete.tables.first() {
|
||||
return object_name_table_ref(first);
|
||||
}
|
||||
let tables = match &delete.from {
|
||||
sqlparser::ast::FromTable::WithFromKeyword(tables) | sqlparser::ast::FromTable::WithoutKeyword(tables) => {
|
||||
tables
|
||||
}
|
||||
};
|
||||
match tables.first().and_then(|table| match &table.relation {
|
||||
sqlparser::ast::TableFactor::Table { name, .. } => Some(name),
|
||||
_ => None,
|
||||
}) {
|
||||
Some(name) => object_name_table_ref(name),
|
||||
None => DmlChangePreviewTableRef { catalog: None, database: None, schema: None, table: None },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn preview(sql: &str, database_type: &str) -> Result<DmlChangePreviewSqlResult, String> {
|
||||
build_dml_change_preview_sql(DmlChangePreviewSqlOptions {
|
||||
sql: sql.to_string(),
|
||||
database_type: Some(database_type.to_string()),
|
||||
identifier_quote: None,
|
||||
columns: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn preview_with_columns(
|
||||
sql: &str,
|
||||
database_type: &str,
|
||||
columns: Vec<String>,
|
||||
) -> Result<DmlChangePreviewSqlResult, String> {
|
||||
build_dml_change_preview_sql(DmlChangePreviewSqlOptions {
|
||||
sql: sql.to_string(),
|
||||
database_type: Some(database_type.to_string()),
|
||||
identifier_quote: None,
|
||||
columns: Some(columns),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_simple_single_table() {
|
||||
let result = preview("UPDATE admin_sessions SET public_id = 123 WHERE id = 1", "mysql").unwrap();
|
||||
assert_eq!(result.operation, "update");
|
||||
assert_eq!(result.sql, "SELECT *, 123 AS `public_id (new)` FROM admin_sessions WHERE id = 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_multiple_assignments_and_expression() {
|
||||
let result = preview("UPDATE t SET a = 1, b = a + 1", "postgres").unwrap();
|
||||
assert_eq!(result.sql, "SELECT *, 1 AS \"a (new)\", a + 1 AS \"b (new)\" FROM t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_deduplicates_same_column_last_wins() {
|
||||
let result = preview("UPDATE t SET x = 1, x = 2", "postgres").unwrap();
|
||||
assert_eq!(result.sql, "SELECT *, 2 AS \"x (new)\" FROM t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_with_alias_and_join() {
|
||||
let result = preview("UPDATE a JOIN b ON a.id = b.id SET a.x = b.x WHERE a.id = 1", "mysql").unwrap();
|
||||
assert_eq!(result.sql, "SELECT *, b.x AS `x (new)` FROM a JOIN b ON a.id = b.id WHERE a.id = 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_postgres_from_clause_rejected() {
|
||||
// `UPDATE … FROM …` 的目标行可能匹配多行来源,跨连接改写会重复计数,应拒绝预览。
|
||||
let error = preview("UPDATE t SET x = s.y FROM s WHERE t.id = s.id", "postgres").unwrap_err();
|
||||
assert_eq!(error, "Preview is not supported: UPDATE ... FROM");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_where_missing() {
|
||||
let result = preview("UPDATE t SET x = 1", "sqlite").unwrap();
|
||||
assert_eq!(result.sql, "SELECT *, 1 AS \"x (new)\" FROM t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_values_with_columns() {
|
||||
let result = preview("INSERT INTO t (a, b) VALUES (1, 'x'), (2, 'y')", "postgres").unwrap();
|
||||
assert_eq!(result.sql, "SELECT 1 AS \"a\", 'x' AS \"b\" UNION ALL SELECT 2 AS \"a\", 'y' AS \"b\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_select_source() {
|
||||
let result = preview("INSERT INTO t SELECT id, name FROM src WHERE active", "postgres").unwrap();
|
||||
assert_eq!(result.sql, "SELECT id, name FROM src WHERE active");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_set_mysql() {
|
||||
let result = preview("INSERT INTO t SET a = 1, b = 'x'", "mysql").unwrap();
|
||||
assert_eq!(result.sql, "SELECT 1 AS `a`, 'x' AS `b`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_simple() {
|
||||
let result = preview("DELETE FROM t WHERE id = 1", "postgres").unwrap();
|
||||
assert_eq!(result.operation, "delete");
|
||||
assert_eq!(result.sql, "SELECT * FROM t WHERE id = 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_mysql_multi_table() {
|
||||
let result = preview("DELETE d FROM d JOIN logs l ON d.id = l.id WHERE l.ts < '2020-01-01'", "mysql").unwrap();
|
||||
assert_eq!(result.sql, "SELECT * FROM d JOIN logs l ON d.id = l.id WHERE l.ts < '2020-01-01'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_multiple_statements() {
|
||||
assert!(preview("UPDATE t SET a = 1; UPDATE t2 SET b = 2", "postgres").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_dml() {
|
||||
assert!(preview("SELECT * FROM t", "postgres").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_identifier_quote() {
|
||||
let result = build_dml_change_preview_sql(DmlChangePreviewSqlOptions {
|
||||
sql: "UPDATE t SET a = 1 WHERE id = 1".to_string(),
|
||||
database_type: Some("postgres".to_string()),
|
||||
identifier_quote: Some("\"".to_string()),
|
||||
columns: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result.sql, "SELECT *, 1 AS \"a (new)\" FROM t WHERE id = 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_sqlserver_bracket_quote() {
|
||||
let result = build_dml_change_preview_sql(DmlChangePreviewSqlOptions {
|
||||
sql: "UPDATE dbo.t SET a = 1 WHERE id = 1".to_string(),
|
||||
database_type: Some("sqlserver".to_string()),
|
||||
identifier_quote: Some("[".to_string()),
|
||||
columns: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result.sql, "SELECT *, 1 AS [a (new)] FROM dbo.t WHERE id = 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_interleaves_new_value_after_its_original_column() {
|
||||
let result = preview_with_columns(
|
||||
"UPDATE admin_sessions SET public_id = 123 WHERE id = 1",
|
||||
"mysql",
|
||||
vec!["id".to_string(), "public_id".to_string(), "session".to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.sql,
|
||||
"SELECT `id`, `public_id`, 123 AS `public_id (new)`, `session` FROM admin_sessions WHERE id = 1"
|
||||
);
|
||||
assert_eq!(result.tables.len(), 1);
|
||||
assert_eq!(result.tables[0].table.as_deref(), Some("admin_sessions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_interleave_multiple_assignments_keeps_order() {
|
||||
let result = preview_with_columns(
|
||||
"UPDATE t SET b = 2, a = 1",
|
||||
"postgres",
|
||||
vec!["a".to_string(), "b".to_string(), "c".to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.sql, "SELECT \"a\", 1 AS \"a (new)\", \"b\", 2 AS \"b (new)\", \"c\" FROM t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_interleave_missing_set_column_appends_at_end() {
|
||||
let result =
|
||||
preview_with_columns("UPDATE t SET z = 9", "postgres", vec!["a".to_string(), "b".to_string()]).unwrap();
|
||||
assert_eq!(result.sql, "SELECT \"a\", \"b\", 9 AS \"z (new)\" FROM t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_multi_table_ignores_columns_for_append_mode() {
|
||||
let result = preview_with_columns(
|
||||
"UPDATE a JOIN b ON a.id = b.id SET a.x = b.x WHERE a.id = 1",
|
||||
"mysql",
|
||||
vec!["a_x".to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.sql, "SELECT *, b.x AS `x (new)` FROM a JOIN b ON a.id = b.id WHERE a.id = 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_refs_extracted_for_dml() {
|
||||
let update = preview("UPDATE db.public.t SET a = 1", "postgres").unwrap();
|
||||
assert_eq!(update.tables.len(), 1);
|
||||
assert_eq!(update.tables[0].database.as_deref(), Some("db"));
|
||||
assert_eq!(update.tables[0].schema.as_deref(), Some("public"));
|
||||
assert_eq!(update.tables[0].table.as_deref(), Some("t"));
|
||||
|
||||
let insert = preview("INSERT INTO orders (id) VALUES (1)", "postgres").unwrap();
|
||||
assert_eq!(insert.tables[0].table.as_deref(), Some("orders"));
|
||||
|
||||
let delete = preview("DELETE FROM t WHERE id = 1", "postgres").unwrap();
|
||||
assert_eq!(delete.tables[0].table.as_deref(), Some("t"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
pub use dbx_types::{database_manifest, models, types};
|
||||
|
||||
pub mod data_grid_extractors;
|
||||
pub mod data_grid_sql;
|
||||
pub mod database_search_sql;
|
||||
pub mod db_admin_sql;
|
||||
pub mod dml_binding;
|
||||
pub mod dml_preview_sql;
|
||||
pub mod mysql_ddl_normalize;
|
||||
pub mod mysql_event_sql;
|
||||
pub mod object_source_sql;
|
||||
pub mod query_execution_sql;
|
||||
pub mod query_result_sql;
|
||||
pub mod schema_diff;
|
||||
pub mod sql;
|
||||
pub mod sql_analysis;
|
||||
pub mod sql_diagnostics;
|
||||
pub mod sql_dialect;
|
||||
pub mod sql_editability;
|
||||
pub mod sql_error_position;
|
||||
pub mod sql_parser;
|
||||
pub mod sql_risk;
|
||||
pub mod sqlserver_temporal;
|
||||
pub mod table_structure_sql;
|
||||
pub mod tdsql_mysql;
|
||||
pub mod value_literals;
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Post-processing of MySQL `SHOW CREATE TABLE` DDL for database export.
|
||||
//!
|
||||
//! Two transforms, both applied to the table-options tail (the text after the
|
||||
//! column list's closing `)`):
|
||||
//! - **opt-in** strip of the table-level `AUTO_INCREMENT=N` clause. The value `N`
|
||||
//! is the source table's current sequence position — correct for the source
|
||||
//! DB, but noise (and misleading) in a fresh-install script.
|
||||
//! - **always-on** rewrite of legacy `ROW_FORMAT=COMPACT|REDUNDANT` to
|
||||
//! `ROW_FORMAT=DYNAMIC`, for MySQL 8+ export compatibility.
|
||||
//!
|
||||
//! The options tail is parsed with [`winnow`] into structured `(key, value, span)`
|
||||
//! records. Edits are applied to the *original* string by byte span, so every
|
||||
//! byte that is not an edited option is preserved verbatim (parse-to-locate,
|
||||
//! not re-serialize — no fidelity drift). Any parse failure **fails open**: the
|
||||
//! original DDL is returned unchanged, because the correctness of exported DDL
|
||||
//! matters more than this cosmetic option.
|
||||
//!
|
||||
//! Why winnow and not a regex: the value of `AUTO_INCREMENT`/`COMMENT` options
|
||||
//! can contain the literal text `AUTO_INCREMENT=N` inside a quoted string (e.g.
|
||||
//! `COMMENT='starts at AUTO_INCREMENT=1000'`). A quote-aware parser skips those
|
||||
//! correctly; a blind regex cannot. winnow 1.x is already in the build graph
|
||||
//! (transitively, via `toml_edit`), so adding it as a direct dependency compiles
|
||||
//! no new code.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use winnow::ascii::multispace1;
|
||||
use winnow::combinator::{alt, delimited, opt, preceded, repeat};
|
||||
use winnow::prelude::*;
|
||||
use winnow::stream::LocatingSlice;
|
||||
use winnow::token::{none_of, take_till, take_until, take_while};
|
||||
use winnow::ModalResult;
|
||||
|
||||
/// Knobs for [`normalize_mysql_export_ddl`].
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct DdlNormalizeOptions {
|
||||
/// When true, drop the table-level `AUTO_INCREMENT=N` option from the DDL.
|
||||
pub omit_auto_increment: bool,
|
||||
}
|
||||
|
||||
/// One parsed MySQL table option, e.g. `ENGINE=InnoDB`, `AUTO_INCREMENT=121`,
|
||||
/// `COMMENT='...'`, or a bare keyword such as `DEFAULT`.
|
||||
#[derive(Debug, Clone)]
|
||||
struct ParsedOption {
|
||||
/// Byte span of the option *core* (`KEY[=VALUE]`, excluding surrounding
|
||||
/// whitespace), relative to the options tail.
|
||||
span: Range<usize>,
|
||||
/// Upper-cased key, for case-insensitive matching.
|
||||
key: String,
|
||||
/// Raw value (without surrounding quotes), if the option had `= value`.
|
||||
value: Option<String>,
|
||||
}
|
||||
|
||||
const AUTO_INCREMENT: &str = "AUTO_INCREMENT";
|
||||
const ROW_FORMAT: &str = "ROW_FORMAT";
|
||||
|
||||
/// Entry point. Returns the (possibly edited) DDL. On any structural surprise
|
||||
/// the input is returned unchanged.
|
||||
pub fn normalize_mysql_export_ddl(ddl: &str, opts: DdlNormalizeOptions) -> String {
|
||||
// One winnow pass over the whole DDL (wrapped in `LocatingSlice`), so every
|
||||
// option carries an absolute byte span into `ddl` — no offset math.
|
||||
let Some(options) = parse_create_table_options(ddl) else {
|
||||
return ddl.to_string(); // fail-open: unparseable DDL returned verbatim
|
||||
};
|
||||
|
||||
// Compute edits as absolute byte ranges in `ddl`. An empty replacement means
|
||||
// "delete this range".
|
||||
let bytes = ddl.as_bytes();
|
||||
let mut edits: Vec<(Range<usize>, &'static str)> = Vec::new();
|
||||
for opt in &options {
|
||||
let Range { start, end } = opt.span;
|
||||
if opts.omit_auto_increment && opt.key == AUTO_INCREMENT {
|
||||
// Preserve line endings because they may terminate a preceding line comment.
|
||||
let start = if start > 0 && matches!(bytes[start - 1], b' ' | b'\t') { start - 1 } else { start };
|
||||
edits.push((start..end, ""));
|
||||
} else if opt.key == ROW_FORMAT {
|
||||
if let Some(value) = opt.value.as_deref() {
|
||||
let value = value.to_ascii_uppercase();
|
||||
if value == "COMPACT" || value == "REDUNDANT" {
|
||||
edits.push((start..end, "ROW_FORMAT=DYNAMIC"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if edits.is_empty() {
|
||||
return ddl.to_string();
|
||||
}
|
||||
|
||||
// Apply edits to the original string. Distinct options never overlap; sort
|
||||
// by start and stitch the kept slices around each edit.
|
||||
edits.sort_by_key(|(range, _)| range.start);
|
||||
let mut out = String::with_capacity(ddl.len());
|
||||
let mut cursor = 0usize;
|
||||
for (range, replacement) in &edits {
|
||||
if range.start < cursor {
|
||||
continue; // defensive: never expected for distinct options
|
||||
}
|
||||
out.push_str(&ddl[cursor..range.start]);
|
||||
out.push_str(replacement);
|
||||
cursor = range.end;
|
||||
}
|
||||
out.push_str(&ddl[cursor..]);
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// winnow parser.
|
||||
//
|
||||
// One pass over the whole DDL (wrapped in `LocatingSlice`), so every parsed
|
||||
// option carries an absolute byte span into `ddl`. The column-definition body
|
||||
// is skipped with a recursive balanced-`(...)` parser — it never inspects
|
||||
// column content, only tracks nesting and quoted spans so a `(` inside a type
|
||||
// like `decimal(30,6)` or inside a backtick-quoted table name doesn't fool it.
|
||||
// Any parse failure fails open (the caller returns the original DDL).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse `CREATE TABLE <name> ( <columns> ) <options…>` into the table options,
|
||||
/// each with an absolute byte span. The column body is skipped, not modeled.
|
||||
fn parse_create_table_options(ddl: &str) -> Option<Vec<ParsedOption>> {
|
||||
// `skip_create_table_prefix` + `skip_column_body` position the stream at the
|
||||
// options tail; `preceded` discards them. `parse_peek` allows a trailing `;`
|
||||
// or an unmodeled option shape without failing the whole parse.
|
||||
let mut parser = preceded(
|
||||
(skip_create_table_prefix, skip_column_body),
|
||||
repeat(0.., preceded(skip_ws_and_comments, table_option.with_span())).fold(
|
||||
Vec::new,
|
||||
|mut acc: Vec<ParsedOption>, (mut option, span)| {
|
||||
option.span = span;
|
||||
acc.push(option);
|
||||
acc
|
||||
},
|
||||
),
|
||||
);
|
||||
match parser.parse_peek(LocatingSlice::new(ddl)) {
|
||||
Ok((_remaining, options)) => Some(options),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the `CREATE TABLE [IF NOT EXISTS] <name>` prefix, stopping just before
|
||||
/// the column-list opening `(`. Quoted identifiers and comments are consumed
|
||||
/// whole, so a `(` inside e.g. `` `weird(name)` `` is not mistaken for the
|
||||
/// column-list opener.
|
||||
fn skip_create_table_prefix(input: &mut LocatingSlice<&str>) -> ModalResult<()> {
|
||||
repeat(0.., alt((sql_quoted, sql_line_comment, sql_block_comment, none_of('(').void())))
|
||||
.fold(|| (), |(), _| ())
|
||||
.parse_next(input)
|
||||
}
|
||||
|
||||
/// Consume one balanced `(...)` group (the column body), skipping nested parens,
|
||||
/// quoted strings, and comments. Produces no value.
|
||||
fn skip_column_body(input: &mut LocatingSlice<&str>) -> ModalResult<()> {
|
||||
delimited('(', repeat(0.., column_body_token).fold(|| (), |(), _| ()), ')').parse_next(input)
|
||||
}
|
||||
|
||||
/// One token inside the column body: a nested balanced group, a quoted string,
|
||||
/// a comment, or any single non-`)` character. `skip_column_body` is tried first
|
||||
/// so nested `(` is handled recursively, not as a plain character.
|
||||
fn column_body_token(input: &mut LocatingSlice<&str>) -> ModalResult<()> {
|
||||
alt((skip_column_body, sql_quoted, sql_line_comment, sql_block_comment, none_of(')').void())).parse_next(input)
|
||||
}
|
||||
|
||||
/// A `-- …` or `# …` line comment (to end of line). Produces no value.
|
||||
fn sql_line_comment(input: &mut LocatingSlice<&str>) -> ModalResult<()> {
|
||||
alt((("--", take_till(0.., ['\n', '\r'])).void(), ('#', take_till(0.., ['\n', '\r'])).void())).parse_next(input)
|
||||
}
|
||||
|
||||
/// A `/* … */` block comment (MySQL block comments do not nest). Produces no value.
|
||||
fn sql_block_comment(input: &mut LocatingSlice<&str>) -> ModalResult<()> {
|
||||
("/*", take_until(0.., "*/"), "*/").void().parse_next(input)
|
||||
}
|
||||
|
||||
/// Parses one table option: `KEY`, `KEY=VALUE`, or `KEY = VALUE`.
|
||||
fn table_option(input: &mut LocatingSlice<&str>) -> ModalResult<ParsedOption> {
|
||||
let key = option_key.parse_next(input)?;
|
||||
let value =
|
||||
opt(preceded(delimited(skip_ws_and_comments, '=', skip_ws_and_comments), option_value)).parse_next(input)?;
|
||||
Ok(ParsedOption { span: 0..0, key, value })
|
||||
}
|
||||
|
||||
/// An option key: run of ASCII alphanumerics and `_`, upper-cased for matching.
|
||||
fn option_key(input: &mut LocatingSlice<&str>) -> ModalResult<String> {
|
||||
take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
|
||||
.map(|s: &str| s.to_ascii_uppercase())
|
||||
.parse_next(input)
|
||||
}
|
||||
|
||||
/// An option value: either a quoted SQL token (whose content we do not need —
|
||||
/// only its byte span, to keep the tail parse moving past `COMMENT='...'`) or a
|
||||
/// bare token (identifier/number).
|
||||
fn option_value(input: &mut LocatingSlice<&str>) -> ModalResult<String> {
|
||||
alt((sql_quoted.value(String::new()), bare_value.map(|s: &str| s.to_string()))).parse_next(input)
|
||||
}
|
||||
|
||||
fn bare_value<'i>(input: &mut LocatingSlice<&'i str>) -> ModalResult<&'i str> {
|
||||
take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_').parse_next(input)
|
||||
}
|
||||
|
||||
/// Skip any run of whitespace and/or SQL comments. Used as the separator between
|
||||
/// table options so that a comment sitting between options (or between `)` and
|
||||
/// the first option, or around `=`) does not stop the parse short of later
|
||||
/// options like `AUTO_INCREMENT`. Matches zero or more chunks (so it also acts
|
||||
/// like `multispace0` when there is nothing but whitespace).
|
||||
fn skip_ws_and_comments(input: &mut LocatingSlice<&str>) -> ModalResult<()> {
|
||||
repeat(0.., alt((multispace1.void(), sql_line_comment, sql_block_comment)))
|
||||
.fold(|| (), |(), _| ())
|
||||
.parse_next(input)
|
||||
}
|
||||
|
||||
/// Any single-, double-, or backtick-quoted SQL token (content discarded).
|
||||
fn sql_quoted(input: &mut LocatingSlice<&str>) -> ModalResult<()> {
|
||||
alt((sql_single_quoted, sql_double_quoted, sql_backtick_quoted)).parse_next(input)
|
||||
}
|
||||
|
||||
/// Consumes a `'…'` token honoring `''` doubling, producing no value. The
|
||||
/// `repeat(...).fold((), ..)` discards fragments while pinning the accumulator
|
||||
/// type (a bare `repeat(...).void()` cannot infer it).
|
||||
fn sql_single_quoted(input: &mut LocatingSlice<&str>) -> ModalResult<()> {
|
||||
delimited('\'', repeat(0.., alt(("''", take_till(1.., '\'')))).fold(|| (), |(), _| ()), '\'').parse_next(input)
|
||||
}
|
||||
|
||||
fn sql_double_quoted(input: &mut LocatingSlice<&str>) -> ModalResult<()> {
|
||||
delimited('"', repeat(0.., alt(("\"\"", take_till(1.., '"')))).fold(|| (), |(), _| ()), '"').parse_next(input)
|
||||
}
|
||||
|
||||
fn sql_backtick_quoted(input: &mut LocatingSlice<&str>) -> ModalResult<()> {
|
||||
delimited('`', repeat(0.., alt(("``", take_till(1.., '`')))).fold(|| (), |(), _| ()), '`').parse_next(input)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn opts(omit_auto_increment: bool) -> DdlNormalizeOptions {
|
||||
DdlNormalizeOptions { omit_auto_increment }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_table_level_auto_increment() {
|
||||
let ddl = "CREATE TABLE `t` (`id` bigint NOT NULL AUTO_INCREMENT) ENGINE=InnoDB AUTO_INCREMENT=121 DEFAULT CHARSET=utf8mb4";
|
||||
let out = normalize_mysql_export_ddl(ddl, opts(true));
|
||||
assert_eq!(out, "CREATE TABLE `t` (`id` bigint NOT NULL AUTO_INCREMENT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_column_level_auto_increment() {
|
||||
let ddl = "CREATE TABLE `t` (`id` bigint NOT NULL AUTO_INCREMENT) ENGINE=InnoDB AUTO_INCREMENT=5";
|
||||
let out = normalize_mysql_export_ddl(ddl, opts(true));
|
||||
// Table-level gone, column-level bare AUTO_INCREMENT untouched.
|
||||
assert!(out.contains("`id` bigint NOT NULL AUTO_INCREMENT"));
|
||||
assert!(!out.contains("AUTO_INCREMENT=5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_auto_increment_when_option_off() {
|
||||
let ddl = "CREATE TABLE `t` (`id` bigint NOT NULL AUTO_INCREMENT) ENGINE=InnoDB AUTO_INCREMENT=121";
|
||||
assert_eq!(normalize_mysql_export_ddl(ddl, opts(false)), ddl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_double_space_left_behind() {
|
||||
let ddl = "CREATE TABLE `t` (`id` int) ENGINE=InnoDB AUTO_INCREMENT=7 COLLATE=utf8mb4_bin";
|
||||
let out = normalize_mysql_export_ddl(ddl, opts(true));
|
||||
assert_eq!(out, "CREATE TABLE `t` (`id` int) ENGINE=InnoDB COLLATE=utf8mb4_bin");
|
||||
assert!(!out.contains(" "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_increment_inside_comment_is_not_stripped() {
|
||||
let ddl =
|
||||
"CREATE TABLE `t` (`id` int) ENGINE=InnoDB AUTO_INCREMENT=9 COMMENT='see AUTO_INCREMENT=1000 in docs'";
|
||||
let out = normalize_mysql_export_ddl(ddl, opts(true));
|
||||
// Table-level `AUTO_INCREMENT=9` is removed; the COMMENT literal (including
|
||||
// its inner `AUTO_INCREMENT=1000` text) is preserved verbatim — this is the
|
||||
// quote-awareness a blind regex could not provide.
|
||||
assert_eq!(out, "CREATE TABLE `t` (`id` int) ENGINE=InnoDB COMMENT='see AUTO_INCREMENT=1000 in docs'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_between_close_paren_and_first_option() {
|
||||
let ddl = "CREATE TABLE `t` (`id` int) /* generated by tool x */ ENGINE=InnoDB AUTO_INCREMENT=5";
|
||||
let out = normalize_mysql_export_ddl(ddl, opts(true));
|
||||
assert_eq!(out, "CREATE TABLE `t` (`id` int) /* generated by tool x */ ENGINE=InnoDB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_comment_between_options_ignores_inner_parens() {
|
||||
// The `(` and `)` inside the block comment must stay opaque; they must
|
||||
// not be mistaken for column-list delimiters or option boundaries.
|
||||
let ddl = "CREATE TABLE `t` (`id` int) ENGINE=InnoDB /* see (a) and )b( */ AUTO_INCREMENT=5";
|
||||
let out = normalize_mysql_export_ddl(ddl, opts(true));
|
||||
assert_eq!(out, "CREATE TABLE `t` (`id` int) ENGINE=InnoDB /* see (a) and )b( */");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_increment_inside_block_comment_not_stripped() {
|
||||
// Only the real table-level option is removed; the one inside the block
|
||||
// comment is opaque and preserved verbatim.
|
||||
let ddl = "CREATE TABLE `t` (`id` int) ENGINE=InnoDB /* AUTO_INCREMENT=999 */ AUTO_INCREMENT=5";
|
||||
let out = normalize_mysql_export_ddl(ddl, opts(true));
|
||||
assert_eq!(out, "CREATE TABLE `t` (`id` int) ENGINE=InnoDB /* AUTO_INCREMENT=999 */");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_comment_between_options() {
|
||||
let ddl = "CREATE TABLE `t` (`id` int) ENGINE=InnoDB -- remember to bump\nAUTO_INCREMENT=5";
|
||||
let out = normalize_mysql_export_ddl(ddl, opts(true));
|
||||
assert_eq!(out, "CREATE TABLE `t` (`id` int) ENGINE=InnoDB -- remember to bump\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_around_equals_sign_in_option() {
|
||||
let ddl = "CREATE TABLE `t` (`id` int) ENGINE=InnoDB AUTO_INCREMENT/*c*/=/*c*/5";
|
||||
let out = normalize_mysql_export_ddl(ddl, opts(true));
|
||||
assert_eq!(out, "CREATE TABLE `t` (`id` int) ENGINE=InnoDB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_legacy_row_format() {
|
||||
let ddl = "CREATE TABLE `t` (`p` varchar(9)) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT";
|
||||
let out = normalize_mysql_export_ddl(ddl, opts(false));
|
||||
assert_eq!(out, "CREATE TABLE `t` (`p` varchar(9)) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_format_rewrite_is_case_insensitive_and_normalizes_output() {
|
||||
let ddl = "CREATE TABLE `t` (`p` text) engine=InnoDB row_format = redundant";
|
||||
let out = normalize_mysql_export_ddl(ddl, opts(false));
|
||||
assert_eq!(out, "CREATE TABLE `t` (`p` text) engine=InnoDB ROW_FORMAT=DYNAMIC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_legacy_row_format_untouched() {
|
||||
let ddl = "CREATE TABLE `t` (`p` text) ENGINE=InnoDB ROW_FORMAT=COMPRESSED";
|
||||
assert_eq!(normalize_mysql_export_ddl(ddl, opts(false)), ddl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_transforms_compose() {
|
||||
let ddl = "CREATE TABLE `t` (`p` text) ENGINE=InnoDB AUTO_INCREMENT=42 ROW_FORMAT=COMPACT DEFAULT CHARSET=utf8";
|
||||
let out = normalize_mysql_export_ddl(ddl, opts(true));
|
||||
assert_eq!(out, "CREATE TABLE `t` (`p` text) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_parens_in_column_body_do_not_confuse_tail_location() {
|
||||
let ddl = "CREATE TABLE `t` (`amount` decimal(30,6) NOT NULL, `st` enum('a','b') NOT NULL, CHECK (amount > 0)) ENGINE=InnoDB AUTO_INCREMENT=3";
|
||||
let out = normalize_mysql_export_ddl(ddl, opts(true));
|
||||
assert!(out.contains("decimal(30,6)"));
|
||||
assert!(out.contains("enum('a','b')"));
|
||||
assert!(out.contains("CHECK (amount > 0)"));
|
||||
assert!(!out.contains("AUTO_INCREMENT=3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_name_containing_paren_does_not_mislocate_tail() {
|
||||
// The `(` inside the backtick-quoted table name must not be mistaken for
|
||||
// the column-list opener; the real options tail is after the column list.
|
||||
let ddl = "CREATE TABLE `weird(name)` (`id` int) ENGINE=InnoDB AUTO_INCREMENT=7";
|
||||
let out = normalize_mysql_export_ddl(ddl, opts(true));
|
||||
assert_eq!(out, "CREATE TABLE `weird(name)` (`id` int) ENGINE=InnoDB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_comment_survives_byte_offset_edit() {
|
||||
let ddl = "CREATE TABLE `t` (`id` int) ENGINE=InnoDB AUTO_INCREMENT=11 COMMENT='每日收益表'";
|
||||
let out = normalize_mysql_export_ddl(ddl, opts(true));
|
||||
assert_eq!(out, "CREATE TABLE `t` (`id` int) ENGINE=InnoDB COMMENT='每日收益表'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ddl_fails_open_unchanged() {
|
||||
let ddl = "CREATE TABLE `t` (`id` int"; // unbalanced paren
|
||||
assert_eq!(normalize_mysql_export_ddl(ddl, opts(true)), ddl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MysqlEventSchedule {
|
||||
At { execute_at: String },
|
||||
Every { interval_value: String, interval_unit: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct MysqlEventDefinition {
|
||||
pub name: String,
|
||||
pub schema: Option<String>,
|
||||
pub schedule: MysqlEventSchedule,
|
||||
pub starts: Option<String>,
|
||||
pub ends: Option<String>,
|
||||
pub on_completion_preserve: Option<bool>,
|
||||
pub enabled: Option<bool>,
|
||||
pub comment: Option<String>,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
pub use dbx_types::mysql_event::MysqlEventInfo;
|
||||
|
||||
fn ident(value: &str) -> Result<String, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Err("event name must not be empty".into());
|
||||
}
|
||||
Ok(format!("`{}`", value.replace('`', "``")))
|
||||
}
|
||||
fn literal(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"))
|
||||
}
|
||||
fn unit(value: &str) -> Result<&'static str, String> {
|
||||
match value.trim().to_ascii_uppercase().as_str() {
|
||||
"SECOND" => Ok("SECOND"),
|
||||
"MINUTE" => Ok("MINUTE"),
|
||||
"HOUR" => Ok("HOUR"),
|
||||
"DAY" => Ok("DAY"),
|
||||
"WEEK" => Ok("WEEK"),
|
||||
"MONTH" => Ok("MONTH"),
|
||||
"QUARTER" => Ok("QUARTER"),
|
||||
"YEAR" => Ok("YEAR"),
|
||||
_ => Err(format!("invalid event interval unit: {value}")),
|
||||
}
|
||||
}
|
||||
pub fn create_event_sql(definition: &MysqlEventDefinition) -> Result<String, String> {
|
||||
build_event_sql("CREATE EVENT", definition)
|
||||
}
|
||||
pub fn alter_event_sql(definition: &MysqlEventDefinition) -> Result<String, String> {
|
||||
build_event_sql("ALTER EVENT", definition)
|
||||
}
|
||||
fn build_event_sql(prefix: &str, definition: &MysqlEventDefinition) -> Result<String, String> {
|
||||
let name = ident(&definition.name)?;
|
||||
if definition.body.trim().is_empty() {
|
||||
return Err("event body must not be empty".into());
|
||||
}
|
||||
let schedule = match &definition.schedule {
|
||||
MysqlEventSchedule::At { execute_at } if execute_at.trim().is_empty() => {
|
||||
return Err("event AT schedule must include execute_at".into())
|
||||
}
|
||||
MysqlEventSchedule::At { execute_at } => format!("AT {}", literal(execute_at)),
|
||||
MysqlEventSchedule::Every { interval_value, interval_unit } => {
|
||||
let interval_value = interval_value.trim();
|
||||
if !interval_value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
|| interval_value.bytes().all(|byte| byte == b'0')
|
||||
{
|
||||
return Err("event EVERY schedule requires a positive integer interval_value".into());
|
||||
}
|
||||
format!("EVERY {} {}", interval_value, unit(interval_unit)?)
|
||||
}
|
||||
};
|
||||
let mut sql = format!("{prefix} {name} ON SCHEDULE {schedule}");
|
||||
if let Some(value) = definition.starts.as_deref().filter(|v| !v.trim().is_empty()) {
|
||||
sql.push_str(&format!(" STARTS {}", literal(value)));
|
||||
}
|
||||
if let Some(value) = definition.ends.as_deref().filter(|v| !v.trim().is_empty()) {
|
||||
sql.push_str(&format!(" ENDS {}", literal(value)));
|
||||
}
|
||||
if let Some(value) = definition.on_completion_preserve {
|
||||
sql.push_str(if value { " ON COMPLETION PRESERVE" } else { " ON COMPLETION NOT PRESERVE" });
|
||||
}
|
||||
if let Some(value) = definition.enabled {
|
||||
sql.push_str(if value { " ENABLE" } else { " DISABLE" });
|
||||
}
|
||||
if let Some(value) = definition.comment.as_deref() {
|
||||
sql.push_str(&format!(" COMMENT {}", literal(value)));
|
||||
}
|
||||
sql.push_str(" DO ");
|
||||
sql.push_str(definition.body.trim());
|
||||
Ok(sql)
|
||||
}
|
||||
pub fn drop_event_sql(schema: Option<&str>, name: &str, if_exists: bool) -> Result<String, String> {
|
||||
let qualified = match schema.filter(|s| !s.trim().is_empty()) {
|
||||
Some(schema) => format!("{}.{}", ident(schema)?, ident(name)?),
|
||||
None => ident(name)?,
|
||||
};
|
||||
Ok(format!("DROP EVENT {}{}", if if_exists { "IF EXISTS " } else { "" }, qualified))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn definition() -> MysqlEventDefinition {
|
||||
MysqlEventDefinition {
|
||||
name: "daily`sync".into(),
|
||||
schema: Some("shop".into()),
|
||||
schedule: MysqlEventSchedule::Every { interval_value: "1".into(), interval_unit: "day".into() },
|
||||
starts: Some("2026-01-01 00:00:00".into()),
|
||||
ends: None,
|
||||
on_completion_preserve: Some(true),
|
||||
enabled: Some(false),
|
||||
comment: Some("owner's job".into()),
|
||||
body: "CALL `refresh`();".into(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn builds_every_event_with_escaped_values() {
|
||||
assert_eq!(create_event_sql(&definition()).unwrap(), "CREATE EVENT `daily``sync` ON SCHEDULE EVERY 1 DAY STARTS '2026-01-01 00:00:00' ON COMPLETION PRESERVE DISABLE COMMENT 'owner\\'s job' DO CALL `refresh`();");
|
||||
}
|
||||
#[test]
|
||||
fn validates_schedule_and_body() {
|
||||
let mut def = definition();
|
||||
def.body = " ".into();
|
||||
assert!(create_event_sql(&def).is_err());
|
||||
def.body = "SELECT 1".into();
|
||||
def.schedule = MysqlEventSchedule::Every { interval_value: "1".into(), interval_unit: "fortnight".into() };
|
||||
assert!(create_event_sql(&def).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn rejects_non_positive_or_non_numeric_interval_values() {
|
||||
let mut def = definition();
|
||||
for interval_value in ["", "0", " 000 ", "1 DAY", "1; DROP EVENT other"] {
|
||||
def.schedule =
|
||||
MysqlEventSchedule::Every { interval_value: interval_value.into(), interval_unit: "DAY".into() };
|
||||
assert!(create_event_sql(&def).is_err(), "unexpectedly accepted {interval_value:?}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn builds_at_and_drop_sql() {
|
||||
let mut def = definition();
|
||||
def.schedule = MysqlEventSchedule::At { execute_at: "2026-02-01 10:00:00".into() };
|
||||
assert!(create_event_sql(&def).unwrap().contains("ON SCHEDULE AT '2026-02-01 10:00:00'"));
|
||||
assert_eq!(
|
||||
drop_event_sql(Some("shop"), "daily`sync", true).unwrap(),
|
||||
"DROP EVENT IF EXISTS `shop`.`daily``sync`"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,547 @@
|
||||
//! Runtime scripting for SQL Server column dependencies.
|
||||
//!
|
||||
//! SQL Server refuses `ALTER COLUMN` while a number of catalog objects depend
|
||||
//! on the column. The schema diff model intentionally omits unchanged
|
||||
//! objects, so the only complete source for those dependencies is the target
|
||||
//! database itself. This helper snapshots the supported definitions before
|
||||
//! dropping anything, performs the caller-provided alteration, and restores
|
||||
//! the objects in dependency order.
|
||||
|
||||
const DEPENDENCY_CAPTURE_AND_DROP_SQL: &str = r#"
|
||||
DECLARE @dbx_qualified_table nvarchar(517) =
|
||||
QUOTENAME(OBJECT_SCHEMA_NAME(@dbx_object_id)) + N'.' + QUOTENAME(OBJECT_NAME(@dbx_object_id));
|
||||
|
||||
DECLARE @dbx_unsupported_index sysname;
|
||||
SELECT TOP (1) @dbx_unsupported_index = idx.name
|
||||
FROM sys.indexes AS idx
|
||||
WHERE idx.object_id = @dbx_object_id
|
||||
AND idx.index_id > 0
|
||||
AND idx.name IS NOT NULL
|
||||
AND idx.is_hypothetical = 0
|
||||
AND idx.type NOT IN (1, 2)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.index_columns AS ic
|
||||
WHERE ic.object_id = idx.object_id
|
||||
AND ic.index_id = idx.index_id
|
||||
AND ic.column_id = @dbx_column_id
|
||||
);
|
||||
IF @dbx_unsupported_index IS NOT NULL
|
||||
BEGIN
|
||||
DECLARE @dbx_unsupported_index_message nvarchar(2048) =
|
||||
N'DBX cannot safely preserve affected SQL Server index ' + QUOTENAME(@dbx_unsupported_index) +
|
||||
N' because its index type is not a supported rowstore type.';
|
||||
;THROW 50001, @dbx_unsupported_index_message, 1;
|
||||
END;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.indexes AS idx
|
||||
JOIN sys.data_spaces AS data_space ON data_space.data_space_id = idx.data_space_id
|
||||
WHERE idx.object_id = @dbx_object_id
|
||||
AND idx.type IN (1, 2)
|
||||
AND idx.is_hypothetical = 0
|
||||
AND data_space.type = N'PS'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.index_columns AS affected_ic
|
||||
WHERE affected_ic.object_id = idx.object_id
|
||||
AND affected_ic.index_id = idx.index_id
|
||||
AND affected_ic.column_id = @dbx_column_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.index_columns AS partition_ic
|
||||
WHERE partition_ic.object_id = idx.object_id
|
||||
AND partition_ic.index_id = idx.index_id
|
||||
AND partition_ic.partition_ordinal > 0
|
||||
)
|
||||
)
|
||||
BEGIN
|
||||
;THROW 50002, 'DBX could not resolve the partition column for an affected SQL Server index.', 1;
|
||||
END;
|
||||
|
||||
DECLARE @dbx_started_transaction bit = 0;
|
||||
DECLARE @dbx_has_savepoint bit = 0;
|
||||
|
||||
BEGIN TRY
|
||||
IF @@TRANCOUNT = 0
|
||||
BEGIN
|
||||
BEGIN TRANSACTION;
|
||||
SET @dbx_started_transaction = 1;
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
SAVE TRANSACTION dbx_alter_column_dependencies;
|
||||
SET @dbx_has_savepoint = 1;
|
||||
END;
|
||||
|
||||
DECLARE @dbx_dependencies TABLE (
|
||||
dependency_id int IDENTITY(1, 1) NOT NULL,
|
||||
drop_order tinyint NOT NULL,
|
||||
create_order tinyint NOT NULL,
|
||||
drop_sql nvarchar(max) NOT NULL,
|
||||
create_sql nvarchar(max) NOT NULL
|
||||
);
|
||||
|
||||
-- Foreign keys are captured first and dropped before key constraints. The
|
||||
-- predicate covers outgoing, inbound, composite, and self-referencing FKs.
|
||||
INSERT INTO @dbx_dependencies (drop_order, create_order, drop_sql, create_sql)
|
||||
SELECT
|
||||
10,
|
||||
50,
|
||||
N'ALTER TABLE ' + QUOTENAME(OBJECT_SCHEMA_NAME(fk.parent_object_id)) + N'.' +
|
||||
QUOTENAME(OBJECT_NAME(fk.parent_object_id)) + N' DROP CONSTRAINT ' + QUOTENAME(fk.name) + N';',
|
||||
N'ALTER TABLE ' + QUOTENAME(OBJECT_SCHEMA_NAME(fk.parent_object_id)) + N'.' +
|
||||
QUOTENAME(OBJECT_NAME(fk.parent_object_id)) +
|
||||
CASE WHEN fk.is_not_trusted = 1 THEN N' WITH NOCHECK' ELSE N' WITH CHECK' END +
|
||||
N' ADD CONSTRAINT ' + QUOTENAME(fk.name) + N' FOREIGN KEY (' +
|
||||
STUFF((
|
||||
SELECT N', ' + QUOTENAME(parent_column.name)
|
||||
FROM sys.foreign_key_columns AS fkc
|
||||
JOIN sys.columns AS parent_column
|
||||
ON parent_column.object_id = fkc.parent_object_id
|
||||
AND parent_column.column_id = fkc.parent_column_id
|
||||
WHERE fkc.constraint_object_id = fk.object_id
|
||||
ORDER BY fkc.constraint_column_id
|
||||
FOR XML PATH(''), TYPE
|
||||
).value('.', 'nvarchar(max)'), 1, 2, N'') + N') REFERENCES ' +
|
||||
QUOTENAME(OBJECT_SCHEMA_NAME(fk.referenced_object_id)) + N'.' +
|
||||
QUOTENAME(OBJECT_NAME(fk.referenced_object_id)) + N' (' +
|
||||
STUFF((
|
||||
SELECT N', ' + QUOTENAME(referenced_column.name)
|
||||
FROM sys.foreign_key_columns AS fkc
|
||||
JOIN sys.columns AS referenced_column
|
||||
ON referenced_column.object_id = fkc.referenced_object_id
|
||||
AND referenced_column.column_id = fkc.referenced_column_id
|
||||
WHERE fkc.constraint_object_id = fk.object_id
|
||||
ORDER BY fkc.constraint_column_id
|
||||
FOR XML PATH(''), TYPE
|
||||
).value('.', 'nvarchar(max)'), 1, 2, N'') + N')' +
|
||||
CASE WHEN fk.delete_referential_action_desc = N'NO_ACTION' THEN N''
|
||||
ELSE N' ON DELETE ' + REPLACE(fk.delete_referential_action_desc, N'_', N' ') END +
|
||||
CASE WHEN fk.update_referential_action_desc = N'NO_ACTION' THEN N''
|
||||
ELSE N' ON UPDATE ' + REPLACE(fk.update_referential_action_desc, N'_', N' ') END +
|
||||
CASE WHEN fk.is_not_for_replication = 1 THEN N' NOT FOR REPLICATION' ELSE N'' END + N'; ' +
|
||||
N'ALTER TABLE ' + QUOTENAME(OBJECT_SCHEMA_NAME(fk.parent_object_id)) + N'.' +
|
||||
QUOTENAME(OBJECT_NAME(fk.parent_object_id)) +
|
||||
CASE WHEN fk.is_disabled = 1 THEN N' NOCHECK' ELSE N' CHECK' END +
|
||||
N' CONSTRAINT ' + QUOTENAME(fk.name) + N';'
|
||||
FROM sys.foreign_keys AS fk
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.foreign_key_columns AS fkc
|
||||
WHERE fkc.constraint_object_id = fk.object_id
|
||||
AND ((fkc.parent_object_id = @dbx_object_id AND fkc.parent_column_id = @dbx_column_id)
|
||||
OR (fkc.referenced_object_id = @dbx_object_id AND fkc.referenced_column_id = @dbx_column_id))
|
||||
);
|
||||
|
||||
-- parent_column_id identifies column-level checks. Table-level checks use
|
||||
-- expression dependencies to avoid dropping unrelated constraints.
|
||||
INSERT INTO @dbx_dependencies (drop_order, create_order, drop_sql, create_sql)
|
||||
SELECT
|
||||
20,
|
||||
40,
|
||||
N'ALTER TABLE ' + @dbx_qualified_table + N' DROP CONSTRAINT ' + QUOTENAME(cc.name) + N';',
|
||||
N'ALTER TABLE ' + @dbx_qualified_table +
|
||||
CASE WHEN cc.is_not_trusted = 1 THEN N' WITH NOCHECK' ELSE N' WITH CHECK' END +
|
||||
N' ADD CONSTRAINT ' + QUOTENAME(cc.name) + N' CHECK ' +
|
||||
CASE WHEN cc.is_not_for_replication = 1 THEN N'NOT FOR REPLICATION ' ELSE N'' END +
|
||||
cc.definition + N'; ALTER TABLE ' + @dbx_qualified_table +
|
||||
CASE WHEN cc.is_disabled = 1 THEN N' NOCHECK' ELSE N' CHECK' END +
|
||||
N' CONSTRAINT ' + QUOTENAME(cc.name) + N';'
|
||||
FROM sys.check_constraints AS cc
|
||||
WHERE cc.parent_object_id = @dbx_object_id
|
||||
AND (cc.parent_column_id = @dbx_column_id OR EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.sql_expression_dependencies AS sed
|
||||
WHERE sed.referencing_id = cc.object_id
|
||||
AND sed.referenced_id = @dbx_object_id
|
||||
AND sed.referenced_minor_id = @dbx_column_id
|
||||
));
|
||||
|
||||
-- Index-owned statistics disappear with their index. Only independent,
|
||||
-- user-created statistics need an explicit drop and recreation.
|
||||
INSERT INTO @dbx_dependencies (drop_order, create_order, drop_sql, create_sql)
|
||||
SELECT
|
||||
30,
|
||||
30,
|
||||
N'DROP STATISTICS ' + @dbx_qualified_table + N'.' + QUOTENAME(stats.name) + N';',
|
||||
N'CREATE STATISTICS ' + QUOTENAME(stats.name) + N' ON ' + @dbx_qualified_table + N' (' +
|
||||
STUFF((
|
||||
SELECT N', ' + QUOTENAME(stats_column.name)
|
||||
FROM sys.stats_columns AS sc
|
||||
JOIN sys.columns AS stats_column
|
||||
ON stats_column.object_id = sc.object_id
|
||||
AND stats_column.column_id = sc.column_id
|
||||
WHERE sc.object_id = stats.object_id
|
||||
AND sc.stats_id = stats.stats_id
|
||||
ORDER BY sc.stats_column_id
|
||||
FOR XML PATH(''), TYPE
|
||||
).value('.', 'nvarchar(max)'), 1, 2, N'') + N')' +
|
||||
CASE WHEN stats.has_filter = 1 THEN N' WHERE ' + stats.filter_definition ELSE N'' END +
|
||||
CASE WHEN stats.no_recompute = 1 THEN N' WITH NORECOMPUTE' ELSE N'' END + N';'
|
||||
FROM sys.stats AS stats
|
||||
WHERE stats.object_id = @dbx_object_id
|
||||
AND stats.user_created = 1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.stats_columns AS sc
|
||||
WHERE sc.object_id = stats.object_id
|
||||
AND sc.stats_id = stats.stats_id
|
||||
AND sc.column_id = @dbx_column_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.indexes AS matching_index
|
||||
WHERE matching_index.object_id = stats.object_id
|
||||
AND matching_index.index_id = stats.stats_id
|
||||
);
|
||||
|
||||
-- Ordinary clustered/nonclustered rowstore indexes. Explicit key order,
|
||||
-- INCLUDE columns, filters, durable catalog options, data space, and the
|
||||
-- disabled state are reconstructed from the live target catalog.
|
||||
INSERT INTO @dbx_dependencies (drop_order, create_order, drop_sql, create_sql)
|
||||
SELECT
|
||||
40,
|
||||
20,
|
||||
N'DROP INDEX ' + QUOTENAME(idx.name) + N' ON ' + @dbx_qualified_table + N';',
|
||||
N'CREATE ' + CASE WHEN idx.is_unique = 1 THEN N'UNIQUE ' ELSE N'' END + idx.type_desc +
|
||||
N' INDEX ' + QUOTENAME(idx.name) + N' ON ' + @dbx_qualified_table + N' (' +
|
||||
STUFF((
|
||||
SELECT N', ' + QUOTENAME(index_column.name) +
|
||||
CASE WHEN ic.is_descending_key = 1 THEN N' DESC' ELSE N' ASC' END
|
||||
FROM sys.index_columns AS ic
|
||||
JOIN sys.columns AS index_column
|
||||
ON index_column.object_id = ic.object_id
|
||||
AND index_column.column_id = ic.column_id
|
||||
WHERE ic.object_id = idx.object_id
|
||||
AND ic.index_id = idx.index_id
|
||||
AND ic.key_ordinal > 0
|
||||
ORDER BY ic.key_ordinal
|
||||
FOR XML PATH(''), TYPE
|
||||
).value('.', 'nvarchar(max)'), 1, 2, N'') + N')' +
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.index_columns AS include_check
|
||||
WHERE include_check.object_id = idx.object_id
|
||||
AND include_check.index_id = idx.index_id
|
||||
AND include_check.is_included_column = 1
|
||||
) THEN N' INCLUDE (' + STUFF((
|
||||
SELECT N', ' + QUOTENAME(include_column.name)
|
||||
FROM sys.index_columns AS include_ic
|
||||
JOIN sys.columns AS include_column
|
||||
ON include_column.object_id = include_ic.object_id
|
||||
AND include_column.column_id = include_ic.column_id
|
||||
WHERE include_ic.object_id = idx.object_id
|
||||
AND include_ic.index_id = idx.index_id
|
||||
AND include_ic.is_included_column = 1
|
||||
ORDER BY include_ic.index_column_id
|
||||
FOR XML PATH(''), TYPE
|
||||
).value('.', 'nvarchar(max)'), 1, 2, N'') + N')' ELSE N'' END +
|
||||
CASE WHEN idx.has_filter = 1 THEN N' WHERE ' + idx.filter_definition ELSE N'' END +
|
||||
N' WITH (PAD_INDEX = ' + CASE WHEN idx.is_padded = 1 THEN N'ON' ELSE N'OFF' END +
|
||||
CASE WHEN idx.fill_factor > 0
|
||||
THEN N', FILLFACTOR = ' + CONVERT(nvarchar(3), idx.fill_factor)
|
||||
ELSE N'' END +
|
||||
N', IGNORE_DUP_KEY = ' + CASE WHEN idx.ignore_dup_key = 1 THEN N'ON' ELSE N'OFF' END +
|
||||
N', STATISTICS_NORECOMPUTE = ' + CASE WHEN index_stats.no_recompute = 1 THEN N'ON' ELSE N'OFF' END +
|
||||
N', ALLOW_ROW_LOCKS = ' + CASE WHEN idx.allow_row_locks = 1 THEN N'ON' ELSE N'OFF' END +
|
||||
N', ALLOW_PAGE_LOCKS = ' + CASE WHEN idx.allow_page_locks = 1 THEN N'ON' ELSE N'OFF' END + N')' +
|
||||
CASE WHEN data_space.type = N'FG' THEN N' ON ' + QUOTENAME(data_space.name)
|
||||
WHEN data_space.type = N'PS' THEN N' ON ' + QUOTENAME(data_space.name) + N' (' +
|
||||
QUOTENAME((
|
||||
SELECT TOP (1) partition_column.name
|
||||
FROM sys.index_columns AS partition_ic
|
||||
JOIN sys.columns AS partition_column
|
||||
ON partition_column.object_id = partition_ic.object_id
|
||||
AND partition_column.column_id = partition_ic.column_id
|
||||
WHERE partition_ic.object_id = idx.object_id
|
||||
AND partition_ic.index_id = idx.index_id
|
||||
AND partition_ic.partition_ordinal > 0
|
||||
ORDER BY partition_ic.partition_ordinal
|
||||
)) + N')'
|
||||
ELSE N'' END + N';' +
|
||||
CASE WHEN idx.is_disabled = 1 THEN N' ALTER INDEX ' + QUOTENAME(idx.name) +
|
||||
N' ON ' + @dbx_qualified_table + N' DISABLE;' ELSE N'' END
|
||||
FROM sys.indexes AS idx
|
||||
LEFT JOIN sys.stats AS index_stats
|
||||
ON index_stats.object_id = idx.object_id
|
||||
AND index_stats.stats_id = idx.index_id
|
||||
LEFT JOIN sys.data_spaces AS data_space ON data_space.data_space_id = idx.data_space_id
|
||||
WHERE idx.object_id = @dbx_object_id
|
||||
AND idx.index_id > 0
|
||||
AND idx.name IS NOT NULL
|
||||
AND idx.type IN (1, 2)
|
||||
AND idx.is_hypothetical = 0
|
||||
AND idx.is_primary_key = 0
|
||||
AND idx.is_unique_constraint = 0
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.index_columns AS affected_ic
|
||||
WHERE affected_ic.object_id = idx.object_id
|
||||
AND affected_ic.index_id = idx.index_id
|
||||
AND affected_ic.column_id = @dbx_column_id
|
||||
);
|
||||
|
||||
-- PRIMARY KEY and UNIQUE constraints must be dropped/created as constraints,
|
||||
-- never as ordinary unique indexes.
|
||||
INSERT INTO @dbx_dependencies (drop_order, create_order, drop_sql, create_sql)
|
||||
SELECT
|
||||
50,
|
||||
10,
|
||||
N'ALTER TABLE ' + @dbx_qualified_table + N' DROP CONSTRAINT ' + QUOTENAME(key_constraint.name) + N';',
|
||||
N'ALTER TABLE ' + @dbx_qualified_table + N' ADD CONSTRAINT ' + QUOTENAME(key_constraint.name) + N' ' +
|
||||
CASE WHEN key_constraint.type = N'PK' THEN N'PRIMARY KEY ' ELSE N'UNIQUE ' END +
|
||||
idx.type_desc + N' (' +
|
||||
STUFF((
|
||||
SELECT N', ' + QUOTENAME(key_column.name) +
|
||||
CASE WHEN ic.is_descending_key = 1 THEN N' DESC' ELSE N' ASC' END
|
||||
FROM sys.index_columns AS ic
|
||||
JOIN sys.columns AS key_column
|
||||
ON key_column.object_id = ic.object_id
|
||||
AND key_column.column_id = ic.column_id
|
||||
WHERE ic.object_id = idx.object_id
|
||||
AND ic.index_id = idx.index_id
|
||||
AND ic.key_ordinal > 0
|
||||
ORDER BY ic.key_ordinal
|
||||
FOR XML PATH(''), TYPE
|
||||
).value('.', 'nvarchar(max)'), 1, 2, N'') + N')' +
|
||||
N' WITH (PAD_INDEX = ' + CASE WHEN idx.is_padded = 1 THEN N'ON' ELSE N'OFF' END +
|
||||
CASE WHEN idx.fill_factor > 0
|
||||
THEN N', FILLFACTOR = ' + CONVERT(nvarchar(3), idx.fill_factor)
|
||||
ELSE N'' END +
|
||||
N', IGNORE_DUP_KEY = ' + CASE WHEN idx.ignore_dup_key = 1 THEN N'ON' ELSE N'OFF' END +
|
||||
N', STATISTICS_NORECOMPUTE = ' + CASE WHEN key_stats.no_recompute = 1 THEN N'ON' ELSE N'OFF' END +
|
||||
N', ALLOW_ROW_LOCKS = ' + CASE WHEN idx.allow_row_locks = 1 THEN N'ON' ELSE N'OFF' END +
|
||||
N', ALLOW_PAGE_LOCKS = ' + CASE WHEN idx.allow_page_locks = 1 THEN N'ON' ELSE N'OFF' END + N')' +
|
||||
CASE WHEN data_space.type = N'FG' THEN N' ON ' + QUOTENAME(data_space.name)
|
||||
WHEN data_space.type = N'PS' THEN N' ON ' + QUOTENAME(data_space.name) + N' (' +
|
||||
QUOTENAME((
|
||||
SELECT TOP (1) partition_column.name
|
||||
FROM sys.index_columns AS partition_ic
|
||||
JOIN sys.columns AS partition_column
|
||||
ON partition_column.object_id = partition_ic.object_id
|
||||
AND partition_column.column_id = partition_ic.column_id
|
||||
WHERE partition_ic.object_id = idx.object_id
|
||||
AND partition_ic.index_id = idx.index_id
|
||||
AND partition_ic.partition_ordinal > 0
|
||||
ORDER BY partition_ic.partition_ordinal
|
||||
)) + N')'
|
||||
ELSE N'' END + N';' +
|
||||
CASE WHEN idx.is_disabled = 1 THEN N' ALTER INDEX ' + QUOTENAME(idx.name) +
|
||||
N' ON ' + @dbx_qualified_table + N' DISABLE;' ELSE N'' END
|
||||
FROM sys.key_constraints AS key_constraint
|
||||
JOIN sys.indexes AS idx
|
||||
ON idx.object_id = key_constraint.parent_object_id
|
||||
AND idx.index_id = key_constraint.unique_index_id
|
||||
LEFT JOIN sys.stats AS key_stats
|
||||
ON key_stats.object_id = idx.object_id
|
||||
AND key_stats.stats_id = idx.index_id
|
||||
LEFT JOIN sys.data_spaces AS data_space ON data_space.data_space_id = idx.data_space_id
|
||||
WHERE key_constraint.parent_object_id = @dbx_object_id
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.index_columns AS affected_ic
|
||||
WHERE affected_ic.object_id = idx.object_id
|
||||
AND affected_ic.index_id = idx.index_id
|
||||
AND affected_ic.column_id = @dbx_column_id
|
||||
);
|
||||
|
||||
DECLARE @dbx_dependency_sql nvarchar(max);
|
||||
DECLARE dbx_dependency_drop_cursor CURSOR LOCAL FAST_FORWARD FOR
|
||||
SELECT drop_sql
|
||||
FROM @dbx_dependencies
|
||||
ORDER BY drop_order, dependency_id;
|
||||
OPEN dbx_dependency_drop_cursor;
|
||||
FETCH NEXT FROM dbx_dependency_drop_cursor INTO @dbx_dependency_sql;
|
||||
WHILE @@FETCH_STATUS = 0
|
||||
BEGIN
|
||||
EXEC sys.sp_executesql @dbx_dependency_sql;
|
||||
FETCH NEXT FROM dbx_dependency_drop_cursor INTO @dbx_dependency_sql;
|
||||
END;
|
||||
CLOSE dbx_dependency_drop_cursor;
|
||||
DEALLOCATE dbx_dependency_drop_cursor;
|
||||
"#;
|
||||
|
||||
const DEPENDENCY_RECREATE_AND_FINISH_SQL: &str = r#"
|
||||
DECLARE dbx_dependency_create_cursor CURSOR LOCAL FAST_FORWARD FOR
|
||||
SELECT create_sql
|
||||
FROM @dbx_dependencies
|
||||
ORDER BY create_order, dependency_id;
|
||||
OPEN dbx_dependency_create_cursor;
|
||||
FETCH NEXT FROM dbx_dependency_create_cursor INTO @dbx_dependency_sql;
|
||||
WHILE @@FETCH_STATUS = 0
|
||||
BEGIN
|
||||
EXEC sys.sp_executesql @dbx_dependency_sql;
|
||||
FETCH NEXT FROM dbx_dependency_create_cursor INTO @dbx_dependency_sql;
|
||||
END;
|
||||
CLOSE dbx_dependency_create_cursor;
|
||||
DEALLOCATE dbx_dependency_create_cursor;
|
||||
|
||||
IF @dbx_started_transaction = 1
|
||||
COMMIT TRANSACTION;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF CURSOR_STATUS('local', 'dbx_dependency_drop_cursor') >= 0
|
||||
CLOSE dbx_dependency_drop_cursor;
|
||||
IF CURSOR_STATUS('local', 'dbx_dependency_drop_cursor') >= -1
|
||||
DEALLOCATE dbx_dependency_drop_cursor;
|
||||
IF CURSOR_STATUS('local', 'dbx_dependency_create_cursor') >= 0
|
||||
CLOSE dbx_dependency_create_cursor;
|
||||
IF CURSOR_STATUS('local', 'dbx_dependency_create_cursor') >= -1
|
||||
DEALLOCATE dbx_dependency_create_cursor;
|
||||
|
||||
IF @dbx_started_transaction = 1 AND XACT_STATE() <> 0
|
||||
ROLLBACK TRANSACTION;
|
||||
ELSE IF @dbx_has_savepoint = 1 AND XACT_STATE() = 1
|
||||
ROLLBACK TRANSACTION dbx_alter_column_dependencies;
|
||||
|
||||
THROW;
|
||||
END CATCH;
|
||||
"#;
|
||||
|
||||
fn escape_tsql_string_literal(value: &str) -> String {
|
||||
value.replace('\'', "''")
|
||||
}
|
||||
|
||||
/// Build one executable T-SQL batch that preserves live dependencies around an
|
||||
/// `ALTER COLUMN` operation.
|
||||
///
|
||||
/// `table` is the already-quoted target table name used by generated DDL (for
|
||||
/// example `[dbo].[orders]`). `column_name` is the unquoted catalog name.
|
||||
/// `alter_batch` may contain more than one statement, such as the existing
|
||||
/// default-constraint preservation batch followed by `ALTER COLUMN`.
|
||||
pub(super) fn build_dependency_aware_alter_column_batch(table: &str, column_name: &str, alter_batch: &str) -> String {
|
||||
let table_literal = escape_tsql_string_literal(table);
|
||||
let column_literal = escape_tsql_string_literal(column_name);
|
||||
let alter_batch = alter_batch.trim();
|
||||
|
||||
let mut batch = String::with_capacity(
|
||||
DEPENDENCY_CAPTURE_AND_DROP_SQL.len() + DEPENDENCY_RECREATE_AND_FINISH_SQL.len() + alter_batch.len() + 512,
|
||||
);
|
||||
batch.push_str(
|
||||
"SET ANSI_NULLS ON;\n\
|
||||
SET QUOTED_IDENTIFIER ON;\n\
|
||||
SET ANSI_PADDING ON;\n\
|
||||
SET ANSI_WARNINGS ON;\n\
|
||||
SET CONCAT_NULL_YIELDS_NULL ON;\n\
|
||||
SET ARITHABORT ON;\n\
|
||||
SET NUMERIC_ROUNDABORT OFF;\n\
|
||||
DECLARE @dbx_object_id int = OBJECT_ID(N'",
|
||||
);
|
||||
batch.push_str(&table_literal);
|
||||
batch.push_str("');\nDECLARE @dbx_column_id int = COLUMNPROPERTY(@dbx_object_id, N'");
|
||||
batch.push_str(&column_literal);
|
||||
batch.push_str("', 'ColumnId');\nIF @dbx_object_id IS NULL OR @dbx_column_id IS NULL\n");
|
||||
batch.push_str(" THROW 50000, 'DBX could not resolve the SQL Server column before ALTER COLUMN.', 1;\n");
|
||||
batch.push_str(DEPENDENCY_CAPTURE_AND_DROP_SQL);
|
||||
batch.push('\n');
|
||||
batch.push_str(" ");
|
||||
batch.push_str(alter_batch);
|
||||
if !alter_batch.ends_with(';') {
|
||||
batch.push(';');
|
||||
}
|
||||
batch.push('\n');
|
||||
batch.push_str(DEPENDENCY_RECREATE_AND_FINISH_SQL);
|
||||
batch
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dependency_batch_escapes_catalog_literals_and_keeps_alter_sql() {
|
||||
let sql = build_dependency_aware_alter_column_batch(
|
||||
"[odd].[O'Brien]",
|
||||
"amount's value",
|
||||
"ALTER TABLE [odd].[O'Brien] ALTER COLUMN [amount's value] bigint NULL;",
|
||||
);
|
||||
|
||||
assert!(sql.contains("OBJECT_ID(N'[odd].[O''Brien]')"), "{sql}");
|
||||
assert!(sql.contains("COLUMNPROPERTY(@dbx_object_id, N'amount''s value', 'ColumnId')"), "{sql}");
|
||||
assert!(sql.contains("ALTER TABLE [odd].[O'Brien] ALTER COLUMN [amount's value] bigint NULL;"), "{sql}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_batch_covers_every_supported_dependency_kind() {
|
||||
let sql = build_dependency_aware_alter_column_batch(
|
||||
"[dbo].[orders]",
|
||||
"amount",
|
||||
"ALTER TABLE [dbo].[orders] ALTER COLUMN [amount] bigint NOT NULL;",
|
||||
);
|
||||
|
||||
for catalog in [
|
||||
"sys.foreign_keys AS fk",
|
||||
"sys.foreign_key_columns AS fkc",
|
||||
"sys.check_constraints AS cc",
|
||||
"sys.sql_expression_dependencies AS sed",
|
||||
"sys.stats_columns AS sc",
|
||||
"sys.indexes AS idx",
|
||||
"sys.key_constraints AS key_constraint",
|
||||
] {
|
||||
assert!(sql.contains(catalog), "missing {catalog}: {sql}");
|
||||
}
|
||||
|
||||
assert!(sql.contains("fkc.referenced_object_id = @dbx_object_id"), "inbound FK predicate: {sql}");
|
||||
assert!(sql.contains("ORDER BY fkc.constraint_column_id"), "composite FK order: {sql}");
|
||||
assert!(sql.contains("fk.delete_referential_action_desc"), "delete action: {sql}");
|
||||
assert!(sql.contains("fk.update_referential_action_desc"), "update action: {sql}");
|
||||
assert!(sql.contains("fk.is_not_for_replication"), "NFR state: {sql}");
|
||||
assert!(sql.contains("fk.is_not_trusted"), "trust state: {sql}");
|
||||
assert!(sql.contains("fk.is_disabled"), "enabled state: {sql}");
|
||||
assert!(sql.contains("stats.user_created = 1"), "independent statistics: {sql}");
|
||||
assert!(sql.contains("stats.no_recompute"), "statistics options: {sql}");
|
||||
assert!(sql.contains("ic.is_descending_key"), "index key order: {sql}");
|
||||
assert!(sql.contains("include_ic.is_included_column = 1"), "included columns: {sql}");
|
||||
assert!(sql.contains("idx.filter_definition"), "filtered index: {sql}");
|
||||
assert!(sql.contains("idx.allow_row_locks"), "index options: {sql}");
|
||||
assert!(sql.contains("data_space.type = N'PS'"), "partition data space: {sql}");
|
||||
assert!(sql.contains("idx.is_disabled = 1"), "disabled index state: {sql}");
|
||||
assert!(sql.contains("key_constraint.type = N'PK'"), "primary key form: {sql}");
|
||||
assert!(sql.contains("THEN N'PRIMARY KEY ' ELSE N'UNIQUE ' END"), "PK/UQ distinction: {sql}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_batch_rejects_unsupported_indexes_before_dropping_anything() {
|
||||
let sql = build_dependency_aware_alter_column_batch(
|
||||
"[dbo].[events]",
|
||||
"payload",
|
||||
"ALTER TABLE [dbo].[events] ALTER COLUMN [payload] varbinary(max) NULL;",
|
||||
);
|
||||
|
||||
let guard = sql.find("idx.type NOT IN (1, 2)").expect("unsupported-index guard");
|
||||
let rejection = sql.find("THROW 50001").expect("unsupported-index THROW");
|
||||
let drop_cursor = sql.find("dbx_dependency_drop_cursor CURSOR").expect("drop cursor");
|
||||
assert!(guard < rejection && rejection < drop_cursor, "guard must run before every dependency drop: {sql}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_batch_is_atomic_and_recreates_after_the_alter() {
|
||||
let alter = "ALTER TABLE [dbo].[metrics] ALTER COLUMN [value] decimal(18,2) NOT NULL;";
|
||||
let sql = build_dependency_aware_alter_column_batch("[dbo].[metrics]", "value", alter);
|
||||
|
||||
let drop_cursor = sql.find("dbx_dependency_drop_cursor CURSOR").expect("drop cursor");
|
||||
let alter_position = sql.find(alter).expect("alter statement");
|
||||
let create_cursor = sql.find("dbx_dependency_create_cursor CURSOR").expect("create cursor");
|
||||
assert!(drop_cursor < alter_position && alter_position < create_cursor, "drop/alter/create order: {sql}");
|
||||
assert!(sql.contains("BEGIN TRY"), "{sql}");
|
||||
assert!(sql.starts_with("SET ANSI_NULLS ON;\nSET QUOTED_IDENTIFIER ON;"), "{sql}");
|
||||
assert!(sql.contains("SAVE TRANSACTION dbx_alter_column_dependencies"), "{sql}");
|
||||
assert!(sql.contains("ROLLBACK TRANSACTION dbx_alter_column_dependencies"), "{sql}");
|
||||
assert!(sql.contains("IF @dbx_started_transaction = 1\n COMMIT TRANSACTION"), "{sql}");
|
||||
assert!(sql.contains("THROW;\nEND CATCH"), "{sql}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_batch_terminates_an_unterminated_alter_batch_once() {
|
||||
let sql = build_dependency_aware_alter_column_batch(
|
||||
"[dbo].[metrics]",
|
||||
"value",
|
||||
"ALTER TABLE [dbo].[metrics] ALTER COLUMN [value] bigint NULL",
|
||||
);
|
||||
assert!(sql.contains("ALTER TABLE [dbo].[metrics] ALTER COLUMN [value] bigint NULL;\n"), "{sql}");
|
||||
assert!(!sql.contains("bigint NULL;;"), "{sql}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,259 @@
|
||||
const DEFAULT_SQL_DIAGNOSTIC_MAX_CHARS: usize = 512;
|
||||
|
||||
fn is_sensitive_key(key: &str) -> bool {
|
||||
let key = key.to_ascii_lowercase();
|
||||
key.contains("password")
|
||||
|| key.contains("passwd")
|
||||
|| key == "pwd"
|
||||
|| key.contains("secret")
|
||||
|| key.contains("token")
|
||||
|| key.contains("api_key")
|
||||
|| key.contains("apikey")
|
||||
|| key.contains("access_key")
|
||||
|| key.contains("private_key")
|
||||
|| key.contains("credential")
|
||||
|| key.contains("authorization")
|
||||
|| key.contains("bearer")
|
||||
}
|
||||
|
||||
fn truncate_for_diagnostics(value: String, max_chars: usize, input_truncated: bool) -> String {
|
||||
if value.chars().count() <= max_chars {
|
||||
return if input_truncated { format!("{value}…[truncated]") } else { value };
|
||||
}
|
||||
let head: String = value.chars().take(max_chars).collect();
|
||||
format!("{head}…[truncated]")
|
||||
}
|
||||
|
||||
fn bounded_input(sql: &str, max_chars: usize) -> (&str, bool) {
|
||||
if max_chars == 0 {
|
||||
return ("", !sql.is_empty());
|
||||
}
|
||||
match sql.char_indices().nth(max_chars) {
|
||||
Some((index, _)) => (&sql[..index], true),
|
||||
None => (sql, false),
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_literals(sql: &str) -> String {
|
||||
let chars: Vec<char> = sql.chars().collect();
|
||||
let mut out = String::new();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let ch = chars[i];
|
||||
let next = chars.get(i + 1).copied();
|
||||
if matches!(ch, '\'' | '"' | '`') {
|
||||
out.push(ch);
|
||||
out.push_str("[REDACTED]");
|
||||
out.push(ch);
|
||||
i += 1;
|
||||
while i < chars.len() {
|
||||
let current = chars[i];
|
||||
if current == ch {
|
||||
if chars.get(i + 1).copied() == Some(ch) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
if current == '\\' && ch != '`' {
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ch == '$' {
|
||||
let j = i + 1;
|
||||
if j >= chars.len() {
|
||||
out.push(ch);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if chars[j] == '$' {
|
||||
// $$...$$ dollar-quoted string
|
||||
out.push_str("$$[REDACTED]$$");
|
||||
i += 2;
|
||||
while i + 1 < chars.len() && !(chars[i] == '$' && chars[i + 1] == '$') {
|
||||
i += 1;
|
||||
}
|
||||
if i + 1 < chars.len() {
|
||||
i += 2;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// $tag$...$tag$ dollar-quoted string
|
||||
let tag_start = j;
|
||||
let mut tag_end = j;
|
||||
while tag_end < chars.len() && (chars[tag_end].is_ascii_alphanumeric() || chars[tag_end] == '_') {
|
||||
tag_end += 1;
|
||||
}
|
||||
if tag_end > tag_start && tag_end < chars.len() && chars[tag_end] == '$' {
|
||||
let tag: String = chars[tag_start..tag_end].iter().collect();
|
||||
out.push_str("$[REDACTED]$");
|
||||
i = tag_end + 1;
|
||||
let closing: Vec<char> = format!("${}$", tag).chars().collect();
|
||||
while i + closing.len() <= chars.len() {
|
||||
if chars[i..i + closing.len()] == closing[..] {
|
||||
i += closing.len();
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push(ch);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if ch == '-' && next == Some('-') {
|
||||
out.push_str("--[REDACTED_COMMENT]");
|
||||
i += 2;
|
||||
while i < chars.len() && chars[i] != '\n' && chars[i] != '\r' {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ch == '/' && next == Some('*') {
|
||||
out.push_str("/*[REDACTED_COMMENT]*/");
|
||||
i += 2;
|
||||
while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '/') {
|
||||
i += 1;
|
||||
}
|
||||
if i + 1 < chars.len() {
|
||||
i += 2;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push(ch);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn redact_sensitive_assignments(sql: &str) -> String {
|
||||
let chars: Vec<char> = sql.chars().collect();
|
||||
let mut out = String::new();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
if chars[i].is_whitespace() {
|
||||
out.push(chars[i]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
while i < chars.len()
|
||||
&& (chars[i].is_ascii_alphanumeric() || chars[i] == '_' || chars[i] == '-' || chars[i] == '.')
|
||||
{
|
||||
i += 1;
|
||||
}
|
||||
if i == start {
|
||||
out.push(chars[i]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let key: String = chars[start..i].iter().collect();
|
||||
let mut j = i;
|
||||
while j < chars.len() && chars[j].is_whitespace() {
|
||||
j += 1;
|
||||
}
|
||||
if j < chars.len() && (chars[j] == '=' || chars[j] == ':') && is_sensitive_key(&key) {
|
||||
out.push_str(&key);
|
||||
out.extend(chars[i..j].iter().copied());
|
||||
out.push(chars[j]);
|
||||
j += 1;
|
||||
while j < chars.len() && chars[j].is_whitespace() {
|
||||
out.push(chars[j]);
|
||||
j += 1;
|
||||
}
|
||||
while j < chars.len() && !chars[j].is_whitespace() {
|
||||
j += 1;
|
||||
}
|
||||
out.push_str("[REDACTED]");
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
out.push_str(&key);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn redact_sql_for_diagnostics(sql: &str) -> String {
|
||||
let max_chars = DEFAULT_SQL_DIAGNOSTIC_MAX_CHARS;
|
||||
let (bounded_sql, input_truncated) = bounded_input(sql, max_chars);
|
||||
truncate_for_diagnostics(redact_sensitive_assignments(&redact_literals(bounded_sql)), max_chars, input_truncated)
|
||||
}
|
||||
|
||||
pub fn debug_sql(scope: &str, sql: &str) {
|
||||
log::debug!("[{scope}] sql={}", redact_sql_for_diagnostics(sql));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn redacts_sensitive_literals_and_bounds_large_sql() {
|
||||
let sql = format!(
|
||||
"select * from users where password = 'secret-123' and api_key=abc and name = 'alice' {};",
|
||||
"x".repeat(900)
|
||||
);
|
||||
let redacted = redact_sql_for_diagnostics(&sql);
|
||||
assert!(!redacted.contains("secret-123"));
|
||||
assert!(!redacted.contains("api_key=abc"));
|
||||
assert!(!redacted.contains("alice"));
|
||||
// Literals not part of sensitive assignments retain single-quote redaction
|
||||
assert!(redacted.contains("'[REDACTED]'"), "name literal should be redacted: {}", redacted);
|
||||
// Sensitive assignment values are redacted with bracket notation
|
||||
assert!(redacted.contains("password = [REDACTED]"));
|
||||
assert!(redacted.contains("api_key=[REDACTED]"));
|
||||
assert!(redacted.contains("truncated"));
|
||||
assert!(redacted.len() < sql.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_space_separated_sensitive_assignments() {
|
||||
let sql = "select * from users where password = hunter2";
|
||||
let redacted = redact_sensitive_assignments(sql);
|
||||
assert!(!redacted.contains("hunter2"));
|
||||
assert!(redacted.contains("password = [REDACTED]"));
|
||||
assert!(redacted.contains("select"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_dollar_quoted_strings() {
|
||||
let sql = "select $$secret$$, $tag$hello$tag$ from t";
|
||||
let redacted = redact_sql_for_diagnostics(sql);
|
||||
assert!(redacted.contains("$$[REDACTED]$$"));
|
||||
assert!(redacted.contains("$[REDACTED]$"));
|
||||
assert!(!redacted.contains("secret"));
|
||||
assert!(!redacted.contains("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_input_bounded_allocation() {
|
||||
let sql = "x".repeat(1_000_000);
|
||||
let redacted = redact_sql_for_diagnostics(&sql);
|
||||
assert!(redacted.len() <= 550);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_inside_unclosed_literal_does_not_leak_prefix() {
|
||||
let sql = format!("select '{}'", "secret-".repeat(1_000));
|
||||
let redacted = truncate_for_diagnostics(redact_literals(bounded_input(&sql, 32).0), 32, true);
|
||||
assert!(!redacted.contains("secret-"));
|
||||
assert!(redacted.contains("[REDACTED]"));
|
||||
assert!(redacted.contains("truncated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_inside_sensitive_assignment_does_not_leak_prefix() {
|
||||
let sql = format!("password = {}", "secret-token".repeat(1_000));
|
||||
let (bounded, truncated) = bounded_input(&sql, 24);
|
||||
let redacted = truncate_for_diagnostics(redact_sensitive_assignments(&redact_literals(bounded)), 24, truncated);
|
||||
assert!(!redacted.contains("secret-token"));
|
||||
assert!(redacted.contains("password = [REDACTED]"));
|
||||
assert!(redacted.contains("truncated"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
mod capabilities;
|
||||
pub mod ddl_profile;
|
||||
pub mod descriptor;
|
||||
pub mod dialect_loader;
|
||||
pub mod dialect_types;
|
||||
pub mod dialect_yaml;
|
||||
pub mod hot_reload;
|
||||
mod identifiers;
|
||||
pub mod inference;
|
||||
mod table_select;
|
||||
pub mod type_rewrite;
|
||||
mod types;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod descriptor_snapshots;
|
||||
|
||||
pub use capabilities::{
|
||||
firebird_rows_clause, is_schema_aware, pagination_strategy, table_pagination_strategy, uses_fetch_first,
|
||||
uses_oracle_row_id, uses_single_row_insert_statements, uses_synthetic_row_id, uses_xugu_row_id, PaginationContext,
|
||||
TablePaginationStrategy,
|
||||
};
|
||||
pub use ddl_profile::{
|
||||
profile_for, AutoIncSyntax, DdlDialectProfile, IndexTypePlacement, QuoteStyle, RenameColumnSyntax, TriggerTemplate,
|
||||
TypeMapEntry,
|
||||
};
|
||||
pub use descriptor::{
|
||||
dialect_check, dialect_check_all, DialectCapabilityDescriptor, DialectInfo, DialectKind, TypeConversionRule,
|
||||
TypeMappingMatrix, CAP_ADD_COLUMN, CAP_ALTER_EXISTING_COLUMN, CAP_ALTER_OWNER, CAP_ALTER_PRIMARY_KEY,
|
||||
CAP_AUTO_INCREMENT, CAP_COMMENT, CAP_CREATE_FUNCTION, CAP_CREATE_INDEX, CAP_CREATE_OR_REPLACE, CAP_CREATE_SEQUENCE,
|
||||
CAP_CREATE_TABLE, CAP_CREATE_TRIGGER, CAP_DROP_COLUMN, CAP_DROP_FUNCTION, CAP_DROP_INDEX, CAP_DROP_SEQUENCE,
|
||||
CAP_DROP_TABLE, CAP_DROP_TRIGGER, CAP_FOREIGN_KEY, CAP_GRANT_REVOKE, CAP_IDENTITY_COLUMNS, CAP_IF_NOT_EXISTS,
|
||||
CAP_INDEX_COMMENT, CAP_INDEX_FILTER, CAP_INDEX_INCLUDE, CAP_INDEX_TYPE, CAP_REBUILD_INDEX, CAP_RENAME_COLUMN,
|
||||
CAP_REORDER_COLUMN, CAP_TEMPORARY_TABLE, CAP_TRANSACTIONAL_DDL, CAP_TRUNCATE_TABLE,
|
||||
};
|
||||
pub use identifiers::{
|
||||
normalize_where_input, qualified_table_name, qualified_table_name_with_catalog, quote_table_identifier,
|
||||
};
|
||||
pub use identifiers::{
|
||||
parse_sqlserver_linked_schema_ref, qualified_transfer_table, quote_iris_identifier, quote_transfer_identifier,
|
||||
transfer_column_identifier,
|
||||
};
|
||||
pub use table_select::{
|
||||
build_count_table_sql, build_table_data_select_sql, build_table_data_select_sql_with_database,
|
||||
build_table_select_sql, DBX_LARGE_VALUE_BYTES_COLUMN_PREFIX,
|
||||
};
|
||||
pub use table_select::{
|
||||
quote_table_data_identifier, table_data_qualified_table_name, table_data_schema, uses_connection_identifier_quote,
|
||||
};
|
||||
pub use type_rewrite::{
|
||||
apply_auto_inc_to_column_def, column_is_auto_increment, normalize_len_params, rewrite_column_type,
|
||||
split_type_base_params, type_looks_integer, AutoIncColumnBuild,
|
||||
};
|
||||
pub use types::*;
|
||||
|
||||
/// Resolve a dialect descriptor for the given kind.
|
||||
pub fn resolve(kind: descriptor::DialectKind) -> descriptor::DialectCapabilityDescriptor {
|
||||
lazy_init();
|
||||
if let Some(desc) = dialect_loader::DialectRegistry::global().get_descriptor(kind.label()) {
|
||||
return desc;
|
||||
}
|
||||
descriptor::DialectCapabilityDescriptor::for_dialect(kind)
|
||||
}
|
||||
|
||||
fn lazy_init() {
|
||||
use std::sync::OnceLock;
|
||||
static INIT: OnceLock<()> = OnceLock::new();
|
||||
INIT.get_or_init(|| {
|
||||
dialect_loader::register_core_dialects();
|
||||
let _ = crate::dml_binding::DmlCleanRuleRegistry::load_default();
|
||||
});
|
||||
}
|
||||
|
||||
pub fn resolve_for_db(db_type: crate::models::connection::DatabaseType) -> descriptor::DialectCapabilityDescriptor {
|
||||
let kind = descriptor::DialectKind::from_database_type(db_type);
|
||||
resolve(kind)
|
||||
}
|
||||
|
||||
pub fn resolve_label(kind: descriptor::DialectKind) -> String {
|
||||
if let Some(loaded) = dialect_loader::DialectRegistry::global().get(kind.label()) {
|
||||
return loaded.yaml.dialect.display_name.clone().unwrap_or_else(|| kind.label().to_string());
|
||||
}
|
||||
kind.label().to_string()
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TablePaginationStrategy {
|
||||
LimitOffset,
|
||||
FetchFirst,
|
||||
Db2FetchFirst,
|
||||
SqlServerTop,
|
||||
IrisTop,
|
||||
InformixFirst,
|
||||
FirebirdRows,
|
||||
Rownum,
|
||||
QuestDbLimit,
|
||||
Unbounded,
|
||||
AgentMaxRows,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PaginationContext {
|
||||
TablePreview,
|
||||
BoundedRead,
|
||||
UserQuery,
|
||||
}
|
||||
|
||||
pub fn is_schema_aware(database_type: DatabaseType) -> bool {
|
||||
matches!(
|
||||
database_type,
|
||||
DatabaseType::Postgres
|
||||
| DatabaseType::SqlServer
|
||||
| DatabaseType::Oracle
|
||||
| DatabaseType::Redshift
|
||||
| DatabaseType::Dameng
|
||||
| DatabaseType::Gaussdb
|
||||
| DatabaseType::Kwdb
|
||||
| DatabaseType::Kingbase
|
||||
| DatabaseType::Highgo
|
||||
| DatabaseType::Uxdb
|
||||
| DatabaseType::Vastbase
|
||||
| DatabaseType::Yashandb
|
||||
| DatabaseType::Oscar
|
||||
| DatabaseType::Databricks
|
||||
| DatabaseType::SapHana
|
||||
| DatabaseType::Teradata
|
||||
| DatabaseType::Vertica
|
||||
| DatabaseType::Exasol
|
||||
| DatabaseType::OpenGauss
|
||||
| DatabaseType::OceanbaseOracle
|
||||
| DatabaseType::Gbase
|
||||
| DatabaseType::Databend
|
||||
| DatabaseType::Jdbc
|
||||
| DatabaseType::H2
|
||||
| DatabaseType::Snowflake
|
||||
| DatabaseType::Trino
|
||||
| DatabaseType::PrestoSql
|
||||
| DatabaseType::Hive
|
||||
| DatabaseType::Kyuubi
|
||||
| DatabaseType::Impala
|
||||
| DatabaseType::Argo
|
||||
| DatabaseType::Spark
|
||||
| DatabaseType::Db2
|
||||
| DatabaseType::Informix
|
||||
| DatabaseType::Tdengine
|
||||
| DatabaseType::Xugu
|
||||
| DatabaseType::Sqlite
|
||||
| DatabaseType::DuckDb
|
||||
| DatabaseType::Iris
|
||||
// Spanner supports named schemas; the PostgreSQL dialect defaults to `public`.
|
||||
// GoogleSQL's default schema is the empty string, which the blank-schema filters
|
||||
// in `qualified_table_name` / `table_data_qualified_table_name` drop along with
|
||||
// the dot separator (`` `s`.`t` `` with an empty `s` is a Spanner syntax error).
|
||||
| DatabaseType::Spanner
|
||||
)
|
||||
}
|
||||
|
||||
pub fn uses_fetch_first(database_type: DatabaseType) -> bool {
|
||||
matches!(database_type, DatabaseType::Oracle | DatabaseType::Dameng | DatabaseType::Db2)
|
||||
}
|
||||
|
||||
pub fn uses_oracle_row_id(database_type: Option<DatabaseType>) -> bool {
|
||||
matches!(database_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle))
|
||||
}
|
||||
|
||||
/// Xugu exposes an unqualified ROWID pseudo-column for base, partitioned and
|
||||
/// temporary tables. It is intentionally separate from Oracle's ROWIDTOCHAR
|
||||
/// representation because qualified ROWID and ROWIDTOCHAR are not supported
|
||||
/// by Xugu.
|
||||
pub fn uses_xugu_row_id(database_type: Option<DatabaseType>) -> bool {
|
||||
database_type == Some(DatabaseType::Xugu)
|
||||
}
|
||||
|
||||
pub fn uses_synthetic_row_id(database_type: Option<DatabaseType>) -> bool {
|
||||
uses_oracle_row_id(database_type) || uses_xugu_row_id(database_type)
|
||||
}
|
||||
|
||||
/// Oracle 系方言不支持 `INSERT ... VALUES (...), (...)` 多行语法,
|
||||
/// 复制为 INSERT 与导出 INSERT 都需按行生成单条语句。
|
||||
pub fn uses_single_row_insert_statements(database_type: DatabaseType) -> bool {
|
||||
matches!(database_type, DatabaseType::Oracle | DatabaseType::OceanbaseOracle)
|
||||
}
|
||||
|
||||
pub fn pagination_strategy(database_type: Option<DatabaseType>, context: PaginationContext) -> TablePaginationStrategy {
|
||||
match database_type {
|
||||
Some(DatabaseType::Jdbc) => TablePaginationStrategy::AgentMaxRows,
|
||||
Some(DatabaseType::Oracle) if matches!(context, PaginationContext::TablePreview) => {
|
||||
TablePaginationStrategy::Rownum
|
||||
}
|
||||
// Oracle's row-limiting clause (`FETCH FIRST`/`OFFSET ... FETCH`) was
|
||||
// introduced in 12c. ROWNUM remains compatible with the supported 11g
|
||||
// baseline while still providing a bounded read for newer servers.
|
||||
Some(DatabaseType::Oracle) if matches!(context, PaginationContext::BoundedRead) => {
|
||||
TablePaginationStrategy::Rownum
|
||||
}
|
||||
Some(DatabaseType::Oracle) => TablePaginationStrategy::Unbounded,
|
||||
Some(DatabaseType::Oscar)
|
||||
if matches!(context, PaginationContext::TablePreview | PaginationContext::BoundedRead) =>
|
||||
{
|
||||
TablePaginationStrategy::Rownum
|
||||
}
|
||||
Some(DatabaseType::Oscar) => TablePaginationStrategy::Unbounded,
|
||||
Some(DatabaseType::Dameng) => TablePaginationStrategy::Rownum,
|
||||
Some(DatabaseType::Db2) => TablePaginationStrategy::Db2FetchFirst,
|
||||
Some(DatabaseType::SqlServer) => TablePaginationStrategy::SqlServerTop,
|
||||
Some(DatabaseType::Iris) => TablePaginationStrategy::IrisTop,
|
||||
Some(DatabaseType::Informix) => TablePaginationStrategy::InformixFirst,
|
||||
Some(DatabaseType::Firebird) => TablePaginationStrategy::FirebirdRows,
|
||||
Some(DatabaseType::OceanbaseOracle) => TablePaginationStrategy::Rownum,
|
||||
Some(DatabaseType::Questdb) => TablePaginationStrategy::QuestDbLimit,
|
||||
_ => TablePaginationStrategy::LimitOffset,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn table_pagination_strategy(database_type: Option<DatabaseType>) -> TablePaginationStrategy {
|
||||
pagination_strategy(database_type, PaginationContext::TablePreview)
|
||||
}
|
||||
|
||||
pub(super) fn is_simple_informix_identifier(name: &str) -> bool {
|
||||
let mut chars = name.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
(first.is_ascii_alphabetic() || first == '_')
|
||||
&& chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '$')
|
||||
}
|
||||
|
||||
pub fn firebird_rows_clause(limit: usize, offset: usize) -> String {
|
||||
if offset > 0 {
|
||||
let start = offset + 1;
|
||||
let end = offset + limit;
|
||||
format!("ROWS {start} TO {end}")
|
||||
} else {
|
||||
format!("ROWS {limit}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,971 @@
|
||||
//! Target-database DDL profile: how to *write* SQL for a concrete [`DatabaseType`].
|
||||
//!
|
||||
//! Profiles are data + small enums. Call sites must not branch on individual databases;
|
||||
//! they only consult profile fields (quote style, auto-increment form, type map, …).
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
/// Identifier quoting style for generated DDL.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum QuoteStyle {
|
||||
/// MySQL-family: `name`
|
||||
Backtick,
|
||||
/// PostgreSQL / Access / most ANSI: "name"
|
||||
DoubleQuote,
|
||||
/// SQL Server: [name]
|
||||
Brackets,
|
||||
/// Oracle-style unquoted uppercase
|
||||
UnquotedUpper,
|
||||
}
|
||||
|
||||
/// How auto-increment / identity is expressed on the target database.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AutoIncSyntax {
|
||||
/// No special auto-increment DDL.
|
||||
None,
|
||||
/// Append a fixed suffix to the column definition (e.g. ` AUTO_INCREMENT`, ` IDENTITY(1,1)`).
|
||||
Suffix(&'static str),
|
||||
/// Replace the mapped type with this type name for auto PK columns (e.g. Access `COUNTER`).
|
||||
ReplaceTypeWith(&'static str),
|
||||
/// PostgreSQL-style sequence + DEFAULT nextval (handled by generator after CREATE).
|
||||
PostgresSequence,
|
||||
}
|
||||
|
||||
/// Static type rewrite rule: source base type (uppercase, no params) → target template.
|
||||
/// Target may use `{}` for a single length/precision placeholder (first param only).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TypeMapEntry {
|
||||
pub source_base: &'static str,
|
||||
pub target_template: &'static str,
|
||||
}
|
||||
|
||||
/// How CREATE INDEX places the index method / type.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum IndexTypePlacement {
|
||||
/// No index-type clause.
|
||||
None,
|
||||
/// PostgreSQL: `CREATE INDEX … ON t USING btree (…)`
|
||||
UsingSuffix,
|
||||
/// SQL Server: `CREATE CLUSTERED INDEX …`
|
||||
TypePrefix,
|
||||
/// MySQL: `CREATE INDEX … USING BTREE ON t (…)`
|
||||
UsingBeforeOn,
|
||||
}
|
||||
|
||||
/// CREATE TRIGGER body shape (templates use `{name}` `{timing}` `{event}` `{table}` `{body}`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TriggerTemplate {
|
||||
/// `CREATE TRIGGER {name} {timing} {event} ON {table} FOR EACH ROW BEGIN {body} END;`
|
||||
MysqlStyle,
|
||||
/// `CREATE TRIGGER {name} {timing} {event} ON {table} FOR EACH ROW EXECUTE FUNCTION {body};`
|
||||
PostgresStyle,
|
||||
/// `CREATE TRIGGER {name} ON {table} {timing} {event} AS BEGIN {body} END;`
|
||||
SqlServerStyle,
|
||||
/// Conservative MySQL-like default for unknown engines.
|
||||
GenericRowBody,
|
||||
}
|
||||
|
||||
/// RENAME COLUMN strategy inside ALTER TABLE.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RenameColumnSyntax {
|
||||
/// `CHANGE COLUMN old new_def` (MySQL)
|
||||
MysqlChangeColumn,
|
||||
/// `RENAME COLUMN old TO new`
|
||||
RenameColumn,
|
||||
/// `ALTER COLUMN old RENAME TO new` (H2)
|
||||
AlterColumnRenameTo,
|
||||
/// `EXEC sp_rename 't.old', 'new', 'COLUMN'`
|
||||
SqlServerSpRename,
|
||||
}
|
||||
|
||||
/// Column DDL that cannot use the generic `ADD COLUMN` / `ALTER COLUMN` forms.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ColumnDdlSyntax {
|
||||
Generic,
|
||||
/// SQL Server uses `ADD` without `COLUMN`, a full definition for
|
||||
/// `ALTER COLUMN`, and separate default-constraint objects.
|
||||
SqlServer,
|
||||
}
|
||||
|
||||
/// Standalone table and column comment representation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommentDdlSyntax {
|
||||
Generic,
|
||||
/// SQL Server stores comments in the `MS_Description` extended property.
|
||||
SqlServerExtendedProperty,
|
||||
}
|
||||
|
||||
/// DDL generation profile for one [`DatabaseType`].
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct DdlDialectProfile {
|
||||
pub database_type: DatabaseType,
|
||||
pub quote: QuoteStyle,
|
||||
pub auto_inc: AutoIncSyntax,
|
||||
/// When false, display widths like `INT(11)` are stripped if no type_map hit.
|
||||
pub supports_display_width: bool,
|
||||
/// Whether this target's VARCHAR/CHARACTER VARYING/NVARCHAR syntax rejects a
|
||||
/// missing length (e.g. `VARCHAR` with no length is a MySQL syntax error).
|
||||
/// Distinct from `supports_display_width`: that flag is about whether the
|
||||
/// target *prints* MySQL-style `INT(11)` widths, not whether VARCHAR needs one.
|
||||
pub requires_explicit_varchar_length: bool,
|
||||
/// Cap for length params when applying `{}` templates (e.g. Access TEXT max 255).
|
||||
pub max_varchar_len: Option<u32>,
|
||||
/// MySQL-style inline `COMMENT '...'` on columns.
|
||||
pub inline_column_comment: bool,
|
||||
/// SQLite-style: emit FOREIGN KEY clauses inside CREATE TABLE.
|
||||
pub foreign_keys_inline_in_create: bool,
|
||||
/// `DROP INDEX name ON table` (MySQL) vs `DROP INDEX IF EXISTS name`.
|
||||
pub drop_index_uses_on_table: bool,
|
||||
/// `DROP FOREIGN KEY` (MySQL) vs `DROP CONSTRAINT`.
|
||||
pub drop_fk_as_foreign_key: bool,
|
||||
/// `DROP TABLE t CASCADE` is accepted. Oracle's `CASCADE CONSTRAINTS` is a
|
||||
/// different clause and does not count.
|
||||
pub drop_table_supports_cascade: bool,
|
||||
/// `DROP TABLE IF EXISTS t` is valid grammar. False for Oracle (pre-23c),
|
||||
/// Access, Firebird, Db2, Informix, SAP HANA and Teradata.
|
||||
pub drop_table_supports_if_exists: bool,
|
||||
/// Index method placement.
|
||||
pub index_type_placement: IndexTypePlacement,
|
||||
/// `INCLUDE (cols)` on indexes.
|
||||
pub index_supports_include: bool,
|
||||
/// Partial index `WHERE …`.
|
||||
pub index_supports_filter: bool,
|
||||
/// Index `COMMENT '…'` (MySQL).
|
||||
pub index_supports_comment: bool,
|
||||
/// Table comment: `ALTER TABLE t COMMENT = '…'` vs `COMMENT ON TABLE`.
|
||||
pub table_comment_via_alter: bool,
|
||||
/// Standalone column comment SQL is unsupported (MySQL needs MODIFY COLUMN).
|
||||
pub column_comment_via_modify_only: bool,
|
||||
pub comment_ddl: CommentDdlSyntax,
|
||||
pub trigger_template: TriggerTemplate,
|
||||
pub rename_column: RenameColumnSyntax,
|
||||
pub column_ddl: ColumnDdlSyntax,
|
||||
/// MySQL `MODIFY COLUMN` vs ANSI `ALTER COLUMN … TYPE/SET`.
|
||||
pub alter_uses_modify_column: bool,
|
||||
/// Batch multiple alter clauses in one `ALTER TABLE` statement.
|
||||
pub alter_batches_clauses: bool,
|
||||
/// Prefer emitting source SHOW CREATE / native DDL when dialects match (MySQL-family).
|
||||
pub prefers_native_source_ddl: bool,
|
||||
/// GRANT/REVOKE identifier style: backtick + user quotes vs ANSI double-quote.
|
||||
pub grant_uses_mysql_user_syntax: bool,
|
||||
/// Emit a comment that FK changes may need table rebuild (SQLite-family).
|
||||
pub warn_fk_needs_table_rebuild: bool,
|
||||
pub create_table_if_not_exists: bool,
|
||||
pub create_index_if_not_exists: bool,
|
||||
pub create_function_or_replace: bool,
|
||||
pub supports_function_ddl: bool,
|
||||
pub supports_sequence_ddl: bool,
|
||||
pub supports_rule_ddl: bool,
|
||||
pub supports_owner_ddl: bool,
|
||||
/// `{create_kw} {name} {definition};` — `create_kw` is CREATE [OR REPLACE] FUNCTION.
|
||||
pub function_create_template: Option<&'static str>,
|
||||
/// `DROP FUNCTION IF EXISTS {name}{cascade};`
|
||||
pub function_drop_template: Option<&'static str>,
|
||||
/// `CREATE SEQUENCE {name} AS {data_type} START WITH {start_value} … {cycle};`
|
||||
pub sequence_create_template: Option<&'static str>,
|
||||
/// `ALTER SEQUENCE {name} AS {data_type} START WITH {start_value} … {cycle};`
|
||||
pub sequence_alter_template: Option<&'static str>,
|
||||
/// `DROP SEQUENCE {name}{cascade};`
|
||||
pub sequence_drop_template: Option<&'static str>,
|
||||
/// `DROP RULE IF EXISTS {rule_name} ON {table_name};`
|
||||
pub rule_drop_template: Option<&'static str>,
|
||||
/// `ALTER {object_type} {name} OWNER TO {owner};`
|
||||
pub owner_alter_template: Option<&'static str>,
|
||||
/// `DROP TABLE {table}{cascade};` — the caller quotes `{table}` itself. Engines
|
||||
/// that drop a differently-named object override the whole shape.
|
||||
pub drop_table_template: &'static str,
|
||||
/// Optional session lock-timeout preamble for generated scripts.
|
||||
pub lock_timeout_sql: Option<&'static str>,
|
||||
/// Data-driven base-type rewrites for this target (empty → rely on matrix / normalize only).
|
||||
pub type_map: &'static [TypeMapEntry],
|
||||
}
|
||||
|
||||
impl DdlDialectProfile {
|
||||
pub fn lookup_type(&self, source_base_upper: &str) -> Option<&'static str> {
|
||||
self.type_map.iter().find(|e| e.source_base.eq_ignore_ascii_case(source_base_upper)).map(|e| e.target_template)
|
||||
}
|
||||
|
||||
pub fn quote_ident(&self, name: &str) -> String {
|
||||
match self.quote {
|
||||
QuoteStyle::Backtick => format!("`{}`", name.replace('`', "``")),
|
||||
QuoteStyle::DoubleQuote => format!("\"{}\"", name.replace('"', "\"\"")),
|
||||
QuoteStyle::Brackets => format!("[{}]", name.replace(']', "]]")),
|
||||
// Oracle: unquoted identifiers fold to uppercase. Mixed-case names and
|
||||
// special characters must be double-quoted with original spelling preserved.
|
||||
QuoteStyle::UnquotedUpper => {
|
||||
let has_lower = name.chars().any(|c| c.is_ascii_lowercase());
|
||||
let has_upper = name.chars().any(|c| c.is_ascii_uppercase());
|
||||
let has_special = name.chars().any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '$' && c != '#');
|
||||
let bad_start =
|
||||
name.is_empty() || !name.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
|
||||
// All-lower "emp" stays unquoted → EMP (Oracle fold). Mixed "empMixed" → "empMixed".
|
||||
let needs_quotes = (has_lower && has_upper) || has_special || bad_start;
|
||||
if needs_quotes {
|
||||
format!("\"{}\"", name.replace('"', "\"\""))
|
||||
} else {
|
||||
name.to_uppercase()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn alter_modify_keyword(&self) -> &'static str {
|
||||
if self.database_type == DatabaseType::Dameng {
|
||||
"MODIFY"
|
||||
} else {
|
||||
"MODIFY COLUMN"
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace `{key}` placeholders. Unknown keys are left unchanged.
|
||||
pub fn render_template(template: &str, vars: &[(&str, &str)]) -> String {
|
||||
let mut out = template.to_string();
|
||||
for (key, value) in vars {
|
||||
out = out.replace(&format!("{{{key}}}"), value);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
const FN_CREATE: &str = "{create_kw} {name} {definition};";
|
||||
const FN_DROP: &str = "DROP FUNCTION IF EXISTS {name}{cascade};";
|
||||
const SEQ_CREATE: &str =
|
||||
"CREATE SEQUENCE {name} AS {data_type} START WITH {start_value} INCREMENT BY {increment} MINVALUE {min_value} MAXVALUE {max_value} {cycle};";
|
||||
const SEQ_ALTER: &str =
|
||||
"ALTER SEQUENCE {name} AS {data_type} START WITH {start_value} INCREMENT BY {increment} MINVALUE {min_value} MAXVALUE {max_value} {cycle};";
|
||||
const SEQ_DROP: &str = "DROP SEQUENCE {name}{cascade};";
|
||||
const SQLSERVER_FN_DROP: &str = "DROP FUNCTION {name};";
|
||||
const SQLSERVER_SEQ_ALTER: &str =
|
||||
"ALTER SEQUENCE {name} RESTART WITH {start_value} INCREMENT BY {increment} MINVALUE {min_value} MAXVALUE {max_value} {cycle};";
|
||||
const RULE_DROP: &str = "DROP RULE IF EXISTS {rule_name} ON {table_name}{cascade};";
|
||||
const OWNER_ALTER: &str = "ALTER {object_type} {name} OWNER TO {owner};";
|
||||
/// Shared ANSI `DROP TABLE` shape. `{if_exists}` is deliberately absent: whether a
|
||||
/// dialect accepts `IF EXISTS` is a capability flag, not part of the rendered shape.
|
||||
const DROP_TABLE: &str = "DROP TABLE {table}{cascade};";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type maps (data only — no per-database logic at call sites)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ACCESS_TYPE_MAP: &[TypeMapEntry] = &[
|
||||
TypeMapEntry { source_base: "BOOL", target_template: "YESNO" },
|
||||
TypeMapEntry { source_base: "BOOLEAN", target_template: "YESNO" },
|
||||
TypeMapEntry { source_base: "BIT", target_template: "YESNO" },
|
||||
TypeMapEntry { source_base: "YESNO", target_template: "YESNO" },
|
||||
TypeMapEntry { source_base: "TINYINT", target_template: "BYTE" },
|
||||
TypeMapEntry { source_base: "BYTE", target_template: "BYTE" },
|
||||
TypeMapEntry { source_base: "SMALLINT", target_template: "SMALLINT" },
|
||||
TypeMapEntry { source_base: "SHORT", target_template: "SMALLINT" },
|
||||
TypeMapEntry { source_base: "MEDIUMINT", target_template: "INTEGER" },
|
||||
TypeMapEntry { source_base: "INT", target_template: "INTEGER" },
|
||||
TypeMapEntry { source_base: "INTEGER", target_template: "INTEGER" },
|
||||
TypeMapEntry { source_base: "INT4", target_template: "INTEGER" },
|
||||
TypeMapEntry { source_base: "LONG", target_template: "INTEGER" },
|
||||
TypeMapEntry { source_base: "BIGINT", target_template: "DECIMAL(20,0)" },
|
||||
TypeMapEntry { source_base: "INT8", target_template: "DECIMAL(20,0)" },
|
||||
TypeMapEntry { source_base: "FLOAT", target_template: "SINGLE" },
|
||||
TypeMapEntry { source_base: "REAL", target_template: "SINGLE" },
|
||||
TypeMapEntry { source_base: "SINGLE", target_template: "SINGLE" },
|
||||
TypeMapEntry { source_base: "DOUBLE", target_template: "DOUBLE" },
|
||||
TypeMapEntry { source_base: "DOUBLE PRECISION", target_template: "DOUBLE" },
|
||||
TypeMapEntry { source_base: "DECIMAL", target_template: "DECIMAL({})" },
|
||||
TypeMapEntry { source_base: "NUMERIC", target_template: "DECIMAL({})" },
|
||||
TypeMapEntry { source_base: "NUMBER", target_template: "DECIMAL({})" },
|
||||
TypeMapEntry { source_base: "CURRENCY", target_template: "CURRENCY" },
|
||||
TypeMapEntry { source_base: "VARCHAR", target_template: "TEXT({})" },
|
||||
TypeMapEntry { source_base: "CHARACTER VARYING", target_template: "TEXT({})" },
|
||||
TypeMapEntry { source_base: "CHAR", target_template: "TEXT({})" },
|
||||
TypeMapEntry { source_base: "CHARACTER", target_template: "TEXT({})" },
|
||||
TypeMapEntry { source_base: "NVARCHAR", target_template: "TEXT({})" },
|
||||
TypeMapEntry { source_base: "NVARCHAR2", target_template: "TEXT({})" },
|
||||
TypeMapEntry { source_base: "VARCHAR2", target_template: "TEXT({})" },
|
||||
TypeMapEntry { source_base: "TEXT", target_template: "LONGTEXT" },
|
||||
TypeMapEntry { source_base: "TINYTEXT", target_template: "LONGTEXT" },
|
||||
TypeMapEntry { source_base: "MEDIUMTEXT", target_template: "LONGTEXT" },
|
||||
TypeMapEntry { source_base: "LONGTEXT", target_template: "LONGTEXT" },
|
||||
TypeMapEntry { source_base: "CLOB", target_template: "LONGTEXT" },
|
||||
TypeMapEntry { source_base: "MEMO", target_template: "LONGTEXT" },
|
||||
TypeMapEntry { source_base: "DATE", target_template: "DATETIME" },
|
||||
TypeMapEntry { source_base: "DATETIME", target_template: "DATETIME" },
|
||||
TypeMapEntry { source_base: "TIMESTAMP", target_template: "DATETIME" },
|
||||
TypeMapEntry { source_base: "TIME", target_template: "DATETIME" },
|
||||
TypeMapEntry { source_base: "YEAR", target_template: "SMALLINT" },
|
||||
TypeMapEntry { source_base: "BLOB", target_template: "OLEOBJECT" },
|
||||
TypeMapEntry { source_base: "TINYBLOB", target_template: "OLEOBJECT" },
|
||||
TypeMapEntry { source_base: "MEDIUMBLOB", target_template: "OLEOBJECT" },
|
||||
TypeMapEntry { source_base: "LONGBLOB", target_template: "OLEOBJECT" },
|
||||
TypeMapEntry { source_base: "BINARY", target_template: "OLEOBJECT" },
|
||||
TypeMapEntry { source_base: "VARBINARY", target_template: "OLEOBJECT" },
|
||||
TypeMapEntry { source_base: "IMAGE", target_template: "OLEOBJECT" },
|
||||
TypeMapEntry { source_base: "JSON", target_template: "LONGTEXT" },
|
||||
TypeMapEntry { source_base: "JSONB", target_template: "LONGTEXT" },
|
||||
TypeMapEntry { source_base: "UUID", target_template: "GUID" },
|
||||
TypeMapEntry { source_base: "UNIQUEIDENTIFIER", target_template: "GUID" },
|
||||
TypeMapEntry { source_base: "GUID", target_template: "GUID" },
|
||||
];
|
||||
|
||||
const SQLITE_TYPE_MAP: &[TypeMapEntry] = &[
|
||||
TypeMapEntry { source_base: "TINYINT", target_template: "INTEGER" },
|
||||
TypeMapEntry { source_base: "SMALLINT", target_template: "INTEGER" },
|
||||
TypeMapEntry { source_base: "MEDIUMINT", target_template: "INTEGER" },
|
||||
TypeMapEntry { source_base: "INT", target_template: "INTEGER" },
|
||||
TypeMapEntry { source_base: "INTEGER", target_template: "INTEGER" },
|
||||
TypeMapEntry { source_base: "BIGINT", target_template: "INTEGER" },
|
||||
TypeMapEntry { source_base: "FLOAT", target_template: "REAL" },
|
||||
TypeMapEntry { source_base: "DOUBLE", target_template: "REAL" },
|
||||
TypeMapEntry { source_base: "DOUBLE PRECISION", target_template: "REAL" },
|
||||
TypeMapEntry { source_base: "DECIMAL", target_template: "NUMERIC" },
|
||||
TypeMapEntry { source_base: "NUMERIC", target_template: "NUMERIC" },
|
||||
TypeMapEntry { source_base: "VARCHAR", target_template: "TEXT" },
|
||||
TypeMapEntry { source_base: "CHAR", target_template: "TEXT" },
|
||||
TypeMapEntry { source_base: "TEXT", target_template: "TEXT" },
|
||||
TypeMapEntry { source_base: "TINYTEXT", target_template: "TEXT" },
|
||||
TypeMapEntry { source_base: "MEDIUMTEXT", target_template: "TEXT" },
|
||||
TypeMapEntry { source_base: "LONGTEXT", target_template: "TEXT" },
|
||||
TypeMapEntry { source_base: "DATETIME", target_template: "TEXT" },
|
||||
TypeMapEntry { source_base: "TIMESTAMP", target_template: "TEXT" },
|
||||
TypeMapEntry { source_base: "DATE", target_template: "TEXT" },
|
||||
TypeMapEntry { source_base: "BLOB", target_template: "BLOB" },
|
||||
TypeMapEntry { source_base: "JSON", target_template: "TEXT" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile families (shared shapes; registration is the only DatabaseType match)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn mysql_family(db: DatabaseType) -> DdlDialectProfile {
|
||||
DdlDialectProfile {
|
||||
database_type: db,
|
||||
quote: QuoteStyle::Backtick,
|
||||
auto_inc: AutoIncSyntax::Suffix(" AUTO_INCREMENT"),
|
||||
supports_display_width: true,
|
||||
requires_explicit_varchar_length: true,
|
||||
max_varchar_len: Some(65_535),
|
||||
inline_column_comment: true,
|
||||
foreign_keys_inline_in_create: false,
|
||||
drop_index_uses_on_table: true,
|
||||
drop_fk_as_foreign_key: true,
|
||||
drop_table_supports_cascade: false,
|
||||
drop_table_supports_if_exists: true,
|
||||
index_type_placement: IndexTypePlacement::UsingBeforeOn,
|
||||
index_supports_include: false,
|
||||
index_supports_filter: false,
|
||||
index_supports_comment: true,
|
||||
table_comment_via_alter: true,
|
||||
column_comment_via_modify_only: true,
|
||||
comment_ddl: CommentDdlSyntax::Generic,
|
||||
trigger_template: TriggerTemplate::MysqlStyle,
|
||||
rename_column: RenameColumnSyntax::MysqlChangeColumn,
|
||||
column_ddl: ColumnDdlSyntax::Generic,
|
||||
prefers_native_source_ddl: true,
|
||||
grant_uses_mysql_user_syntax: true,
|
||||
warn_fk_needs_table_rebuild: false,
|
||||
alter_uses_modify_column: true,
|
||||
alter_batches_clauses: true,
|
||||
create_table_if_not_exists: true,
|
||||
create_index_if_not_exists: false,
|
||||
create_function_or_replace: false,
|
||||
supports_function_ddl: false,
|
||||
supports_sequence_ddl: false,
|
||||
supports_rule_ddl: false,
|
||||
supports_owner_ddl: false,
|
||||
function_create_template: None,
|
||||
function_drop_template: None,
|
||||
sequence_create_template: None,
|
||||
sequence_alter_template: None,
|
||||
sequence_drop_template: None,
|
||||
rule_drop_template: None,
|
||||
owner_alter_template: None,
|
||||
drop_table_template: DROP_TABLE,
|
||||
lock_timeout_sql: Some("SET SESSION lock_wait_timeout = 3;"),
|
||||
type_map: &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn postgres_family(db: DatabaseType) -> DdlDialectProfile {
|
||||
DdlDialectProfile {
|
||||
database_type: db,
|
||||
quote: QuoteStyle::DoubleQuote,
|
||||
auto_inc: AutoIncSyntax::PostgresSequence,
|
||||
supports_display_width: false,
|
||||
requires_explicit_varchar_length: false,
|
||||
max_varchar_len: None,
|
||||
inline_column_comment: false,
|
||||
foreign_keys_inline_in_create: false,
|
||||
drop_index_uses_on_table: false,
|
||||
drop_fk_as_foreign_key: false,
|
||||
drop_table_supports_cascade: true,
|
||||
drop_table_supports_if_exists: true,
|
||||
index_type_placement: IndexTypePlacement::UsingSuffix,
|
||||
index_supports_include: true,
|
||||
index_supports_filter: true,
|
||||
index_supports_comment: false,
|
||||
table_comment_via_alter: false,
|
||||
column_comment_via_modify_only: false,
|
||||
comment_ddl: CommentDdlSyntax::Generic,
|
||||
trigger_template: TriggerTemplate::PostgresStyle,
|
||||
rename_column: RenameColumnSyntax::RenameColumn,
|
||||
column_ddl: ColumnDdlSyntax::Generic,
|
||||
prefers_native_source_ddl: false,
|
||||
grant_uses_mysql_user_syntax: false,
|
||||
warn_fk_needs_table_rebuild: false,
|
||||
alter_uses_modify_column: false,
|
||||
alter_batches_clauses: false,
|
||||
create_table_if_not_exists: false,
|
||||
create_index_if_not_exists: true,
|
||||
create_function_or_replace: true,
|
||||
supports_function_ddl: true,
|
||||
supports_sequence_ddl: true,
|
||||
supports_rule_ddl: true,
|
||||
supports_owner_ddl: true,
|
||||
function_create_template: Some(FN_CREATE),
|
||||
function_drop_template: Some(FN_DROP),
|
||||
sequence_create_template: Some(SEQ_CREATE),
|
||||
sequence_alter_template: Some(SEQ_ALTER),
|
||||
sequence_drop_template: Some(SEQ_DROP),
|
||||
rule_drop_template: Some(RULE_DROP),
|
||||
owner_alter_template: Some(OWNER_ALTER),
|
||||
drop_table_template: DROP_TABLE,
|
||||
lock_timeout_sql: Some("SET lock_timeout = '3s';"),
|
||||
type_map: &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn oracle_family(db: DatabaseType) -> DdlDialectProfile {
|
||||
DdlDialectProfile {
|
||||
database_type: db,
|
||||
quote: QuoteStyle::UnquotedUpper,
|
||||
auto_inc: AutoIncSyntax::Suffix(" GENERATED BY DEFAULT AS IDENTITY"),
|
||||
supports_display_width: false,
|
||||
// Oracle's VARCHAR2 also rejects a missing length in real DDL, but this
|
||||
// is left false (unverified, no Oracle instance in scope for this
|
||||
// change) rather than asserting untested cross-dialect behavior.
|
||||
requires_explicit_varchar_length: false,
|
||||
max_varchar_len: Some(4000),
|
||||
inline_column_comment: false,
|
||||
foreign_keys_inline_in_create: false,
|
||||
drop_index_uses_on_table: false,
|
||||
drop_fk_as_foreign_key: false,
|
||||
// Oracle spells it `CASCADE CONSTRAINTS`, and `IF EXISTS` only landed in 23c.
|
||||
drop_table_supports_cascade: false,
|
||||
drop_table_supports_if_exists: false,
|
||||
index_type_placement: IndexTypePlacement::None,
|
||||
index_supports_include: false,
|
||||
index_supports_filter: false,
|
||||
index_supports_comment: false,
|
||||
table_comment_via_alter: false,
|
||||
column_comment_via_modify_only: false,
|
||||
comment_ddl: CommentDdlSyntax::Generic,
|
||||
trigger_template: TriggerTemplate::GenericRowBody,
|
||||
rename_column: RenameColumnSyntax::RenameColumn,
|
||||
column_ddl: ColumnDdlSyntax::Generic,
|
||||
prefers_native_source_ddl: false,
|
||||
grant_uses_mysql_user_syntax: false,
|
||||
warn_fk_needs_table_rebuild: false,
|
||||
alter_uses_modify_column: false,
|
||||
alter_batches_clauses: false,
|
||||
create_table_if_not_exists: false,
|
||||
create_index_if_not_exists: false,
|
||||
create_function_or_replace: false,
|
||||
supports_function_ddl: true,
|
||||
supports_sequence_ddl: true,
|
||||
supports_rule_ddl: false,
|
||||
supports_owner_ddl: false,
|
||||
// Same ANSI shapes as Postgres for the subset currently generated by schema_diff.
|
||||
function_create_template: Some(FN_CREATE),
|
||||
function_drop_template: Some(FN_DROP),
|
||||
sequence_create_template: Some(SEQ_CREATE),
|
||||
sequence_alter_template: Some(SEQ_ALTER),
|
||||
sequence_drop_template: Some(SEQ_DROP),
|
||||
rule_drop_template: None,
|
||||
owner_alter_template: None,
|
||||
drop_table_template: DROP_TABLE,
|
||||
lock_timeout_sql: Some("ALTER SESSION SET DDL_LOCK_TIMEOUT = 3;"),
|
||||
type_map: &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlserver_family(db: DatabaseType) -> DdlDialectProfile {
|
||||
DdlDialectProfile {
|
||||
database_type: db,
|
||||
quote: QuoteStyle::Brackets,
|
||||
auto_inc: AutoIncSyntax::Suffix(" IDENTITY(1,1)"),
|
||||
supports_display_width: false,
|
||||
requires_explicit_varchar_length: false,
|
||||
max_varchar_len: None,
|
||||
inline_column_comment: false,
|
||||
foreign_keys_inline_in_create: false,
|
||||
drop_index_uses_on_table: false,
|
||||
drop_fk_as_foreign_key: false,
|
||||
drop_table_supports_cascade: false,
|
||||
// `DROP TABLE IF EXISTS` is valid on SQL Server 2016+.
|
||||
drop_table_supports_if_exists: true,
|
||||
index_type_placement: IndexTypePlacement::TypePrefix,
|
||||
index_supports_include: true,
|
||||
index_supports_filter: true,
|
||||
index_supports_comment: false,
|
||||
table_comment_via_alter: false,
|
||||
column_comment_via_modify_only: false,
|
||||
comment_ddl: CommentDdlSyntax::SqlServerExtendedProperty,
|
||||
trigger_template: TriggerTemplate::SqlServerStyle,
|
||||
rename_column: RenameColumnSyntax::SqlServerSpRename,
|
||||
column_ddl: ColumnDdlSyntax::SqlServer,
|
||||
prefers_native_source_ddl: false,
|
||||
grant_uses_mysql_user_syntax: false,
|
||||
warn_fk_needs_table_rebuild: false,
|
||||
alter_uses_modify_column: false,
|
||||
alter_batches_clauses: false,
|
||||
create_table_if_not_exists: false,
|
||||
create_index_if_not_exists: false,
|
||||
create_function_or_replace: false,
|
||||
supports_function_ddl: true,
|
||||
supports_sequence_ddl: true,
|
||||
supports_rule_ddl: false,
|
||||
supports_owner_ddl: false,
|
||||
function_create_template: Some(FN_CREATE),
|
||||
function_drop_template: Some(SQLSERVER_FN_DROP),
|
||||
sequence_create_template: Some(SEQ_CREATE),
|
||||
sequence_alter_template: Some(SQLSERVER_SEQ_ALTER),
|
||||
sequence_drop_template: Some(SEQ_DROP),
|
||||
rule_drop_template: None,
|
||||
owner_alter_template: None,
|
||||
drop_table_template: DROP_TABLE,
|
||||
lock_timeout_sql: Some("SET LOCK_TIMEOUT 3000;"),
|
||||
type_map: &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlite_family(db: DatabaseType) -> DdlDialectProfile {
|
||||
DdlDialectProfile {
|
||||
database_type: db,
|
||||
quote: QuoteStyle::DoubleQuote,
|
||||
auto_inc: AutoIncSyntax::Suffix(" AUTOINCREMENT"),
|
||||
supports_display_width: false,
|
||||
requires_explicit_varchar_length: false,
|
||||
max_varchar_len: None,
|
||||
inline_column_comment: false,
|
||||
foreign_keys_inline_in_create: true,
|
||||
drop_index_uses_on_table: false,
|
||||
drop_fk_as_foreign_key: false,
|
||||
drop_table_supports_cascade: false,
|
||||
drop_table_supports_if_exists: true,
|
||||
index_type_placement: IndexTypePlacement::None,
|
||||
index_supports_include: false,
|
||||
index_supports_filter: true,
|
||||
index_supports_comment: false,
|
||||
table_comment_via_alter: false,
|
||||
column_comment_via_modify_only: false,
|
||||
comment_ddl: CommentDdlSyntax::Generic,
|
||||
trigger_template: TriggerTemplate::GenericRowBody,
|
||||
rename_column: RenameColumnSyntax::RenameColumn,
|
||||
column_ddl: ColumnDdlSyntax::Generic,
|
||||
prefers_native_source_ddl: false,
|
||||
grant_uses_mysql_user_syntax: false,
|
||||
warn_fk_needs_table_rebuild: true,
|
||||
alter_uses_modify_column: false,
|
||||
alter_batches_clauses: false,
|
||||
create_table_if_not_exists: true,
|
||||
create_index_if_not_exists: true,
|
||||
create_function_or_replace: false,
|
||||
supports_function_ddl: false,
|
||||
supports_sequence_ddl: false,
|
||||
supports_rule_ddl: false,
|
||||
supports_owner_ddl: false,
|
||||
function_create_template: None,
|
||||
function_drop_template: None,
|
||||
sequence_create_template: None,
|
||||
sequence_alter_template: None,
|
||||
sequence_drop_template: None,
|
||||
rule_drop_template: None,
|
||||
owner_alter_template: None,
|
||||
drop_table_template: DROP_TABLE,
|
||||
lock_timeout_sql: None,
|
||||
type_map: SQLITE_TYPE_MAP,
|
||||
}
|
||||
}
|
||||
|
||||
fn access_profile() -> DdlDialectProfile {
|
||||
DdlDialectProfile {
|
||||
database_type: DatabaseType::Access,
|
||||
quote: QuoteStyle::DoubleQuote,
|
||||
auto_inc: AutoIncSyntax::ReplaceTypeWith("COUNTER"),
|
||||
supports_display_width: false,
|
||||
requires_explicit_varchar_length: false,
|
||||
max_varchar_len: Some(255),
|
||||
inline_column_comment: false,
|
||||
foreign_keys_inline_in_create: false,
|
||||
drop_index_uses_on_table: false,
|
||||
drop_fk_as_foreign_key: false,
|
||||
// Jet SQL has neither CASCADE nor IF EXISTS on DROP TABLE.
|
||||
drop_table_supports_cascade: false,
|
||||
drop_table_supports_if_exists: false,
|
||||
index_type_placement: IndexTypePlacement::None,
|
||||
index_supports_include: false,
|
||||
index_supports_filter: false,
|
||||
index_supports_comment: false,
|
||||
table_comment_via_alter: false,
|
||||
column_comment_via_modify_only: false,
|
||||
comment_ddl: CommentDdlSyntax::Generic,
|
||||
trigger_template: TriggerTemplate::GenericRowBody,
|
||||
rename_column: RenameColumnSyntax::RenameColumn,
|
||||
column_ddl: ColumnDdlSyntax::Generic,
|
||||
prefers_native_source_ddl: false,
|
||||
grant_uses_mysql_user_syntax: false,
|
||||
warn_fk_needs_table_rebuild: false,
|
||||
alter_uses_modify_column: false,
|
||||
alter_batches_clauses: false,
|
||||
create_table_if_not_exists: false,
|
||||
create_index_if_not_exists: false,
|
||||
create_function_or_replace: false,
|
||||
supports_function_ddl: false,
|
||||
supports_sequence_ddl: false,
|
||||
supports_rule_ddl: false,
|
||||
supports_owner_ddl: false,
|
||||
function_create_template: None,
|
||||
function_drop_template: None,
|
||||
sequence_create_template: None,
|
||||
sequence_alter_template: None,
|
||||
sequence_drop_template: None,
|
||||
rule_drop_template: None,
|
||||
owner_alter_template: None,
|
||||
drop_table_template: DROP_TABLE,
|
||||
lock_timeout_sql: None,
|
||||
type_map: ACCESS_TYPE_MAP,
|
||||
}
|
||||
}
|
||||
|
||||
fn h2_profile() -> DdlDialectProfile {
|
||||
DdlDialectProfile {
|
||||
database_type: DatabaseType::H2,
|
||||
quote: QuoteStyle::DoubleQuote,
|
||||
auto_inc: AutoIncSyntax::None,
|
||||
supports_display_width: false,
|
||||
requires_explicit_varchar_length: false,
|
||||
max_varchar_len: None,
|
||||
inline_column_comment: false,
|
||||
foreign_keys_inline_in_create: false,
|
||||
drop_index_uses_on_table: false,
|
||||
drop_fk_as_foreign_key: false,
|
||||
// H2 accepts CASCADE, but it stays off to keep the cascade set aligned with
|
||||
// the frontend preview list in `dbAdminSql.ts`.
|
||||
drop_table_supports_cascade: false,
|
||||
drop_table_supports_if_exists: true,
|
||||
index_type_placement: IndexTypePlacement::None,
|
||||
index_supports_include: false,
|
||||
index_supports_filter: false,
|
||||
index_supports_comment: false,
|
||||
table_comment_via_alter: false,
|
||||
column_comment_via_modify_only: false,
|
||||
comment_ddl: CommentDdlSyntax::Generic,
|
||||
trigger_template: TriggerTemplate::GenericRowBody,
|
||||
rename_column: RenameColumnSyntax::AlterColumnRenameTo,
|
||||
column_ddl: ColumnDdlSyntax::Generic,
|
||||
prefers_native_source_ddl: false,
|
||||
grant_uses_mysql_user_syntax: false,
|
||||
warn_fk_needs_table_rebuild: false,
|
||||
alter_uses_modify_column: false,
|
||||
alter_batches_clauses: false,
|
||||
create_table_if_not_exists: false,
|
||||
create_index_if_not_exists: false,
|
||||
create_function_or_replace: false,
|
||||
supports_function_ddl: false,
|
||||
supports_sequence_ddl: false,
|
||||
supports_rule_ddl: false,
|
||||
supports_owner_ddl: false,
|
||||
function_create_template: None,
|
||||
function_drop_template: None,
|
||||
sequence_create_template: None,
|
||||
sequence_alter_template: None,
|
||||
sequence_drop_template: None,
|
||||
rule_drop_template: None,
|
||||
owner_alter_template: None,
|
||||
drop_table_template: DROP_TABLE,
|
||||
lock_timeout_sql: None,
|
||||
type_map: &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn conservative_ansi(db: DatabaseType) -> DdlDialectProfile {
|
||||
DdlDialectProfile {
|
||||
database_type: db,
|
||||
quote: QuoteStyle::DoubleQuote,
|
||||
auto_inc: AutoIncSyntax::None,
|
||||
supports_display_width: false,
|
||||
// This fallback also covers DB2 and Informix, which reject a bare
|
||||
// VARCHAR with no length — but it's shared with dialects that don't
|
||||
// (Snowflake, BigQuery, DuckDB, Trino, ...), so it can't be flipped
|
||||
// true here without a dedicated DB2/Informix profile and DB
|
||||
// verification. Tracked as a known gap, not fixed by this flag.
|
||||
requires_explicit_varchar_length: false,
|
||||
max_varchar_len: None,
|
||||
inline_column_comment: false,
|
||||
foreign_keys_inline_in_create: false,
|
||||
drop_index_uses_on_table: false,
|
||||
drop_fk_as_foreign_key: false,
|
||||
drop_table_supports_cascade: false,
|
||||
// Most engines in this bucket accept `IF EXISTS`; `profile_for` opts out the
|
||||
// ones that do not.
|
||||
drop_table_supports_if_exists: true,
|
||||
index_type_placement: IndexTypePlacement::None,
|
||||
index_supports_include: false,
|
||||
index_supports_filter: false,
|
||||
index_supports_comment: false,
|
||||
table_comment_via_alter: false,
|
||||
column_comment_via_modify_only: false,
|
||||
comment_ddl: CommentDdlSyntax::Generic,
|
||||
trigger_template: TriggerTemplate::GenericRowBody,
|
||||
rename_column: RenameColumnSyntax::RenameColumn,
|
||||
column_ddl: ColumnDdlSyntax::Generic,
|
||||
prefers_native_source_ddl: false,
|
||||
grant_uses_mysql_user_syntax: false,
|
||||
warn_fk_needs_table_rebuild: false,
|
||||
alter_uses_modify_column: false,
|
||||
alter_batches_clauses: false,
|
||||
create_table_if_not_exists: false,
|
||||
create_index_if_not_exists: false,
|
||||
create_function_or_replace: false,
|
||||
supports_function_ddl: false,
|
||||
supports_sequence_ddl: false,
|
||||
supports_rule_ddl: false,
|
||||
supports_owner_ddl: false,
|
||||
function_create_template: None,
|
||||
function_drop_template: None,
|
||||
sequence_create_template: None,
|
||||
sequence_alter_template: None,
|
||||
sequence_drop_template: None,
|
||||
rule_drop_template: None,
|
||||
owner_alter_template: None,
|
||||
drop_table_template: DROP_TABLE,
|
||||
lock_timeout_sql: None,
|
||||
type_map: &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn clickhouse_profile() -> DdlDialectProfile {
|
||||
let mut p = conservative_ansi(DatabaseType::ClickHouse);
|
||||
// ClickHouse supports CREATE TABLE IF NOT EXISTS like MySQL/SQLite.
|
||||
p.create_table_if_not_exists = true;
|
||||
p.lock_timeout_sql = None;
|
||||
p
|
||||
}
|
||||
|
||||
fn influxdb_profile() -> DdlDialectProfile {
|
||||
let mut p = conservative_ansi(DatabaseType::InfluxDb);
|
||||
// InfluxDB drops a measurement, not a table, and has no IF EXISTS form.
|
||||
p.drop_table_template = "DROP MEASUREMENT {table};";
|
||||
p.drop_table_supports_if_exists = false;
|
||||
p
|
||||
}
|
||||
|
||||
/// Resolve DDL profile for a concrete target database type.
|
||||
///
|
||||
/// This is the **only** place that maps [`DatabaseType`] → profile data.
|
||||
/// Generators must not re-match on individual databases afterward.
|
||||
pub fn profile_for(db_type: DatabaseType) -> DdlDialectProfile {
|
||||
use DatabaseType::*;
|
||||
match db_type {
|
||||
// MySQL family
|
||||
Mysql | Doris | StarRocks | Goldendb | Sundb | Databend | Gbase | ManticoreSearch => mysql_family(db_type),
|
||||
|
||||
Dameng => {
|
||||
let mut profile = oracle_family(db_type);
|
||||
profile.alter_uses_modify_column = true;
|
||||
profile
|
||||
}
|
||||
|
||||
// PostgreSQL family
|
||||
Postgres | Redshift | Gaussdb | Kingbase | Highgo | Vastbase | OpenGauss | Kwdb | Uxdb => {
|
||||
postgres_family(db_type)
|
||||
}
|
||||
|
||||
// PostgreSQL-shaped DDL, but not the PostgreSQL `DROP TABLE` grammar:
|
||||
// Firebird has neither CASCADE nor IF EXISTS; Vertica and Exasol are kept off
|
||||
// CASCADE so the set stays aligned with the frontend preview list.
|
||||
Firebird | Vertica | Exasol => {
|
||||
let mut profile = postgres_family(db_type);
|
||||
profile.drop_table_supports_cascade = false;
|
||||
profile.drop_table_supports_if_exists = db_type != Firebird;
|
||||
profile
|
||||
}
|
||||
|
||||
// Oracle family
|
||||
Oracle | OceanbaseOracle | Yashandb | Xugu | Iris => oracle_family(db_type),
|
||||
|
||||
// SQL Server (not Access)
|
||||
SqlServer => sqlserver_family(db_type),
|
||||
|
||||
// Access: own profile (must not inherit SqlServer IDENTITY)
|
||||
Access => access_profile(),
|
||||
|
||||
// SQLite family
|
||||
Sqlite | Rqlite | Turso | CloudflareD1 => sqlite_family(db_type),
|
||||
|
||||
H2 => h2_profile(),
|
||||
|
||||
ClickHouse => clickhouse_profile(),
|
||||
|
||||
// Engines whose DROP TABLE grammar has no `IF EXISTS`.
|
||||
Db2 | Informix | SapHana | Teradata => {
|
||||
let mut profile = conservative_ansi(db_type);
|
||||
profile.drop_table_supports_if_exists = false;
|
||||
profile
|
||||
}
|
||||
|
||||
DuckDb | Questdb | Snowflake | Trino | PrestoSql | Hive | Kyuubi | Impala | Argo | Spark | Bigquery
|
||||
| Spanner | Kylin | Ignite | Ignite3 | Oscar | Tdengine | Iotdb | Databricks | Jdbc => {
|
||||
conservative_ansi(db_type)
|
||||
}
|
||||
|
||||
InfluxDb => influxdb_profile(),
|
||||
|
||||
Plugin => conservative_ansi(db_type),
|
||||
|
||||
// Non-tabular / not applicable for relational CREATE TABLE
|
||||
Redis | MongoDb | DynamoDb | Elasticsearch | Easysearch | Meilisearch | Qdrant | Milvus | Weaviate
|
||||
| ChromaDb | Neo4j | Cassandra | Etcd | ZooKeeper | Nacos | Consul | InfluxDb3 | VictoriaMetrics
|
||||
| MessageQueue | Mqtt | Hbase => conservative_ansi(db_type),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn access_profile_is_not_sqlserver_identity() {
|
||||
let access = profile_for(DatabaseType::Access);
|
||||
let mssql = profile_for(DatabaseType::SqlServer);
|
||||
assert!(matches!(access.auto_inc, AutoIncSyntax::ReplaceTypeWith("COUNTER")));
|
||||
assert!(matches!(mssql.auto_inc, AutoIncSyntax::Suffix(s) if s.contains("IDENTITY")));
|
||||
assert!(access.lookup_type("VARCHAR").is_some());
|
||||
assert!(mssql.type_map.is_empty());
|
||||
// Access must not inherit SQL Server lock-timeout behavior. Neither
|
||||
// database supports CREATE INDEX IF NOT EXISTS syntax.
|
||||
assert_eq!(access.lock_timeout_sql, None);
|
||||
assert!(mssql.lock_timeout_sql.is_some());
|
||||
assert!(!access.create_index_if_not_exists);
|
||||
assert!(!mssql.create_index_if_not_exists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_profile_uses_valid_identity_and_ddl_lock_timeout() {
|
||||
let oracle = profile_for(DatabaseType::Oracle);
|
||||
assert!(matches!(
|
||||
oracle.auto_inc,
|
||||
AutoIncSyntax::Suffix(s) if s.contains("GENERATED BY DEFAULT AS IDENTITY")
|
||||
));
|
||||
assert_eq!(oracle.lock_timeout_sql, Some("ALTER SESSION SET DDL_LOCK_TIMEOUT = 3;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_styles() {
|
||||
assert_eq!(profile_for(DatabaseType::Mysql).quote_ident("a`b"), "`a``b`");
|
||||
assert_eq!(profile_for(DatabaseType::Access).quote_ident(r#"a"b"#), "\"a\"\"b\"");
|
||||
assert_eq!(profile_for(DatabaseType::SqlServer).quote_ident("a]b"), "[a]]b]");
|
||||
assert_eq!(profile_for(DatabaseType::Oracle).quote_ident("emp"), "EMP");
|
||||
assert_eq!(profile_for(DatabaseType::Oracle).quote_ident("EMP"), "EMP");
|
||||
// Mixed-case must preserve spelling via quotes (not EMPMIXED).
|
||||
assert_eq!(profile_for(DatabaseType::Oracle).quote_ident("empMixed"), "\"empMixed\"");
|
||||
assert_eq!(profile_for(DatabaseType::Oracle).quote_ident("Emp"), "\"Emp\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_object_templates_are_populated() {
|
||||
let p = profile_for(DatabaseType::Postgres);
|
||||
assert!(p.function_create_template.is_some());
|
||||
assert!(p.function_drop_template.is_some());
|
||||
assert!(p.sequence_create_template.is_some());
|
||||
assert!(p.sequence_alter_template.is_some());
|
||||
assert!(p.sequence_drop_template.is_some());
|
||||
assert!(p.rule_drop_template.is_some());
|
||||
assert!(p.owner_alter_template.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_object_templates_are_absent() {
|
||||
let p = profile_for(DatabaseType::Mysql);
|
||||
assert!(p.function_create_template.is_none());
|
||||
assert!(p.sequence_create_template.is_none());
|
||||
assert!(p.rule_drop_template.is_none());
|
||||
assert!(p.owner_alter_template.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_template_replaces_placeholders() {
|
||||
let rendered = DdlDialectProfile::render_template(
|
||||
"DROP FUNCTION IF EXISTS {name}{cascade};",
|
||||
&[("name", "\"public\".\"f\""), ("cascade", " CASCADE")],
|
||||
);
|
||||
assert_eq!(rendered, "DROP FUNCTION IF EXISTS \"public\".\"f\" CASCADE;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_table_if_exists_support_matches_dialect_grammar() {
|
||||
for db in [
|
||||
DatabaseType::Mysql,
|
||||
DatabaseType::Postgres,
|
||||
DatabaseType::SqlServer,
|
||||
DatabaseType::Sqlite,
|
||||
DatabaseType::DuckDb,
|
||||
DatabaseType::ClickHouse,
|
||||
DatabaseType::H2,
|
||||
DatabaseType::Snowflake,
|
||||
] {
|
||||
assert!(profile_for(db).drop_table_supports_if_exists, "{db:?} does support DROP TABLE IF EXISTS");
|
||||
}
|
||||
// Oracle only gained `IF EXISTS` in 23c; the rest have no such grammar at all.
|
||||
for db in [
|
||||
DatabaseType::Oracle,
|
||||
DatabaseType::Dameng,
|
||||
DatabaseType::OceanbaseOracle,
|
||||
DatabaseType::Access,
|
||||
DatabaseType::Firebird,
|
||||
DatabaseType::Db2,
|
||||
DatabaseType::Informix,
|
||||
DatabaseType::SapHana,
|
||||
DatabaseType::Teradata,
|
||||
DatabaseType::InfluxDb,
|
||||
] {
|
||||
assert!(!profile_for(db).drop_table_supports_if_exists, "{db:?} does not support DROP TABLE IF EXISTS");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_table_cascade_support_is_limited_to_the_postgres_grammar() {
|
||||
for db in [
|
||||
DatabaseType::Postgres,
|
||||
DatabaseType::Redshift,
|
||||
DatabaseType::Gaussdb,
|
||||
DatabaseType::Kwdb,
|
||||
DatabaseType::Kingbase,
|
||||
DatabaseType::Highgo,
|
||||
DatabaseType::Uxdb,
|
||||
DatabaseType::Vastbase,
|
||||
DatabaseType::OpenGauss,
|
||||
] {
|
||||
assert!(profile_for(db).drop_table_supports_cascade, "{db:?} should keep DROP TABLE … CASCADE");
|
||||
}
|
||||
// Oracle's `CASCADE CONSTRAINTS` is a different clause; Firebird, Vertica and Exasol
|
||||
// share the PostgreSQL profile but stay out of the cascade set so the backend matches
|
||||
// the frontend preview list in `dbAdminSql.ts`.
|
||||
for db in [
|
||||
DatabaseType::Oracle,
|
||||
DatabaseType::Firebird,
|
||||
DatabaseType::Vertica,
|
||||
DatabaseType::Exasol,
|
||||
DatabaseType::Mysql,
|
||||
DatabaseType::Sqlite,
|
||||
DatabaseType::DuckDb,
|
||||
DatabaseType::SqlServer,
|
||||
DatabaseType::H2,
|
||||
] {
|
||||
assert!(!profile_for(db).drop_table_supports_cascade, "{db:?} must not emit DROP TABLE … CASCADE");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_table_template_carries_the_whole_statement_shape() {
|
||||
assert_eq!(profile_for(DatabaseType::Postgres).drop_table_template, "DROP TABLE {table}{cascade};");
|
||||
assert_eq!(profile_for(DatabaseType::InfluxDb).drop_table_template, "DROP MEASUREMENT {table};");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,68 @@
|
||||
use crate::sql_dialect::descriptor::{DialectCapabilityDescriptor, DialectInfo, DialectKind, TypeMappingMatrix};
|
||||
use insta::assert_json_snapshot;
|
||||
|
||||
#[test]
|
||||
fn snapshot_mysql_descriptor() {
|
||||
let desc = DialectCapabilityDescriptor::for_dialect(DialectKind::Mysql);
|
||||
assert_json_snapshot!("mysql_descriptor", desc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_postgres_descriptor() {
|
||||
let desc = DialectCapabilityDescriptor::for_dialect(DialectKind::Postgres);
|
||||
assert_json_snapshot!("postgres_descriptor", desc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_sqlite_descriptor() {
|
||||
let desc = DialectCapabilityDescriptor::for_dialect(DialectKind::Sqlite);
|
||||
assert_json_snapshot!("sqlite_descriptor", desc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_sqlserver_descriptor() {
|
||||
let desc = DialectCapabilityDescriptor::for_dialect(DialectKind::SqlServer);
|
||||
assert_json_snapshot!("sqlserver_descriptor", desc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_oracle_descriptor() {
|
||||
let desc = DialectCapabilityDescriptor::for_dialect(DialectKind::Oracle);
|
||||
assert_json_snapshot!("oracle_descriptor", desc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_duckdb_descriptor() {
|
||||
let desc = DialectCapabilityDescriptor::for_dialect(DialectKind::DuckDb);
|
||||
assert_json_snapshot!("duckdb_descriptor", desc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_mysql_info() {
|
||||
let info = DialectInfo::for_kind(DialectKind::Mysql);
|
||||
assert_json_snapshot!("mysql_info", info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_postgres_info() {
|
||||
let info = DialectInfo::for_kind(DialectKind::Postgres);
|
||||
assert_json_snapshot!("postgres_info", info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_mysql_to_postgres_type_mapping() {
|
||||
let matrix = TypeMappingMatrix::for_dialects(DialectKind::Mysql, DialectKind::Postgres);
|
||||
assert_json_snapshot!("mysql_to_postgres_mapping", &matrix.rules);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_postgres_to_mysql_type_mapping() {
|
||||
let matrix = TypeMappingMatrix::for_dialects(DialectKind::Postgres, DialectKind::Mysql);
|
||||
assert_json_snapshot!("postgres_to_mysql_mapping", &matrix.rules);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_all_dialects_info() {
|
||||
let all = DialectInfo::all();
|
||||
assert_json_snapshot!("all_dialects_info", all);
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::RwLock;
|
||||
|
||||
use super::descriptor::{DialectCapabilityDescriptor, DialectKind};
|
||||
use super::dialect_yaml::DialectYaml;
|
||||
|
||||
/// Singleton dialect registry that holds all loaded dialect descriptors.
|
||||
/// Thread-safe via RwLock. Access via `DialectRegistry::global()`.
|
||||
pub struct DialectRegistry {
|
||||
descriptors: RwLock<HashMap<String, LoadedDialect>>,
|
||||
plugin_dirs: RwLock<Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoadedDialect {
|
||||
pub dialect_name: String,
|
||||
pub kind: DialectKind,
|
||||
pub descriptor: DialectCapabilityDescriptor,
|
||||
pub yaml: DialectYaml,
|
||||
pub source_path: Option<PathBuf>,
|
||||
pub loaded_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoadError {
|
||||
pub path: PathBuf,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
impl DialectRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self { descriptors: RwLock::new(HashMap::new()), plugin_dirs: RwLock::new(Vec::new()) }
|
||||
}
|
||||
|
||||
pub fn global() -> &'static Self {
|
||||
use std::sync::OnceLock;
|
||||
static INSTANCE: OnceLock<DialectRegistry> = OnceLock::new();
|
||||
INSTANCE.get_or_init(DialectRegistry::new)
|
||||
}
|
||||
|
||||
pub fn add_plugin_dir(&self, dir: PathBuf) {
|
||||
if let Ok(mut dirs) = self.plugin_dirs.write() {
|
||||
if !dirs.contains(&dir) {
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, name: &str) -> Option<LoadedDialect> {
|
||||
self.descriptors.read().ok().and_then(|d| d.get(&name.to_ascii_lowercase()).cloned())
|
||||
}
|
||||
|
||||
pub fn get_by_kind(&self, kind: DialectKind) -> Option<LoadedDialect> {
|
||||
self.descriptors.read().ok().and_then(|d| d.values().find(|ld| ld.kind == kind).cloned())
|
||||
}
|
||||
|
||||
/// Return all loaded dialects that map to the given kind.
|
||||
/// Unlike `get_by_kind` (which returns only the first match), this is
|
||||
/// order-independent and is used when a type's capabilities must be
|
||||
/// resolved across an entire dialect family (e.g. PostgreSQL, HighGo,
|
||||
/// KingBase all share `DialectKind::Postgres`).
|
||||
pub fn get_all_by_kind(&self, kind: DialectKind) -> Vec<LoadedDialect> {
|
||||
self.descriptors
|
||||
.read()
|
||||
.ok()
|
||||
.map(|d| d.values().filter(|ld| ld.kind == kind).cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn get_descriptor(&self, name: &str) -> Option<DialectCapabilityDescriptor> {
|
||||
self.get(name).map(|ld| ld.descriptor)
|
||||
}
|
||||
|
||||
pub fn get_descriptor_for_db(
|
||||
&self,
|
||||
db_type: crate::models::connection::DatabaseType,
|
||||
) -> Option<DialectCapabilityDescriptor> {
|
||||
if let Some(dialect_name) = crate::database_manifest::dialect_name(&db_type) {
|
||||
if let Some(loaded) = self.get(dialect_name) {
|
||||
return Some(loaded.descriptor);
|
||||
}
|
||||
}
|
||||
let kind = DialectKind::from_database_type(db_type);
|
||||
let label = kind.label();
|
||||
let db_name = db_type.as_str();
|
||||
self.get(db_name).or_else(|| self.get(label)).map(|ld| ld.descriptor)
|
||||
}
|
||||
|
||||
pub fn get_yaml(&self, name: &str) -> Option<DialectYaml> {
|
||||
self.get(name).map(|ld| ld.yaml)
|
||||
}
|
||||
|
||||
pub fn has(&self, name: &str) -> bool {
|
||||
self.descriptors.read().ok().is_some_and(|d| d.contains_key(&name.to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
pub fn all_names(&self) -> Vec<String> {
|
||||
self.descriptors.read().ok().map(|d| d.keys().cloned().collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn all_descriptors(&self) -> Vec<DialectCapabilityDescriptor> {
|
||||
self.descriptors.read().ok().map(|d| d.values().map(|ld| ld.descriptor).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn register(
|
||||
&self,
|
||||
name: &str,
|
||||
kind: DialectKind,
|
||||
descriptor: DialectCapabilityDescriptor,
|
||||
yaml: DialectYaml,
|
||||
source_path: Option<PathBuf>,
|
||||
) {
|
||||
if let Ok(mut d) = self.descriptors.write() {
|
||||
d.insert(
|
||||
name.to_ascii_lowercase(),
|
||||
LoadedDialect {
|
||||
dialect_name: name.to_string(),
|
||||
kind,
|
||||
descriptor,
|
||||
yaml,
|
||||
source_path,
|
||||
loaded_at: chrono::Utc::now().to_rfc3339(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_yaml(&self, path: &Path, yaml: DialectYaml, descriptor: DialectCapabilityDescriptor) {
|
||||
let kind = descriptor.dialect;
|
||||
let name = yaml.dialect.name.clone();
|
||||
self.register(&name, kind, descriptor, yaml, Some(path.to_path_buf()));
|
||||
}
|
||||
|
||||
pub fn register_descriptor(&self, name: &str, descriptor: DialectCapabilityDescriptor, yaml: DialectYaml) {
|
||||
let kind = descriptor.dialect;
|
||||
self.register(name, kind, descriptor, yaml, None);
|
||||
}
|
||||
|
||||
pub fn unregister(&self, name: &str) -> bool {
|
||||
self.descriptors.write().ok().is_some_and(|mut d| d.remove(&name.to_ascii_lowercase()).is_some())
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.descriptors.read().ok().is_none_or(|d| d.is_empty())
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.descriptors.read().ok().map_or(0, |d| d.len())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DialectRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Register all core dialect YAML definitions embedded at compile time.
|
||||
/// Called once at startup by `lazy_init()`.
|
||||
pub fn register_core_dialects() {
|
||||
use std::sync::Once;
|
||||
static INIT: Once = Once::new();
|
||||
INIT.call_once(|| {
|
||||
let registry = DialectRegistry::global();
|
||||
include!(concat!(env!("OUT_DIR"), "/core_dialects.rs"));
|
||||
let n = registry.len();
|
||||
log::info!("Registered {n} core dialects");
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DialectPluginLoader — scans plugin directories and loads YAML files
|
||||
// ============================================================================
|
||||
|
||||
pub struct DialectPluginLoader;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LoadResult {
|
||||
pub loaded: Vec<DialectKind>,
|
||||
pub errors: Vec<LoadError>,
|
||||
pub skipped: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl DialectPluginLoader {
|
||||
pub fn scan_and_load(registry: &DialectRegistry, plugin_dirs: &[PathBuf]) -> LoadResult {
|
||||
let mut result = LoadResult { loaded: Vec::new(), errors: Vec::new(), skipped: Vec::new() };
|
||||
|
||||
for dir in plugin_dirs {
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
result.errors.push(LoadError { path: dir.clone(), error: format!("Cannot read directory: {e}") });
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
|
||||
if !matches!(extension, "yaml" | "yml") {
|
||||
continue;
|
||||
}
|
||||
|
||||
match Self::load_file(&path) {
|
||||
Ok((kind, yaml, descriptor)) => {
|
||||
registry.register(file_name, kind, descriptor, yaml, Some(path.clone()));
|
||||
result.loaded.push(kind);
|
||||
}
|
||||
Err(e) => {
|
||||
result.errors.push(LoadError { path: path.clone(), error: e });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn load_file(path: &Path) -> Result<(DialectKind, DialectYaml, DialectCapabilityDescriptor), String> {
|
||||
let content = fs::read_to_string(path).map_err(|e| format!("Cannot read file: {e}"))?;
|
||||
Self::load_from_string(&content, Some(path))
|
||||
}
|
||||
|
||||
pub fn load_from_string(
|
||||
yaml_str: &str,
|
||||
source_path: Option<&Path>,
|
||||
) -> Result<(DialectKind, DialectYaml, DialectCapabilityDescriptor), String> {
|
||||
let parsed: DialectYaml = serde_yaml::from_str(yaml_str).map_err(|e| format!("YAML parse error: {e}"))?;
|
||||
|
||||
let kind = parsed.dialect_kind().ok_or_else(|| {
|
||||
format!(
|
||||
"Unknown dialect name '{}' in {}",
|
||||
parsed.dialect.name,
|
||||
source_path.map_or_else(|| "<string>".to_string(), |p| p.display().to_string())
|
||||
)
|
||||
})?;
|
||||
|
||||
let validation_errors = parsed.validate();
|
||||
if !validation_errors.is_empty() {
|
||||
let msgs: Vec<String> = validation_errors.iter().map(|e| e.to_string()).collect();
|
||||
return Err(format!("Validation errors: {}", msgs.join("; ")));
|
||||
}
|
||||
|
||||
let descriptor = parsed.to_descriptor(kind);
|
||||
Ok((kind, parsed, descriptor))
|
||||
}
|
||||
|
||||
pub fn load_directory(registry: &DialectRegistry, dir: &Path) -> LoadResult {
|
||||
Self::scan_and_load(registry, &[dir.to_path_buf()])
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fallback: use hardcoded descriptors when YAML is unavailable
|
||||
// ============================================================================
|
||||
|
||||
pub fn resolve_descriptor(kind: DialectKind, registry: &DialectRegistry) -> DialectCapabilityDescriptor {
|
||||
if let Some(desc) = registry.get_descriptor(kind.label()) {
|
||||
return desc;
|
||||
}
|
||||
DialectCapabilityDescriptor::for_dialect(kind)
|
||||
}
|
||||
|
||||
pub fn resolve_descriptor_for_db(
|
||||
db_type: crate::models::connection::DatabaseType,
|
||||
registry: &DialectRegistry,
|
||||
) -> DialectCapabilityDescriptor {
|
||||
if let Some(desc) = registry.get_descriptor_for_db(db_type) {
|
||||
return desc;
|
||||
}
|
||||
let kind = DialectKind::from_database_type(db_type);
|
||||
DialectCapabilityDescriptor::for_dialect(kind)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn temp_dialect_dir() -> PathBuf {
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("dbx-dialect-test-{}-{stamp}", std::process::id()));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn write_temp_yaml(dir: &Path, name: &str, dialect_name: &str) -> PathBuf {
|
||||
let file_path = dir.join(name);
|
||||
let yaml_content = format!(
|
||||
r#"
|
||||
dialect:
|
||||
name: "{dialect_name}"
|
||||
versions:
|
||||
- version: "1.0"
|
||||
identifier_rules:
|
||||
quote_char: "\""
|
||||
max_length: 64
|
||||
"#
|
||||
);
|
||||
let mut file = fs::File::create(&file_path).unwrap();
|
||||
file.write_all(yaml_content.as_bytes()).unwrap();
|
||||
file_path
|
||||
}
|
||||
|
||||
fn cleanup_temp_dir(dir: &Path) {
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_valid_yaml_file() {
|
||||
let dir = temp_dialect_dir();
|
||||
let path = write_temp_yaml(&dir, "dialect_postgresql.yaml", "PostgreSQL");
|
||||
let result = DialectPluginLoader::load_file(&path);
|
||||
cleanup_temp_dir(&dir);
|
||||
assert!(result.is_ok());
|
||||
let (kind, _yaml, desc) = result.unwrap();
|
||||
assert_eq!(kind, DialectKind::Postgres);
|
||||
assert_eq!(desc.dialect, DialectKind::Postgres);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_valid_yaml_file_mysql() {
|
||||
let dir = temp_dialect_dir();
|
||||
let path = write_temp_yaml(&dir, "dialect_mysql.yaml", "MySQL");
|
||||
let result = DialectPluginLoader::load_file(&path);
|
||||
cleanup_temp_dir(&dir);
|
||||
assert!(result.is_ok());
|
||||
let (kind, _, _) = result.unwrap();
|
||||
assert_eq!(kind, DialectKind::Mysql);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_invalid_dialect_name() {
|
||||
let dir = temp_dialect_dir();
|
||||
let path = write_temp_yaml(&dir, "dialect_unknown.yaml", "UnknownDB");
|
||||
let result = DialectPluginLoader::load_file(&path);
|
||||
cleanup_temp_dir(&dir);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_invalid_yaml_syntax() {
|
||||
let dir = temp_dialect_dir();
|
||||
let path = dir.join("dialect_bad.yaml");
|
||||
let mut file = fs::File::create(&path).unwrap();
|
||||
file.write_all(b"dialect: [invalid: yaml: syntax:").unwrap();
|
||||
let result = DialectPluginLoader::load_file(&path);
|
||||
cleanup_temp_dir(&dir);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_register_and_get() {
|
||||
let registry = DialectRegistry::new();
|
||||
assert!(registry.is_empty());
|
||||
|
||||
let desc = DialectCapabilityDescriptor::for_dialect(DialectKind::Mysql);
|
||||
let yaml = DialectYaml {
|
||||
dialect: super::super::dialect_yaml::DialectMeta {
|
||||
name: "MySQL".to_string(),
|
||||
display_name: Some("MySQL".to_string()),
|
||||
versions: vec![],
|
||||
},
|
||||
identifier_rules: super::super::dialect_yaml::IdentifierRules {
|
||||
quote_char: "`".to_string(),
|
||||
case_sensitive: false,
|
||||
max_length: 64,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
registry.register_descriptor("MySQL", desc, yaml);
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert!(registry.has("MySQL"));
|
||||
assert!(!registry.has("PostgreSQL"));
|
||||
|
||||
let loaded = registry.get("MySQL").unwrap();
|
||||
assert_eq!(loaded.dialect_name, "MySQL");
|
||||
|
||||
registry.unregister("MySQL");
|
||||
assert!(registry.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_fallback_resolve() {
|
||||
let registry = DialectRegistry::new();
|
||||
let desc = resolve_descriptor(DialectKind::Mysql, ®istry);
|
||||
assert!(desc.has_capability(super::super::descriptor::CAP_ADD_COLUMN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_all_kinds() {
|
||||
let registry = DialectRegistry::new();
|
||||
let desc = DialectCapabilityDescriptor::for_dialect(DialectKind::Mysql);
|
||||
let yaml = DialectYaml {
|
||||
dialect: super::super::dialect_yaml::DialectMeta {
|
||||
name: "MySQL".to_string(),
|
||||
display_name: None,
|
||||
versions: vec![],
|
||||
},
|
||||
identifier_rules: super::super::dialect_yaml::IdentifierRules {
|
||||
quote_char: "`".to_string(),
|
||||
case_sensitive: false,
|
||||
max_length: 64,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
registry.register_descriptor("MySQL", desc, yaml);
|
||||
let names = registry.all_names();
|
||||
assert_eq!(names, vec!["mysql"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use crate::sql_dialect::descriptor::DialectKind;
|
||||
use crate::sql_dialect::dialect_loader::DialectRegistry;
|
||||
|
||||
pub fn list_dialect_type_names(dialect_name: &str) -> Vec<String> {
|
||||
super::lazy_init();
|
||||
list_dialect_type_names_in(dialect_name, DialectRegistry::global())
|
||||
}
|
||||
|
||||
fn list_dialect_type_names_in(dialect_name: &str, registry: &DialectRegistry) -> Vec<String> {
|
||||
if let Some(loaded) = registry.get(dialect_name) {
|
||||
return loaded.yaml.types.iter().map(|t| t.name.clone()).collect();
|
||||
}
|
||||
|
||||
// Public callers may use stable dialect labels such as `postgres`,
|
||||
// while the embedded catalog keeps the product name `PostgreSQL`.
|
||||
if let Some(kind) = DialectKind::from_label(dialect_name) {
|
||||
if let Some(loaded) = registry.get(core_catalog_name(kind)) {
|
||||
return loaded.yaml.types.iter().map(|t| t.name.clone()).collect();
|
||||
}
|
||||
// Last-resort fallback: any dialect in the same family
|
||||
if let Some(loaded) = registry.get_by_kind(kind) {
|
||||
return loaded.yaml.types.iter().map(|t| t.name.clone()).collect();
|
||||
}
|
||||
}
|
||||
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn core_catalog_name(kind: DialectKind) -> &'static str {
|
||||
match kind {
|
||||
DialectKind::Mysql => "MySQL",
|
||||
DialectKind::Postgres => "PostgreSQL",
|
||||
DialectKind::Sqlite => "SQLite",
|
||||
DialectKind::DuckDb => "DuckDB",
|
||||
DialectKind::SqlServer => "SQL Server",
|
||||
DialectKind::Oracle => "Oracle",
|
||||
DialectKind::H2 => "H2",
|
||||
DialectKind::ClickHouse => "ClickHouse",
|
||||
DialectKind::ManticoreSearch => "ManticoreSearch",
|
||||
DialectKind::Informix => "Informix",
|
||||
DialectKind::Questdb => "QuestDB",
|
||||
DialectKind::Unsupported => "Unsupported",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sql_dialect::descriptor::DialectCapabilityDescriptor;
|
||||
use crate::sql_dialect::dialect_yaml::{DialectMeta, DialectType, DialectVersion, DialectYaml, IdentifierRules};
|
||||
|
||||
fn make_registry_with_types(dialect_name: &str, type_names: &[&str]) -> DialectRegistry {
|
||||
let registry = DialectRegistry::new();
|
||||
let types: Vec<DialectType> = type_names
|
||||
.iter()
|
||||
.map(|&name| DialectType {
|
||||
name: name.to_string(),
|
||||
category: "STRING".to_string(),
|
||||
max_precision: None,
|
||||
precision_range: None,
|
||||
has_length: false,
|
||||
has_precision: false,
|
||||
aliases: vec![],
|
||||
semantic_fidelity_base: 1.0,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let yaml = DialectYaml {
|
||||
dialect: DialectMeta {
|
||||
name: dialect_name.to_string(),
|
||||
display_name: Some(dialect_name.to_string()),
|
||||
versions: vec![DialectVersion { version: "1.0".to_string(), status: "COMPATIBLE".to_string() }],
|
||||
},
|
||||
types,
|
||||
identifier_rules: IdentifierRules { quote_char: "\"".to_string(), case_sensitive: false, max_length: 64 },
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let kind = yaml.dialect_kind().unwrap();
|
||||
let descriptor = DialectCapabilityDescriptor::for_dialect(kind);
|
||||
registry.register_descriptor(dialect_name, descriptor, yaml);
|
||||
registry
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_mysql_types() {
|
||||
let registry = make_registry_with_types("MySQL", &["VARCHAR", "INT", "BIGINT"]);
|
||||
let types = list_dialect_type_names_in("MySQL", ®istry);
|
||||
assert_eq!(types, vec!["VARCHAR", "INT", "BIGINT"]);
|
||||
assert!(types.contains(&"VARCHAR".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_dameng_types() {
|
||||
let registry = make_registry_with_types(
|
||||
"Dameng",
|
||||
&[
|
||||
"VARCHAR",
|
||||
"VARCHAR2",
|
||||
"INT",
|
||||
"INTEGER",
|
||||
"BIGINT",
|
||||
"FLOAT",
|
||||
"DOUBLE",
|
||||
"DATE",
|
||||
"TIMESTAMP",
|
||||
"CLOB",
|
||||
"BLOB",
|
||||
"TEXT",
|
||||
],
|
||||
);
|
||||
let types = list_dialect_type_names_in("Dameng", ®istry);
|
||||
assert!(!types.is_empty(), "Dameng should have types");
|
||||
assert!(types.contains(&"VARCHAR2".to_string()));
|
||||
assert_eq!(types.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_unknown_dialect_returns_empty() {
|
||||
let registry = DialectRegistry::new();
|
||||
let types = list_dialect_type_names_in("nonexistent_db", ®istry);
|
||||
assert!(types.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_embedded_postgres_types_by_name_and_label() {
|
||||
let by_name = list_dialect_type_names("PostgreSQL");
|
||||
let by_label = list_dialect_type_names("postgres");
|
||||
|
||||
assert!(!by_name.is_empty());
|
||||
assert_eq!(by_label, by_name);
|
||||
assert!(by_name.contains(&"SMALLINT".to_string()));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,143 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::dialect_loader::DialectPluginLoader;
|
||||
|
||||
/// Configuration for the hot-reload watcher.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HotReloadConfig {
|
||||
/// Debounce duration — multiple changes within this window are coalesced.
|
||||
pub debounce_ms: u64,
|
||||
/// Whether to enable polling (needed for some network filesystems).
|
||||
pub enable_polling: bool,
|
||||
/// Polling interval when polling is enabled.
|
||||
pub poll_interval_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for HotReloadConfig {
|
||||
fn default() -> Self {
|
||||
Self { debounce_ms: 500, enable_polling: false, poll_interval_ms: 2000 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a hot-reload operation on a changed file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ReloadEvent {
|
||||
/// A dialect YAML file was successfully loaded/updated.
|
||||
Loaded { path: PathBuf },
|
||||
/// A dialect YAML file was removed — dialect unregistered.
|
||||
Removed { path: PathBuf, dialect: String },
|
||||
/// A file changed but failed to load.
|
||||
Error { path: PathBuf, error: String },
|
||||
}
|
||||
|
||||
/// Hot-reload watcher that monitors `plugins/dialects/` for YAML changes
|
||||
/// and updates the `DialectRegistry` in real time.
|
||||
pub struct DialectHotReload {
|
||||
watcher: Option<RecommendedWatcher>,
|
||||
config: HotReloadConfig,
|
||||
}
|
||||
|
||||
impl DialectHotReload {
|
||||
pub fn new(config: HotReloadConfig) -> Self {
|
||||
Self { watcher: None, config }
|
||||
}
|
||||
|
||||
/// Start watching. Returns a channel receiver for reload events.
|
||||
pub fn start(&mut self, watch_dirs: Vec<PathBuf>) -> Result<mpsc::UnboundedReceiver<ReloadEvent>, String> {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
|
||||
let _debounce = Duration::from_millis(self.config.debounce_ms);
|
||||
|
||||
let tx_clone = tx.clone();
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |res: Result<Event, notify::Error>| match res {
|
||||
Ok(event) => {
|
||||
if !Self::is_relevant(&event) {
|
||||
return;
|
||||
}
|
||||
for path in &event.paths {
|
||||
let _ = tx_clone.send(match event.kind {
|
||||
EventKind::Create(_) | EventKind::Modify(_) => ReloadEvent::Loaded { path: path.clone() },
|
||||
EventKind::Remove(_) => ReloadEvent::Removed {
|
||||
path: path.clone(),
|
||||
dialect: path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown").to_string(),
|
||||
},
|
||||
_ => continue,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx_clone
|
||||
.send(ReloadEvent::Error { path: PathBuf::from("."), error: format!("Watcher error: {e}") });
|
||||
}
|
||||
},
|
||||
Config::default()
|
||||
.with_poll_interval(Duration::from_millis(self.config.poll_interval_ms))
|
||||
.with_compare_contents(true),
|
||||
)
|
||||
.map_err(|e| format!("Failed to create file watcher: {e}"))?;
|
||||
|
||||
for dir in &watch_dirs {
|
||||
if dir.exists() {
|
||||
watcher
|
||||
.watch(dir, RecursiveMode::NonRecursive)
|
||||
.map_err(|e| format!("Failed to watch {}: {e}", dir.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
self.watcher = Some(watcher);
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// Process reload events in a loop, updating the registry.
|
||||
/// This is a convenience function that runs forever on the current task.
|
||||
pub async fn run_forever(
|
||||
watch_dirs: Vec<PathBuf>,
|
||||
registry: &'static super::dialect_loader::DialectRegistry,
|
||||
) -> Result<(), String> {
|
||||
let mut reload = Self::new(HotReloadConfig::default());
|
||||
let mut rx = reload.start(watch_dirs)?;
|
||||
|
||||
while let Some(event) = rx.recv().await {
|
||||
match &event {
|
||||
ReloadEvent::Loaded { path } => match DialectPluginLoader::load_file(path) {
|
||||
Ok((kind, yaml, descriptor)) => {
|
||||
registry.register_yaml(path, yaml, descriptor);
|
||||
log::info!("Hot-reload: loaded dialect {:?} from {}", kind, path.display());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Hot-reload: failed to load {}: {e}", path.display());
|
||||
}
|
||||
},
|
||||
ReloadEvent::Removed { path, dialect } => {
|
||||
registry.unregister(dialect);
|
||||
log::info!("Hot-reload: unregistered dialect {dialect} ({})", path.display());
|
||||
}
|
||||
ReloadEvent::Error { path, error } => {
|
||||
log::error!("Hot-reload: error watching {}: {error}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_relevant(event: &Event) -> bool {
|
||||
for path in &event.paths {
|
||||
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
|
||||
if matches!(ext, "yaml" | "yml") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
self.watcher = None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
use crate::models::connection::DatabaseType;
|
||||
use percent_encoding::percent_decode_str;
|
||||
|
||||
use super::capabilities::{is_schema_aware, is_simple_informix_identifier};
|
||||
|
||||
pub const SQLSERVER_LINKED_SCHEMA_PREFIX: &str = "__dbx_sqlserver_linked__:";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SqlServerLinkedSchemaRef {
|
||||
pub server: String,
|
||||
pub catalog: String,
|
||||
pub schema: String,
|
||||
}
|
||||
|
||||
pub fn parse_sqlserver_linked_schema_ref(schema: &str) -> Option<SqlServerLinkedSchemaRef> {
|
||||
let payload = schema.strip_prefix(SQLSERVER_LINKED_SCHEMA_PREFIX)?;
|
||||
let mut parts = payload.split('|');
|
||||
let server = decode_linked_schema_part(parts.next()?)?;
|
||||
let catalog = decode_linked_schema_part(parts.next()?)?;
|
||||
let schema = decode_linked_schema_part(parts.next()?)?;
|
||||
if parts.next().is_some() || server.trim().is_empty() || catalog.trim().is_empty() || schema.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(SqlServerLinkedSchemaRef { server, catalog, schema })
|
||||
}
|
||||
|
||||
pub fn sqlserver_linked_table_name(linked: &SqlServerLinkedSchemaRef, table_name: &str) -> String {
|
||||
[linked.server.as_str(), linked.catalog.as_str(), linked.schema.as_str(), table_name]
|
||||
.into_iter()
|
||||
.map(|part| quote_table_identifier(Some(DatabaseType::SqlServer), part))
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
|
||||
fn decode_linked_schema_part(value: &str) -> Option<String> {
|
||||
percent_decode_str(value).decode_utf8().ok().map(|value| value.into_owned())
|
||||
}
|
||||
|
||||
pub fn qualified_table_name(database_type: Option<DatabaseType>, schema: Option<&str>, table_name: &str) -> String {
|
||||
if database_type == Some(DatabaseType::Iotdb) {
|
||||
let table_name = quote_table_identifier(database_type, table_name);
|
||||
let schema = schema.map(str::trim).filter(|schema| !schema.is_empty());
|
||||
if let Some(schema) = schema {
|
||||
if table_name == schema || table_name.starts_with(&format!("{schema}.")) {
|
||||
return table_name;
|
||||
}
|
||||
return format!("{}.{}", quote_table_identifier(database_type, schema), table_name);
|
||||
}
|
||||
return table_name;
|
||||
}
|
||||
let supports_qualifier = database_type.is_some_and(is_schema_aware)
|
||||
|| matches!(
|
||||
database_type,
|
||||
Some(DatabaseType::Mysql | DatabaseType::Goldendb | DatabaseType::StarRocks | DatabaseType::Doris)
|
||||
);
|
||||
if supports_qualifier
|
||||
&& database_type != Some(DatabaseType::Jdbc)
|
||||
&& schema.is_some_and(|schema| !schema.trim().is_empty())
|
||||
{
|
||||
if database_type == Some(DatabaseType::SqlServer) {
|
||||
if let Some(linked) = schema.and_then(parse_sqlserver_linked_schema_ref) {
|
||||
return sqlserver_linked_table_name(&linked, table_name);
|
||||
}
|
||||
}
|
||||
return format!(
|
||||
"{}.{}",
|
||||
quote_table_identifier(database_type, schema.unwrap()),
|
||||
quote_table_identifier(database_type, table_name)
|
||||
);
|
||||
}
|
||||
quote_table_identifier(database_type, table_name)
|
||||
}
|
||||
|
||||
/// Like `qualified_table_name`, but also supports 3-part names for SQL Server
|
||||
/// (`<database>.<schema>.<table>`) and Doris/StarRocks external catalogs
|
||||
/// (`<catalog>.<database>.<table>`). SQL parsing stores the first segment of a
|
||||
/// 3-part source in `catalog`; for SQL Server that segment is its database.
|
||||
/// Doris/StarRocks use `schema` as the middle segment when present, otherwise
|
||||
/// `database`, and ignore their built-in `internal` catalog.
|
||||
pub fn qualified_table_name_with_catalog(
|
||||
database_type: Option<DatabaseType>,
|
||||
catalog: Option<&str>,
|
||||
schema: Option<&str>,
|
||||
database: Option<&str>,
|
||||
table_name: &str,
|
||||
) -> String {
|
||||
let catalog = catalog.map(str::trim).filter(|catalog| !catalog.is_empty());
|
||||
match (catalog, database_type) {
|
||||
(Some(database), Some(DatabaseType::SqlServer)) => {
|
||||
let table = qualified_table_name(database_type, schema, table_name);
|
||||
format!("{}.{}", quote_table_identifier(database_type, database), table)
|
||||
}
|
||||
(Some(catalog), Some(DatabaseType::Doris | DatabaseType::StarRocks)) if catalog != "internal" => {
|
||||
let middle = schema
|
||||
.map(str::trim)
|
||||
.filter(|schema| !schema.is_empty())
|
||||
.or_else(|| database.map(str::trim).filter(|database| !database.is_empty()));
|
||||
let table = match middle {
|
||||
Some(middle) => format!(
|
||||
"{}.{}",
|
||||
quote_table_identifier(database_type, middle),
|
||||
quote_table_identifier(database_type, table_name)
|
||||
),
|
||||
None => quote_table_identifier(database_type, table_name),
|
||||
};
|
||||
format!("{}.{}", quote_table_identifier(database_type, catalog), table)
|
||||
}
|
||||
_ => qualified_table_name(database_type, schema, table_name),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn quote_table_identifier(database_type: Option<DatabaseType>, name: &str) -> String {
|
||||
if matches!(database_type, Some(DatabaseType::Gaussdb | DatabaseType::OpenGauss))
|
||||
&& is_explicitly_quoted_identifier(name)
|
||||
{
|
||||
return name.to_string();
|
||||
}
|
||||
match database_type {
|
||||
Some(DatabaseType::Iotdb) => name.to_string(),
|
||||
// JDBC connections use the driver-reported identifier quote string
|
||||
// (DatabaseMetaData.getIdentifierQuoteString()) inside the JDBC agent,
|
||||
// so the Rust layer passes identifiers through unquoted.
|
||||
Some(DatabaseType::Jdbc) => name.to_string(),
|
||||
Some(
|
||||
DatabaseType::Mysql
|
||||
| DatabaseType::ClickHouse
|
||||
| DatabaseType::Doris
|
||||
| DatabaseType::Goldendb
|
||||
| DatabaseType::StarRocks
|
||||
| DatabaseType::ManticoreSearch
|
||||
| DatabaseType::Hive
|
||||
| DatabaseType::Kyuubi
|
||||
| DatabaseType::Impala
|
||||
| DatabaseType::Argo
|
||||
| DatabaseType::Spark
|
||||
| DatabaseType::Databricks
|
||||
| DatabaseType::Databend
|
||||
| DatabaseType::Tdengine
|
||||
| DatabaseType::Access
|
||||
| DatabaseType::Bigquery
|
||||
// GoogleSQL (Spanner's default dialect) quotes identifiers with backticks;
|
||||
// double quotes are string literals there, so `SELECT * FROM "users"` is a
|
||||
// syntax error. PostgreSQL-dialect Spanner databases override this through
|
||||
// the identifier quote reported by the agent on connect.
|
||||
| DatabaseType::Spanner
|
||||
| DatabaseType::Questdb,
|
||||
) => {
|
||||
format!("`{}`", name.replace('`', "``"))
|
||||
}
|
||||
Some(DatabaseType::Informix) if is_simple_informix_identifier(name) => name.to_string(),
|
||||
Some(DatabaseType::Neo4j) => format!("`{}`", name.replace('`', "``")),
|
||||
Some(DatabaseType::SqlServer) => format!("[{}]", name.replace(']', "]]")),
|
||||
_ => format!("\"{}\"", name.replace('"', "\"\"")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Quote an IRIS identifier only when its spelling requires a delimited name.
|
||||
///
|
||||
/// Caché/IRIS installations may disable delimited identifiers. The IRIS JDBC
|
||||
/// preparser turns quoted names into `:%qpar(...)` parameters in that mode,
|
||||
/// which is not valid in table references. Ordinary metadata names are
|
||||
/// case-insensitive and can be sent unquoted; names containing separators or
|
||||
/// other punctuation still need the identifier quote character.
|
||||
pub fn quote_iris_identifier(name: &str, identifier_quote: Option<&str>) -> String {
|
||||
let mut chars = name.chars();
|
||||
let ordinary = chars.next().is_some_and(|first| first == '_' || first == '%' || first.is_alphabetic())
|
||||
&& chars.all(|ch| ch == '_' || ch.is_alphanumeric());
|
||||
if ordinary {
|
||||
return name.to_string();
|
||||
}
|
||||
let quote = identifier_quote.map(str::trim).filter(|quote| !quote.is_empty()).unwrap_or("\"");
|
||||
format!("{quote}{}{quote}", name.replace(quote, &format!("{quote}{quote}")))
|
||||
}
|
||||
|
||||
pub fn quote_gaussdb_jdbc_identifier(name: &str, identifier_quote: &str) -> String {
|
||||
if is_explicitly_quoted_identifier(name) {
|
||||
return name.to_string();
|
||||
}
|
||||
let quote = identifier_quote.trim();
|
||||
if quote.is_empty() {
|
||||
return name.to_string();
|
||||
}
|
||||
let requires_quote = !is_simple_lower_identifier(name)
|
||||
|| is_postgres_reserved_identifier(name)
|
||||
|| (quote == "`" && is_mysql_only_reserved_identifier(name));
|
||||
if !requires_quote {
|
||||
return name.to_string();
|
||||
}
|
||||
format!("{quote}{}{quote}", name.replace(quote, &format!("{quote}{quote}")))
|
||||
}
|
||||
|
||||
fn is_explicitly_quoted_identifier(name: &str) -> bool {
|
||||
name.len() >= 2
|
||||
&& ((name.starts_with('"') && name.ends_with('"'))
|
||||
|| (name.starts_with('`') && name.ends_with('`'))
|
||||
|| (name.starts_with('[') && name.ends_with(']')))
|
||||
}
|
||||
|
||||
fn is_simple_lower_identifier(name: &str) -> bool {
|
||||
let mut chars = name.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
(first == '_' || first.is_ascii_lowercase())
|
||||
&& chars.all(|ch| ch == '_' || ch == '$' || ch.is_ascii_lowercase() || ch.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn is_postgres_reserved_identifier(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"all"
|
||||
| "analyse"
|
||||
| "analyze"
|
||||
| "and"
|
||||
| "any"
|
||||
| "array"
|
||||
| "as"
|
||||
| "asc"
|
||||
| "asymmetric"
|
||||
| "authorization"
|
||||
| "binary"
|
||||
| "both"
|
||||
| "case"
|
||||
| "cast"
|
||||
| "check"
|
||||
| "collate"
|
||||
| "collation"
|
||||
| "column"
|
||||
| "concurrently"
|
||||
| "constraint"
|
||||
| "create"
|
||||
| "cross"
|
||||
| "current_catalog"
|
||||
| "current_date"
|
||||
| "current_role"
|
||||
| "current_schema"
|
||||
| "current_time"
|
||||
| "current_timestamp"
|
||||
| "current_user"
|
||||
| "default"
|
||||
| "deferrable"
|
||||
| "desc"
|
||||
| "distinct"
|
||||
| "do"
|
||||
| "else"
|
||||
| "end"
|
||||
| "except"
|
||||
| "false"
|
||||
| "fetch"
|
||||
| "for"
|
||||
| "foreign"
|
||||
| "freeze"
|
||||
| "from"
|
||||
| "full"
|
||||
| "grant"
|
||||
| "group"
|
||||
| "having"
|
||||
| "ilike"
|
||||
| "in"
|
||||
| "initially"
|
||||
| "inner"
|
||||
| "intersect"
|
||||
| "into"
|
||||
| "is"
|
||||
| "isnull"
|
||||
| "join"
|
||||
| "lateral"
|
||||
| "leading"
|
||||
| "left"
|
||||
| "like"
|
||||
| "limit"
|
||||
| "localtime"
|
||||
| "localtimestamp"
|
||||
| "natural"
|
||||
| "not"
|
||||
| "notnull"
|
||||
| "null"
|
||||
| "offset"
|
||||
| "on"
|
||||
| "only"
|
||||
| "or"
|
||||
| "order"
|
||||
| "outer"
|
||||
| "overlaps"
|
||||
| "placing"
|
||||
| "primary"
|
||||
| "references"
|
||||
| "returning"
|
||||
| "right"
|
||||
| "select"
|
||||
| "session_user"
|
||||
| "similar"
|
||||
| "some"
|
||||
| "symmetric"
|
||||
| "system_user"
|
||||
| "table"
|
||||
| "tablesample"
|
||||
| "then"
|
||||
| "to"
|
||||
| "trailing"
|
||||
| "true"
|
||||
| "union"
|
||||
| "unique"
|
||||
| "user"
|
||||
| "using"
|
||||
| "variadic"
|
||||
| "verbose"
|
||||
| "when"
|
||||
| "where"
|
||||
| "window"
|
||||
| "with"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_mysql_only_reserved_identifier(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"accessible"
|
||||
| "auto_increment"
|
||||
| "change"
|
||||
| "database"
|
||||
| "databases"
|
||||
| "delayed"
|
||||
| "describe"
|
||||
| "div"
|
||||
| "dual"
|
||||
| "enclosed"
|
||||
| "escaped"
|
||||
| "explain"
|
||||
| "force"
|
||||
| "fulltext"
|
||||
| "high_priority"
|
||||
| "ignore"
|
||||
| "index"
|
||||
| "infile"
|
||||
| "key"
|
||||
| "keys"
|
||||
| "kill"
|
||||
| "linear"
|
||||
| "lines"
|
||||
| "load"
|
||||
| "lock"
|
||||
| "low_priority"
|
||||
| "master_ssl_verify_server_cert"
|
||||
| "maxvalue"
|
||||
| "mediumint"
|
||||
| "mod"
|
||||
| "no_write_to_binlog"
|
||||
| "optimize"
|
||||
| "optionally"
|
||||
| "outfile"
|
||||
| "partition"
|
||||
| "purge"
|
||||
| "range"
|
||||
| "read_write"
|
||||
| "regexp"
|
||||
| "release"
|
||||
| "rename"
|
||||
| "replace"
|
||||
| "require"
|
||||
| "rlike"
|
||||
| "schema"
|
||||
| "schemas"
|
||||
| "separator"
|
||||
| "show"
|
||||
| "spatial"
|
||||
| "sql_big_result"
|
||||
| "sql_calc_found_rows"
|
||||
| "sql_small_result"
|
||||
| "ssl"
|
||||
| "starting"
|
||||
| "straight_join"
|
||||
| "terminated"
|
||||
| "tinyint"
|
||||
| "unlock"
|
||||
| "unsigned"
|
||||
| "use"
|
||||
| "utc_date"
|
||||
| "utc_time"
|
||||
| "utc_timestamp"
|
||||
| "values"
|
||||
| "varbinary"
|
||||
| "varchar"
|
||||
| "write"
|
||||
| "xor"
|
||||
| "zerofill"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn normalize_where_input(where_input: Option<&str>) -> String {
|
||||
let trimmed = where_input.unwrap_or("").trim().trim_end_matches(';').trim();
|
||||
let mut chars = trimmed.chars();
|
||||
let prefix = chars.by_ref().take(5).collect::<String>();
|
||||
if prefix.eq_ignore_ascii_case("where") {
|
||||
chars.as_str().trim().to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn quote_transfer_identifier(name: &str, database_type: &DatabaseType) -> String {
|
||||
match database_type {
|
||||
DatabaseType::Mysql
|
||||
| DatabaseType::ClickHouse
|
||||
| DatabaseType::Doris
|
||||
| DatabaseType::StarRocks
|
||||
| DatabaseType::Hive
|
||||
| DatabaseType::Kyuubi
|
||||
| DatabaseType::Impala
|
||||
| DatabaseType::Argo
|
||||
| DatabaseType::Spark
|
||||
| DatabaseType::Questdb => format!("`{}`", name.replace('`', "``")),
|
||||
DatabaseType::SqlServer => format!("[{}]", name.replace(']', "]]")),
|
||||
_ => format!("\"{}\"", name.replace('\"', "\"\"")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transfer_column_identifier(name: &str, database_type: &DatabaseType, quote_target_column_names: bool) -> String {
|
||||
if quote_target_column_names
|
||||
|| !matches!(database_type, DatabaseType::Gaussdb | DatabaseType::OpenGauss)
|
||||
|| !is_simple_unquoted_identifier(name)
|
||||
|| is_postgres_reserved_identifier(&name.to_ascii_lowercase())
|
||||
{
|
||||
quote_transfer_identifier(name, database_type)
|
||||
} else {
|
||||
name.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_simple_unquoted_identifier(name: &str) -> bool {
|
||||
let mut chars = name.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
(first == '_' || first.is_ascii_alphabetic())
|
||||
&& chars.all(|ch| ch == '_' || ch == '$' || ch.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
/// Qualified table name for transfer SQL.
|
||||
///
|
||||
/// * Without catalog: produces `schema.table` (or just `table` for MySQL family).
|
||||
/// * With catalog AND the database type supports external catalogs (Doris/StarRocks):
|
||||
/// produces `catalog.schema.table` — the 3-part form those engines require to
|
||||
/// address objects in an external (non-internal) catalog.
|
||||
pub fn qualified_transfer_table(
|
||||
table_name: &str,
|
||||
schema: &str,
|
||||
database_type: &DatabaseType,
|
||||
catalog: Option<&str>,
|
||||
) -> String {
|
||||
let table = quote_transfer_identifier(table_name, database_type);
|
||||
if let Some(catalog) = catalog {
|
||||
format!(
|
||||
"{}.{}.{}",
|
||||
quote_transfer_identifier(catalog, database_type),
|
||||
quote_transfer_identifier(schema, database_type),
|
||||
table
|
||||
)
|
||||
} else if schema.is_empty()
|
||||
|| matches!(database_type, DatabaseType::Mysql | DatabaseType::MongoDb | DatabaseType::Questdb)
|
||||
{
|
||||
table
|
||||
} else {
|
||||
format!("{}.{}", quote_transfer_identifier(schema, database_type), table)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
use super::descriptor::{DialectKind, TypeMappingMatrix};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ColumnType {
|
||||
pub base_type: String,
|
||||
pub precision: Option<u32>,
|
||||
pub scale: Option<u32>,
|
||||
pub length: Option<u32>,
|
||||
pub is_array: bool,
|
||||
pub is_unsigned: bool,
|
||||
pub extras: Vec<String>,
|
||||
}
|
||||
|
||||
impl ColumnType {
|
||||
pub fn parse(type_str: &str) -> Self {
|
||||
let trimmed = type_str.trim();
|
||||
let (base_type, params_str) = match trimmed.find('(') {
|
||||
Some(pos) if trimmed.ends_with(')') => {
|
||||
let base = trimmed[..pos].trim().to_string();
|
||||
let params = trimmed[pos + 1..trimmed.len() - 1].trim();
|
||||
(base, Some(params))
|
||||
}
|
||||
_ => (trimmed.to_string(), None),
|
||||
};
|
||||
|
||||
let is_unsigned = base_type.to_ascii_lowercase().contains("unsigned");
|
||||
let is_array = trimmed.ends_with("[]") || trimmed.to_ascii_lowercase().contains(" array");
|
||||
let base_type_clean =
|
||||
base_type.replace(" unsigned", "").replace(" UNSIGNED", "").replace("[]", "").trim().to_string();
|
||||
|
||||
let (precision, scale, length) = match params_str {
|
||||
Some(params) if params.contains(',') => {
|
||||
let parts: Vec<&str> = params.split(',').collect();
|
||||
if parts.len() >= 2 {
|
||||
let p = parts[0].trim().parse().ok();
|
||||
let s = parts[1].trim().parse().ok();
|
||||
(p, s, None)
|
||||
} else {
|
||||
(None, None, params.parse().ok())
|
||||
}
|
||||
}
|
||||
Some(params) => {
|
||||
let v = params.parse().ok();
|
||||
(None, None, v)
|
||||
}
|
||||
None => (None, None, None),
|
||||
};
|
||||
|
||||
Self { base_type: base_type_clean, precision, scale, length, is_array, is_unsigned, extras: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn to_string(&self, dialect: DialectKind) -> String {
|
||||
let mut result = self.base_type.clone();
|
||||
|
||||
if self.is_unsigned && dialect == DialectKind::Mysql {
|
||||
result.push_str(" UNSIGNED");
|
||||
}
|
||||
|
||||
if let Some(len) = self.length {
|
||||
result.push_str(&format!("({len})"));
|
||||
} else if let Some(p) = self.precision {
|
||||
if let Some(s) = self.scale {
|
||||
result.push_str(&format!("({p},{s})"));
|
||||
} else {
|
||||
result.push_str(&format!("({p})"));
|
||||
}
|
||||
}
|
||||
|
||||
if self.is_array && (dialect == DialectKind::Postgres || dialect == DialectKind::DuckDb) {
|
||||
result.push_str("[]");
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub trait TypeInferenceEngine {
|
||||
fn infer_type(&self, source_type: &str, source_dialect: DialectKind, target_dialect: DialectKind) -> ColumnType;
|
||||
|
||||
fn suggest_type(&self, column: &ColumnType, target_dialect: DialectKind) -> String;
|
||||
|
||||
fn convert_default_value(
|
||||
&self,
|
||||
default_expr: &str,
|
||||
source_dialect: DialectKind,
|
||||
target_dialect: DialectKind,
|
||||
) -> String;
|
||||
|
||||
fn type_compatibility_score(&self, source: &ColumnType, target: &ColumnType) -> f64;
|
||||
}
|
||||
|
||||
const SCORE_IDENTICAL: f64 = 1.0;
|
||||
const SCORE_ALIAS: f64 = 0.9;
|
||||
const SCORE_INTEGER_FAMILY: f64 = 0.8;
|
||||
const SCORE_TEXT_FAMILY: f64 = 0.7;
|
||||
const SCORE_UNRELATED: f64 = 0.3;
|
||||
|
||||
pub struct DefaultTypeInferenceEngine;
|
||||
|
||||
impl DefaultTypeInferenceEngine {
|
||||
fn map_type_name(&self, type_name: &str, from: DialectKind, to: DialectKind) -> String {
|
||||
let matrix = TypeMappingMatrix::for_dialects(from, to);
|
||||
let (converted, _) = matrix.convert_type(type_name);
|
||||
converted
|
||||
}
|
||||
}
|
||||
|
||||
impl TypeInferenceEngine for DefaultTypeInferenceEngine {
|
||||
fn infer_type(&self, source_type: &str, source_dialect: DialectKind, target_dialect: DialectKind) -> ColumnType {
|
||||
let source_parsed = ColumnType::parse(source_type);
|
||||
let mapped_base = self.map_type_name(&source_parsed.base_type, source_dialect, target_dialect);
|
||||
|
||||
let target_caps = crate::sql_dialect::resolve(target_dialect);
|
||||
let needs_precision = mapped_base.to_ascii_lowercase().contains("decimal")
|
||||
|| mapped_base.to_ascii_lowercase().contains("numeric")
|
||||
|| mapped_base.to_ascii_lowercase().contains("varchar");
|
||||
|
||||
let (precision, scale, length) = if needs_precision {
|
||||
(source_parsed.precision, source_parsed.scale, source_parsed.length)
|
||||
} else if mapped_base.eq_ignore_ascii_case("integer") && source_parsed.precision.is_some() {
|
||||
(None, None, None)
|
||||
} else {
|
||||
(source_parsed.precision, source_parsed.scale, source_parsed.length)
|
||||
};
|
||||
|
||||
ColumnType {
|
||||
base_type: mapped_base,
|
||||
precision,
|
||||
scale,
|
||||
length,
|
||||
is_array: source_parsed.is_array && target_caps.supports_array_type,
|
||||
is_unsigned: source_parsed.is_unsigned && target_dialect == DialectKind::Mysql,
|
||||
extras: source_parsed.extras,
|
||||
}
|
||||
}
|
||||
|
||||
fn suggest_type(&self, column: &ColumnType, target_dialect: DialectKind) -> String {
|
||||
column.to_string(target_dialect)
|
||||
}
|
||||
|
||||
fn convert_default_value(
|
||||
&self,
|
||||
default_expr: &str,
|
||||
source_dialect: DialectKind,
|
||||
target_dialect: DialectKind,
|
||||
) -> String {
|
||||
let trimmed = default_expr.trim();
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") || trimmed.eq_ignore_ascii_case("NULL") {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
match (source_dialect, target_dialect) {
|
||||
(DialectKind::Mysql, DialectKind::Postgres) => {
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if lower == "current_timestamp" || lower == "current_timestamp()" || lower == "now()" {
|
||||
return "CURRENT_TIMESTAMP".to_string();
|
||||
}
|
||||
if lower.starts_with("on update ") {
|
||||
return String::new();
|
||||
}
|
||||
}
|
||||
(DialectKind::Postgres, DialectKind::Mysql) => {
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if lower == "current_timestamp" || lower == "now()" || lower == "transaction_timestamp()" {
|
||||
return "CURRENT_TIMESTAMP".to_string();
|
||||
}
|
||||
}
|
||||
(DialectKind::Mysql, DialectKind::Sqlite) => {
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if lower == "current_timestamp" || lower == "current_timestamp()" || lower == "now()" {
|
||||
return "CURRENT_TIMESTAMP".to_string();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
fn type_compatibility_score(&self, source: &ColumnType, target: &ColumnType) -> f64 {
|
||||
let source_lower = source.base_type.to_ascii_lowercase();
|
||||
let target_lower = target.base_type.to_ascii_lowercase();
|
||||
|
||||
if source_lower == target_lower {
|
||||
return SCORE_IDENTICAL;
|
||||
}
|
||||
|
||||
let exact_matches = [
|
||||
("int", "integer"),
|
||||
("integer", "int"),
|
||||
("float", "real"),
|
||||
("real", "float"),
|
||||
("double", "double precision"),
|
||||
("double precision", "double"),
|
||||
("bool", "boolean"),
|
||||
("boolean", "bool"),
|
||||
("timestamp", "datetime"),
|
||||
("datetime", "timestamp"),
|
||||
];
|
||||
if exact_matches.contains(&(source_lower.as_str(), target_lower.as_str())) {
|
||||
return SCORE_ALIAS;
|
||||
}
|
||||
|
||||
let integer_family = ["tinyint", "smallint", "mediumint", "int", "integer", "bigint", "serial", "bigserial"];
|
||||
let text_family = ["char", "varchar", "text", "tinytext", "mediumtext", "longtext", "clob", "nclob"];
|
||||
|
||||
if integer_family.contains(&source_lower.as_str()) && integer_family.contains(&target_lower.as_str()) {
|
||||
return SCORE_INTEGER_FAMILY;
|
||||
}
|
||||
if text_family.contains(&source_lower.as_str()) && text_family.contains(&target_lower.as_str()) {
|
||||
return SCORE_TEXT_FAMILY;
|
||||
}
|
||||
|
||||
SCORE_UNRELATED
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_simple_type() {
|
||||
let t = ColumnType::parse("INT");
|
||||
assert_eq!(t.base_type, "INT");
|
||||
assert!(t.precision.is_none());
|
||||
assert!(t.length.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_type_with_length() {
|
||||
let t = ColumnType::parse("VARCHAR(255)");
|
||||
assert_eq!(t.base_type, "VARCHAR");
|
||||
assert_eq!(t.length, Some(255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_type_with_precision_scale() {
|
||||
let t = ColumnType::parse("DECIMAL(10,2)");
|
||||
assert_eq!(t.base_type, "DECIMAL");
|
||||
assert_eq!(t.precision, Some(10));
|
||||
assert_eq!(t.scale, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unsigned_type() {
|
||||
let t = ColumnType::parse("INT UNSIGNED");
|
||||
assert_eq!(t.base_type, "INT");
|
||||
assert!(t.is_unsigned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_mysql_int_to_postgres() {
|
||||
let engine = DefaultTypeInferenceEngine;
|
||||
let result = engine.infer_type("INT", DialectKind::Mysql, DialectKind::Postgres);
|
||||
assert_eq!(result.base_type, "INTEGER");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_mysql_datetime_to_postgres() {
|
||||
let engine = DefaultTypeInferenceEngine;
|
||||
let result = engine.infer_type("DATETIME", DialectKind::Mysql, DialectKind::Postgres);
|
||||
assert_eq!(result.base_type, "TIMESTAMP");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_postgres_text_to_mysql() {
|
||||
let engine = DefaultTypeInferenceEngine;
|
||||
let result = engine.infer_type("TEXT", DialectKind::Postgres, DialectKind::Mysql);
|
||||
assert_eq!(result.base_type, "LONGTEXT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_compatibility_exact_match() {
|
||||
let engine = DefaultTypeInferenceEngine;
|
||||
let a = ColumnType::parse("INT");
|
||||
let b = ColumnType::parse("INTEGER");
|
||||
let score = engine.type_compatibility_score(&a, &b);
|
||||
assert!((score - 0.9).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_compatibility_integer_family() {
|
||||
let engine = DefaultTypeInferenceEngine;
|
||||
let a = ColumnType::parse("SMALLINT");
|
||||
let b = ColumnType::parse("BIGINT");
|
||||
let score = engine.type_compatibility_score(&a, &b);
|
||||
assert!((score - 0.8).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_default_value_mysql_to_postgres() {
|
||||
let engine = DefaultTypeInferenceEngine;
|
||||
assert_eq!(
|
||||
engine.convert_default_value("CURRENT_TIMESTAMP", DialectKind::Mysql, DialectKind::Postgres),
|
||||
"CURRENT_TIMESTAMP"
|
||||
);
|
||||
assert_eq!(
|
||||
engine.convert_default_value("NOW()", DialectKind::Mysql, DialectKind::Postgres),
|
||||
"CURRENT_TIMESTAMP"
|
||||
);
|
||||
assert!(engine
|
||||
.convert_default_value("ON UPDATE CURRENT_TIMESTAMP", DialectKind::Mysql, DialectKind::Postgres)
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_type_passthrough() {
|
||||
let engine = DefaultTypeInferenceEngine;
|
||||
let result = engine.infer_type("GEOGRAPHY(POINT)", DialectKind::Postgres, DialectKind::Mysql);
|
||||
assert_eq!(result.base_type, "GEOGRAPHY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_array_type() {
|
||||
let t = ColumnType::parse("INTEGER[]");
|
||||
assert!(t.is_array);
|
||||
assert_eq!(t.base_type, "INTEGER");
|
||||
}
|
||||
}
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
---
|
||||
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
|
||||
assertion_line: 67
|
||||
expression: all
|
||||
---
|
||||
[
|
||||
{
|
||||
"kind": "mysql",
|
||||
"label": "mysql",
|
||||
"capabilities": [
|
||||
"add_column",
|
||||
"drop_column",
|
||||
"rename_column",
|
||||
"alter_existing_column",
|
||||
"reorder_column",
|
||||
"comment",
|
||||
"create_index",
|
||||
"drop_index",
|
||||
"rebuild_index",
|
||||
"index_type",
|
||||
"index_comment",
|
||||
"alter_primary_key",
|
||||
"foreign_key",
|
||||
"create_table",
|
||||
"drop_table",
|
||||
"truncate_table",
|
||||
"create_trigger",
|
||||
"drop_trigger",
|
||||
"create_function",
|
||||
"drop_function",
|
||||
"create_sequence",
|
||||
"drop_sequence",
|
||||
"alter_owner",
|
||||
"grant_revoke",
|
||||
"if_not_exists",
|
||||
"temporary_table",
|
||||
"auto_increment"
|
||||
],
|
||||
"max_identifier_length": 64,
|
||||
"supports_schemas": false,
|
||||
"supports_catalogs": false,
|
||||
"max_columns_per_table": 4096,
|
||||
"supports_json_type": true,
|
||||
"supports_array_type": false,
|
||||
"supports_enum_type": true,
|
||||
"supports_uuid_type": false,
|
||||
"supports_auto_increment": true,
|
||||
"supports_identity_columns": false,
|
||||
"supports_sequences": false,
|
||||
"supports_partitioning": true,
|
||||
"supports_full_text_index": true,
|
||||
"supports_spatial_index": true,
|
||||
"supports_transactional_ddl": false
|
||||
},
|
||||
{
|
||||
"kind": "postgres",
|
||||
"label": "postgres",
|
||||
"capabilities": [
|
||||
"add_column",
|
||||
"drop_column",
|
||||
"rename_column",
|
||||
"alter_existing_column",
|
||||
"comment",
|
||||
"create_index",
|
||||
"drop_index",
|
||||
"rebuild_index",
|
||||
"index_type",
|
||||
"index_include",
|
||||
"index_filter",
|
||||
"index_comment",
|
||||
"alter_primary_key",
|
||||
"foreign_key",
|
||||
"create_table",
|
||||
"drop_table",
|
||||
"truncate_table",
|
||||
"create_trigger",
|
||||
"drop_trigger",
|
||||
"create_function",
|
||||
"drop_function",
|
||||
"create_sequence",
|
||||
"drop_sequence",
|
||||
"alter_owner",
|
||||
"grant_revoke",
|
||||
"if_not_exists",
|
||||
"create_or_replace",
|
||||
"temporary_table",
|
||||
"transactional_ddl",
|
||||
"identity_columns"
|
||||
],
|
||||
"max_identifier_length": 63,
|
||||
"supports_schemas": true,
|
||||
"supports_catalogs": false,
|
||||
"max_columns_per_table": 1600,
|
||||
"supports_json_type": true,
|
||||
"supports_array_type": true,
|
||||
"supports_enum_type": true,
|
||||
"supports_uuid_type": true,
|
||||
"supports_auto_increment": false,
|
||||
"supports_identity_columns": true,
|
||||
"supports_sequences": true,
|
||||
"supports_partitioning": true,
|
||||
"supports_full_text_index": true,
|
||||
"supports_spatial_index": true,
|
||||
"supports_transactional_ddl": true
|
||||
},
|
||||
{
|
||||
"kind": "sqlite",
|
||||
"label": "sqlite",
|
||||
"capabilities": [
|
||||
"add_column",
|
||||
"drop_column",
|
||||
"rename_column",
|
||||
"create_index",
|
||||
"drop_index",
|
||||
"rebuild_index",
|
||||
"index_filter",
|
||||
"create_table",
|
||||
"drop_table",
|
||||
"truncate_table",
|
||||
"create_trigger",
|
||||
"drop_trigger",
|
||||
"create_function",
|
||||
"drop_function",
|
||||
"create_sequence",
|
||||
"drop_sequence",
|
||||
"if_not_exists",
|
||||
"auto_increment"
|
||||
],
|
||||
"max_identifier_length": 255,
|
||||
"supports_schemas": false,
|
||||
"supports_catalogs": false,
|
||||
"max_columns_per_table": 2000,
|
||||
"supports_json_type": false,
|
||||
"supports_array_type": false,
|
||||
"supports_enum_type": false,
|
||||
"supports_uuid_type": false,
|
||||
"supports_auto_increment": true,
|
||||
"supports_identity_columns": false,
|
||||
"supports_sequences": false,
|
||||
"supports_partitioning": false,
|
||||
"supports_full_text_index": true,
|
||||
"supports_spatial_index": false,
|
||||
"supports_transactional_ddl": false
|
||||
},
|
||||
{
|
||||
"kind": "duck_db",
|
||||
"label": "duckdb",
|
||||
"capabilities": [
|
||||
"add_column",
|
||||
"drop_column",
|
||||
"rename_column",
|
||||
"create_index",
|
||||
"drop_index",
|
||||
"rebuild_index",
|
||||
"create_table",
|
||||
"drop_table",
|
||||
"truncate_table",
|
||||
"if_not_exists",
|
||||
"create_or_replace",
|
||||
"temporary_table"
|
||||
],
|
||||
"max_identifier_length": 255,
|
||||
"supports_schemas": true,
|
||||
"supports_catalogs": false,
|
||||
"max_columns_per_table": 1600,
|
||||
"supports_json_type": true,
|
||||
"supports_array_type": true,
|
||||
"supports_enum_type": true,
|
||||
"supports_uuid_type": true,
|
||||
"supports_auto_increment": false,
|
||||
"supports_identity_columns": false,
|
||||
"supports_sequences": true,
|
||||
"supports_partitioning": true,
|
||||
"supports_full_text_index": false,
|
||||
"supports_spatial_index": true,
|
||||
"supports_transactional_ddl": false
|
||||
},
|
||||
{
|
||||
"kind": "sql_server",
|
||||
"label": "sqlserver",
|
||||
"capabilities": [
|
||||
"add_column",
|
||||
"drop_column",
|
||||
"rename_column",
|
||||
"alter_existing_column",
|
||||
"comment",
|
||||
"create_index",
|
||||
"drop_index",
|
||||
"rebuild_index",
|
||||
"index_type",
|
||||
"index_include",
|
||||
"index_filter",
|
||||
"index_comment",
|
||||
"create_table",
|
||||
"drop_table",
|
||||
"truncate_table",
|
||||
"create_trigger",
|
||||
"drop_trigger",
|
||||
"create_function",
|
||||
"drop_function",
|
||||
"create_sequence",
|
||||
"drop_sequence",
|
||||
"alter_owner",
|
||||
"grant_revoke",
|
||||
"temporary_table",
|
||||
"transactional_ddl",
|
||||
"identity_columns"
|
||||
],
|
||||
"max_identifier_length": 128,
|
||||
"supports_schemas": true,
|
||||
"supports_catalogs": true,
|
||||
"max_columns_per_table": 1024,
|
||||
"supports_json_type": true,
|
||||
"supports_array_type": false,
|
||||
"supports_enum_type": false,
|
||||
"supports_uuid_type": true,
|
||||
"supports_auto_increment": false,
|
||||
"supports_identity_columns": true,
|
||||
"supports_sequences": true,
|
||||
"supports_partitioning": true,
|
||||
"supports_full_text_index": true,
|
||||
"supports_spatial_index": true,
|
||||
"supports_transactional_ddl": true
|
||||
},
|
||||
{
|
||||
"kind": "oracle",
|
||||
"label": "oracle",
|
||||
"capabilities": [
|
||||
"add_column",
|
||||
"drop_column",
|
||||
"rename_column",
|
||||
"alter_existing_column",
|
||||
"comment",
|
||||
"create_index",
|
||||
"drop_index",
|
||||
"rebuild_index",
|
||||
"index_type",
|
||||
"create_table",
|
||||
"drop_table",
|
||||
"truncate_table",
|
||||
"create_trigger",
|
||||
"drop_trigger",
|
||||
"create_function",
|
||||
"drop_function",
|
||||
"create_sequence",
|
||||
"drop_sequence",
|
||||
"alter_owner",
|
||||
"grant_revoke",
|
||||
"if_not_exists",
|
||||
"temporary_table",
|
||||
"identity_columns"
|
||||
],
|
||||
"max_identifier_length": 30,
|
||||
"supports_schemas": true,
|
||||
"supports_catalogs": false,
|
||||
"max_columns_per_table": 1000,
|
||||
"supports_json_type": true,
|
||||
"supports_array_type": false,
|
||||
"supports_enum_type": false,
|
||||
"supports_uuid_type": false,
|
||||
"supports_auto_increment": false,
|
||||
"supports_identity_columns": true,
|
||||
"supports_sequences": true,
|
||||
"supports_partitioning": true,
|
||||
"supports_full_text_index": true,
|
||||
"supports_spatial_index": true,
|
||||
"supports_transactional_ddl": false
|
||||
},
|
||||
{
|
||||
"kind": "h2",
|
||||
"label": "h2",
|
||||
"capabilities": [
|
||||
"add_column",
|
||||
"drop_column",
|
||||
"rename_column",
|
||||
"alter_existing_column",
|
||||
"comment",
|
||||
"create_index",
|
||||
"drop_index",
|
||||
"rebuild_index",
|
||||
"create_table",
|
||||
"drop_table",
|
||||
"truncate_table",
|
||||
"create_trigger",
|
||||
"drop_trigger",
|
||||
"create_function",
|
||||
"drop_function",
|
||||
"if_not_exists",
|
||||
"temporary_table",
|
||||
"identity_columns"
|
||||
],
|
||||
"max_identifier_length": 256,
|
||||
"supports_schemas": true,
|
||||
"supports_catalogs": false,
|
||||
"max_columns_per_table": 1600,
|
||||
"supports_json_type": true,
|
||||
"supports_array_type": false,
|
||||
"supports_enum_type": false,
|
||||
"supports_uuid_type": true,
|
||||
"supports_auto_increment": false,
|
||||
"supports_identity_columns": true,
|
||||
"supports_sequences": true,
|
||||
"supports_partitioning": false,
|
||||
"supports_full_text_index": true,
|
||||
"supports_spatial_index": true,
|
||||
"supports_transactional_ddl": false
|
||||
},
|
||||
{
|
||||
"kind": "click_house",
|
||||
"label": "clickhouse",
|
||||
"capabilities": [
|
||||
"add_column",
|
||||
"drop_column",
|
||||
"rename_column",
|
||||
"alter_existing_column",
|
||||
"reorder_column",
|
||||
"comment",
|
||||
"create_table",
|
||||
"drop_table",
|
||||
"truncate_table",
|
||||
"if_not_exists",
|
||||
"temporary_table"
|
||||
],
|
||||
"max_identifier_length": 256,
|
||||
"supports_schemas": true,
|
||||
"supports_catalogs": false,
|
||||
"max_columns_per_table": 1000,
|
||||
"supports_json_type": true,
|
||||
"supports_array_type": true,
|
||||
"supports_enum_type": true,
|
||||
"supports_uuid_type": true,
|
||||
"supports_auto_increment": false,
|
||||
"supports_identity_columns": false,
|
||||
"supports_sequences": false,
|
||||
"supports_partitioning": true,
|
||||
"supports_full_text_index": true,
|
||||
"supports_spatial_index": false,
|
||||
"supports_transactional_ddl": false
|
||||
},
|
||||
{
|
||||
"kind": "manticore_search",
|
||||
"label": "manticoresearch",
|
||||
"capabilities": [
|
||||
"add_column",
|
||||
"drop_column",
|
||||
"create_table",
|
||||
"drop_table",
|
||||
"truncate_table",
|
||||
"auto_increment"
|
||||
],
|
||||
"max_identifier_length": 256,
|
||||
"supports_schemas": false,
|
||||
"supports_catalogs": false,
|
||||
"max_columns_per_table": 512,
|
||||
"supports_json_type": true,
|
||||
"supports_array_type": false,
|
||||
"supports_enum_type": false,
|
||||
"supports_uuid_type": false,
|
||||
"supports_auto_increment": true,
|
||||
"supports_identity_columns": false,
|
||||
"supports_sequences": false,
|
||||
"supports_partitioning": false,
|
||||
"supports_full_text_index": true,
|
||||
"supports_spatial_index": true,
|
||||
"supports_transactional_ddl": false
|
||||
},
|
||||
{
|
||||
"kind": "informix",
|
||||
"label": "informix",
|
||||
"capabilities": [
|
||||
"add_column",
|
||||
"drop_column",
|
||||
"rename_column",
|
||||
"alter_existing_column",
|
||||
"create_index",
|
||||
"drop_index",
|
||||
"rebuild_index",
|
||||
"create_table",
|
||||
"drop_table",
|
||||
"truncate_table",
|
||||
"create_trigger",
|
||||
"drop_trigger",
|
||||
"grant_revoke"
|
||||
],
|
||||
"max_identifier_length": 128,
|
||||
"supports_schemas": true,
|
||||
"supports_catalogs": false,
|
||||
"max_columns_per_table": 32767,
|
||||
"supports_json_type": false,
|
||||
"supports_array_type": false,
|
||||
"supports_enum_type": false,
|
||||
"supports_uuid_type": false,
|
||||
"supports_auto_increment": false,
|
||||
"supports_identity_columns": false,
|
||||
"supports_sequences": true,
|
||||
"supports_partitioning": true,
|
||||
"supports_full_text_index": false,
|
||||
"supports_spatial_index": false,
|
||||
"supports_transactional_ddl": false
|
||||
},
|
||||
{
|
||||
"kind": "questdb",
|
||||
"label": "questdb",
|
||||
"capabilities": [
|
||||
"add_column",
|
||||
"drop_column",
|
||||
"rename_column",
|
||||
"alter_existing_column",
|
||||
"create_table",
|
||||
"drop_table",
|
||||
"truncate_table",
|
||||
"if_not_exists"
|
||||
],
|
||||
"max_identifier_length": 256,
|
||||
"supports_schemas": false,
|
||||
"supports_catalogs": false,
|
||||
"max_columns_per_table": 1024,
|
||||
"supports_json_type": false,
|
||||
"supports_array_type": false,
|
||||
"supports_enum_type": false,
|
||||
"supports_uuid_type": false,
|
||||
"supports_auto_increment": false,
|
||||
"supports_identity_columns": false,
|
||||
"supports_sequences": false,
|
||||
"supports_partitioning": true,
|
||||
"supports_full_text_index": false,
|
||||
"supports_spatial_index": false,
|
||||
"supports_transactional_ddl": false
|
||||
}
|
||||
]
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
---
|
||||
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
|
||||
assertion_line: 37
|
||||
expression: desc
|
||||
---
|
||||
{
|
||||
"dialect": "duck_db",
|
||||
"flags": 469991879,
|
||||
"max_identifier_length": 255,
|
||||
"supports_schemas": true,
|
||||
"supports_catalogs": false,
|
||||
"max_columns_per_table": 1600,
|
||||
"max_indexes_per_table": 100,
|
||||
"max_query_size_bytes": 268435456,
|
||||
"supports_full_text_index": false,
|
||||
"supports_spatial_index": true,
|
||||
"supports_partitioning": true,
|
||||
"supports_table_sampling": true,
|
||||
"max_foreign_key_name_length": 255,
|
||||
"supports_on_update_cascade": true,
|
||||
"supports_on_delete_set_null": true,
|
||||
"supports_deferrable_constraints": false,
|
||||
"supports_array_type": true,
|
||||
"supports_json_type": true,
|
||||
"supports_enum_type": true,
|
||||
"supports_uuid_type": true,
|
||||
"supports_identity_columns": false,
|
||||
"supports_auto_increment": false,
|
||||
"supports_sequences": true
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
---
|
||||
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
|
||||
assertion_line: 7
|
||||
expression: desc
|
||||
---
|
||||
{
|
||||
"dialect": "mysql",
|
||||
"flags": 1476391935,
|
||||
"max_identifier_length": 64,
|
||||
"supports_schemas": false,
|
||||
"supports_catalogs": false,
|
||||
"max_columns_per_table": 4096,
|
||||
"max_indexes_per_table": 64,
|
||||
"max_query_size_bytes": 16777216,
|
||||
"supports_full_text_index": true,
|
||||
"supports_spatial_index": true,
|
||||
"supports_partitioning": true,
|
||||
"supports_table_sampling": false,
|
||||
"max_foreign_key_name_length": 64,
|
||||
"supports_on_update_cascade": true,
|
||||
"supports_on_delete_set_null": true,
|
||||
"supports_deferrable_constraints": false,
|
||||
"supports_array_type": false,
|
||||
"supports_json_type": true,
|
||||
"supports_enum_type": true,
|
||||
"supports_uuid_type": false,
|
||||
"supports_identity_columns": false,
|
||||
"supports_auto_increment": true,
|
||||
"supports_sequences": false
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
---
|
||||
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
|
||||
assertion_line: 43
|
||||
expression: info
|
||||
---
|
||||
{
|
||||
"kind": "mysql",
|
||||
"label": "mysql",
|
||||
"capabilities": [
|
||||
"add_column",
|
||||
"drop_column",
|
||||
"rename_column",
|
||||
"alter_existing_column",
|
||||
"reorder_column",
|
||||
"comment",
|
||||
"create_index",
|
||||
"drop_index",
|
||||
"rebuild_index",
|
||||
"index_type",
|
||||
"index_comment",
|
||||
"alter_primary_key",
|
||||
"foreign_key",
|
||||
"create_table",
|
||||
"drop_table",
|
||||
"truncate_table",
|
||||
"create_trigger",
|
||||
"drop_trigger",
|
||||
"create_function",
|
||||
"drop_function",
|
||||
"create_sequence",
|
||||
"drop_sequence",
|
||||
"alter_owner",
|
||||
"grant_revoke",
|
||||
"if_not_exists",
|
||||
"temporary_table",
|
||||
"auto_increment"
|
||||
],
|
||||
"max_identifier_length": 64,
|
||||
"supports_schemas": false,
|
||||
"supports_catalogs": false,
|
||||
"max_columns_per_table": 4096,
|
||||
"supports_json_type": true,
|
||||
"supports_array_type": false,
|
||||
"supports_enum_type": true,
|
||||
"supports_uuid_type": false,
|
||||
"supports_auto_increment": true,
|
||||
"supports_identity_columns": false,
|
||||
"supports_sequences": false,
|
||||
"supports_partitioning": true,
|
||||
"supports_full_text_index": true,
|
||||
"supports_spatial_index": true,
|
||||
"supports_transactional_ddl": false
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
---
|
||||
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
|
||||
assertion_line: 55
|
||||
expression: "&matrix.rules"
|
||||
---
|
||||
[
|
||||
{
|
||||
"source_type": "TINYINT(1)",
|
||||
"target_type": "BOOLEAN",
|
||||
"precision_loss": false,
|
||||
"requires_cast": true
|
||||
},
|
||||
{
|
||||
"source_type": "TINYINT",
|
||||
"target_type": "SMALLINT",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "MEDIUMINT",
|
||||
"target_type": "INTEGER",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "INT",
|
||||
"target_type": "INTEGER",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "BIGINT",
|
||||
"target_type": "BIGINT",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "FLOAT",
|
||||
"target_type": "REAL",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "DOUBLE",
|
||||
"target_type": "DOUBLE PRECISION",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "TINYTEXT",
|
||||
"target_type": "TEXT",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "TEXT",
|
||||
"target_type": "TEXT",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "MEDIUMTEXT",
|
||||
"target_type": "TEXT",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "LONGTEXT",
|
||||
"target_type": "TEXT",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "BLOB",
|
||||
"target_type": "BYTEA",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "TINYBLOB",
|
||||
"target_type": "BYTEA",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "MEDIUMBLOB",
|
||||
"target_type": "BYTEA",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "LONGBLOB",
|
||||
"target_type": "BYTEA",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "DATETIME",
|
||||
"target_type": "TIMESTAMP",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
}
|
||||
]
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
---
|
||||
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
|
||||
assertion_line: 31
|
||||
expression: desc
|
||||
---
|
||||
{
|
||||
"dialect": "oracle",
|
||||
"flags": 402621423,
|
||||
"max_identifier_length": 30,
|
||||
"supports_schemas": true,
|
||||
"supports_catalogs": false,
|
||||
"max_columns_per_table": 1000,
|
||||
"max_indexes_per_table": 100,
|
||||
"max_query_size_bytes": 65536,
|
||||
"supports_full_text_index": true,
|
||||
"supports_spatial_index": true,
|
||||
"supports_partitioning": true,
|
||||
"supports_table_sampling": true,
|
||||
"max_foreign_key_name_length": 30,
|
||||
"supports_on_update_cascade": true,
|
||||
"supports_on_delete_set_null": true,
|
||||
"supports_deferrable_constraints": true,
|
||||
"supports_array_type": false,
|
||||
"supports_json_type": true,
|
||||
"supports_enum_type": false,
|
||||
"supports_uuid_type": false,
|
||||
"supports_identity_columns": true,
|
||||
"supports_auto_increment": false,
|
||||
"supports_sequences": true
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
---
|
||||
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
|
||||
assertion_line: 13
|
||||
expression: desc
|
||||
---
|
||||
{
|
||||
"dialect": "postgres",
|
||||
"flags": 3221225455,
|
||||
"max_identifier_length": 63,
|
||||
"supports_schemas": true,
|
||||
"supports_catalogs": false,
|
||||
"max_columns_per_table": 1600,
|
||||
"max_indexes_per_table": 100,
|
||||
"max_query_size_bytes": 268435456,
|
||||
"supports_full_text_index": true,
|
||||
"supports_spatial_index": true,
|
||||
"supports_partitioning": true,
|
||||
"supports_table_sampling": true,
|
||||
"max_foreign_key_name_length": 63,
|
||||
"supports_on_update_cascade": true,
|
||||
"supports_on_delete_set_null": true,
|
||||
"supports_deferrable_constraints": true,
|
||||
"supports_array_type": true,
|
||||
"supports_json_type": true,
|
||||
"supports_enum_type": true,
|
||||
"supports_uuid_type": true,
|
||||
"supports_identity_columns": true,
|
||||
"supports_auto_increment": false,
|
||||
"supports_sequences": true
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
---
|
||||
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
|
||||
assertion_line: 49
|
||||
expression: info
|
||||
---
|
||||
{
|
||||
"kind": "postgres",
|
||||
"label": "postgres",
|
||||
"capabilities": [
|
||||
"add_column",
|
||||
"drop_column",
|
||||
"rename_column",
|
||||
"alter_existing_column",
|
||||
"comment",
|
||||
"create_index",
|
||||
"drop_index",
|
||||
"rebuild_index",
|
||||
"index_type",
|
||||
"index_include",
|
||||
"index_filter",
|
||||
"index_comment",
|
||||
"alter_primary_key",
|
||||
"foreign_key",
|
||||
"create_table",
|
||||
"drop_table",
|
||||
"truncate_table",
|
||||
"create_trigger",
|
||||
"drop_trigger",
|
||||
"create_function",
|
||||
"drop_function",
|
||||
"create_sequence",
|
||||
"drop_sequence",
|
||||
"alter_owner",
|
||||
"grant_revoke",
|
||||
"if_not_exists",
|
||||
"create_or_replace",
|
||||
"temporary_table",
|
||||
"transactional_ddl",
|
||||
"identity_columns"
|
||||
],
|
||||
"max_identifier_length": 63,
|
||||
"supports_schemas": true,
|
||||
"supports_catalogs": false,
|
||||
"max_columns_per_table": 1600,
|
||||
"supports_json_type": true,
|
||||
"supports_array_type": true,
|
||||
"supports_enum_type": true,
|
||||
"supports_uuid_type": true,
|
||||
"supports_auto_increment": false,
|
||||
"supports_identity_columns": true,
|
||||
"supports_sequences": true,
|
||||
"supports_partitioning": true,
|
||||
"supports_full_text_index": true,
|
||||
"supports_spatial_index": true,
|
||||
"supports_transactional_ddl": true
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
---
|
||||
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
|
||||
assertion_line: 61
|
||||
expression: "&matrix.rules"
|
||||
---
|
||||
[
|
||||
{
|
||||
"source_type": "SMALLINT",
|
||||
"target_type": "SMALLINT",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "INTEGER",
|
||||
"target_type": "INT",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "BIGINT",
|
||||
"target_type": "BIGINT",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "REAL",
|
||||
"target_type": "FLOAT",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "DOUBLE PRECISION",
|
||||
"target_type": "DOUBLE",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "TEXT",
|
||||
"target_type": "LONGTEXT",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "BYTEA",
|
||||
"target_type": "BLOB",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "BOOLEAN",
|
||||
"target_type": "TINYINT(1)",
|
||||
"precision_loss": false,
|
||||
"requires_cast": true
|
||||
},
|
||||
{
|
||||
"source_type": "TIMESTAMP",
|
||||
"target_type": "DATETIME",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
},
|
||||
{
|
||||
"source_type": "TIMESTAMPTZ",
|
||||
"target_type": "DATETIME",
|
||||
"precision_loss": true,
|
||||
"requires_cast": true
|
||||
},
|
||||
{
|
||||
"source_type": "UUID",
|
||||
"target_type": "CHAR(36)",
|
||||
"precision_loss": false,
|
||||
"requires_cast": true
|
||||
},
|
||||
{
|
||||
"source_type": "JSONB",
|
||||
"target_type": "JSON",
|
||||
"precision_loss": false,
|
||||
"requires_cast": false
|
||||
}
|
||||
]
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
---
|
||||
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
|
||||
assertion_line: 19
|
||||
expression: desc
|
||||
---
|
||||
{
|
||||
"dialect": "sqlite",
|
||||
"flags": 1157597639,
|
||||
"max_identifier_length": 255,
|
||||
"supports_schemas": false,
|
||||
"supports_catalogs": false,
|
||||
"max_columns_per_table": 2000,
|
||||
"max_indexes_per_table": 200,
|
||||
"max_query_size_bytes": 0,
|
||||
"supports_full_text_index": true,
|
||||
"supports_spatial_index": false,
|
||||
"supports_partitioning": false,
|
||||
"supports_table_sampling": false,
|
||||
"max_foreign_key_name_length": 255,
|
||||
"supports_on_update_cascade": true,
|
||||
"supports_on_delete_set_null": true,
|
||||
"supports_deferrable_constraints": false,
|
||||
"supports_array_type": false,
|
||||
"supports_json_type": false,
|
||||
"supports_enum_type": false,
|
||||
"supports_uuid_type": false,
|
||||
"supports_identity_columns": false,
|
||||
"supports_auto_increment": true,
|
||||
"supports_sequences": false
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
---
|
||||
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
|
||||
assertion_line: 25
|
||||
expression: desc
|
||||
---
|
||||
{
|
||||
"dialect": "sql_server",
|
||||
"flags": 3019874287,
|
||||
"max_identifier_length": 128,
|
||||
"supports_schemas": true,
|
||||
"supports_catalogs": true,
|
||||
"max_columns_per_table": 1024,
|
||||
"max_indexes_per_table": 999,
|
||||
"max_query_size_bytes": 268435456,
|
||||
"supports_full_text_index": true,
|
||||
"supports_spatial_index": true,
|
||||
"supports_partitioning": true,
|
||||
"supports_table_sampling": true,
|
||||
"max_foreign_key_name_length": 128,
|
||||
"supports_on_update_cascade": true,
|
||||
"supports_on_delete_set_null": true,
|
||||
"supports_deferrable_constraints": false,
|
||||
"supports_array_type": false,
|
||||
"supports_json_type": true,
|
||||
"supports_enum_type": false,
|
||||
"supports_uuid_type": true,
|
||||
"supports_identity_columns": true,
|
||||
"supports_auto_increment": false,
|
||||
"supports_sequences": true
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,616 @@
|
||||
//! Column type rewrite pipeline for cross-database DDL generation.
|
||||
//!
|
||||
//! Order:
|
||||
//! 1. Caller-applied user field mappings (highest priority — not handled here)
|
||||
//! 2. Target [`DdlDialectProfile::type_map`] (data-driven)
|
||||
//! 3. [`TypeMappingMatrix`] between source/target [`DialectKind`] when available
|
||||
//! 4. Generic normalization (strip display width when profile disallows it)
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::sql_dialect::ddl_profile::{profile_for, DdlDialectProfile};
|
||||
use crate::sql_dialect::descriptor::{DialectKind, TypeMappingMatrix};
|
||||
use crate::types::ColumnInfo;
|
||||
|
||||
/// Split `INT(11)` / `DECIMAL(10,2)` / `VARCHAR` into base + params.
|
||||
pub fn split_type_base_params(source_type: &str) -> (String, Option<String>) {
|
||||
let trimmed = source_type.trim();
|
||||
let upper = trimmed.to_ascii_uppercase();
|
||||
match upper.find('(') {
|
||||
Some(i) => {
|
||||
let base = upper[..i].trim().to_string();
|
||||
let end = upper.rfind(')').unwrap_or(upper.len());
|
||||
let params = upper[i + 1..end].trim().to_string();
|
||||
(base, Some(params))
|
||||
}
|
||||
None => (upper, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn first_length_param(params: Option<&str>) -> Option<u32> {
|
||||
params.and_then(|p| p.split(',').next()).and_then(|p| p.trim().parse::<u32>().ok())
|
||||
}
|
||||
|
||||
/// Strip Oracle-specific length-unit qualifiers (`CHAR` / `BYTE`) from a
|
||||
/// parenthesized length slice such as `(50 CHAR)` → `(50)`.
|
||||
///
|
||||
/// Oracle allows character columns to declare their length unit explicitly:
|
||||
/// `VARCHAR2(50 CHAR)` (characters) or `VARCHAR2(50 BYTE)` (bytes). The unit
|
||||
/// qualifier is Oracle-only syntax — MySQL's `VARCHAR(n)` always means
|
||||
/// characters and rejects `VARCHAR(50 CHAR)` with ERROR 1064. Stripping the
|
||||
/// qualifier here keeps the generated target DDL valid while preserving the
|
||||
/// numeric length.
|
||||
///
|
||||
/// Only single numeric parameters followed by `CHAR`/`BYTE` are touched;
|
||||
/// precision/scale lists (`(10,2)`) and enum/set lists (`('a','b')`) are
|
||||
/// returned unchanged.
|
||||
pub fn normalize_len_params(parenthesized: &str) -> String {
|
||||
let Some(open) = parenthesized.find('(') else {
|
||||
return parenthesized.to_string();
|
||||
};
|
||||
let Some(close) = parenthesized.rfind(')') else {
|
||||
return parenthesized.to_string();
|
||||
};
|
||||
if open >= close {
|
||||
return parenthesized.to_string();
|
||||
}
|
||||
// Keep any prefix (base type or whitespace) so a full type string such as
|
||||
// `varchar(50)` round-trips as-is instead of losing its base name.
|
||||
format!("{}({})", &parenthesized[..open], strip_length_unit(&parenthesized[open + 1..close]))
|
||||
}
|
||||
|
||||
/// Strip a trailing `CHAR`/`BYTE` unit from a single length parameter,
|
||||
/// e.g. `50 CHAR` → `50`. Multi-part or non-numeric parameters are unchanged.
|
||||
fn strip_length_unit(params: &str) -> String {
|
||||
let trimmed = params.trim();
|
||||
if trimmed.is_empty() || trimmed.contains(',') {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
let upper = trimmed.to_ascii_uppercase();
|
||||
for unit in ["CHAR", "BYTE"] {
|
||||
if let Some(head) = upper.strip_suffix(unit) {
|
||||
let head = head.trim_end();
|
||||
if !head.is_empty() && head.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return head.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
fn apply_template(template: &str, params: Option<&str>, max_varchar_len: Option<u32>) -> String {
|
||||
if !template.contains("{}") {
|
||||
return template.to_string();
|
||||
}
|
||||
// Prefer full params for DECIMAL(p,s); single length for TEXT({})
|
||||
if template.starts_with("DECIMAL") || template.starts_with("NUMERIC") {
|
||||
return match params {
|
||||
Some(p) if !p.is_empty() => template.replacen("{}", p, 1),
|
||||
_ => template.replace("({})", "").replace("{}", ""),
|
||||
};
|
||||
}
|
||||
let mut len = first_length_param(params).unwrap_or(255);
|
||||
if let Some(max) = max_varchar_len {
|
||||
len = len.clamp(1, max);
|
||||
} else {
|
||||
len = len.max(1);
|
||||
}
|
||||
template.replacen("{}", &len.to_string(), 1)
|
||||
}
|
||||
|
||||
/// Strip MySQL-style display width: `INT(11)` → `INT`, keep `DECIMAL(10,2)` / `VARCHAR(100)`.
|
||||
///
|
||||
/// When keeping length/precision params, original casing is preserved (passthrough-ish).
|
||||
/// When stripping display width, the base is uppercased as a normalized form.
|
||||
pub fn strip_display_width_if_needed(source_type: &str, profile: &DdlDialectProfile) -> String {
|
||||
if profile.supports_display_width {
|
||||
return source_type.trim().to_string();
|
||||
}
|
||||
let trimmed = source_type.trim();
|
||||
let (base, params) = split_type_base_params(trimmed);
|
||||
let Some(params) = params else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
// Keep types where parentheses are meaningful precision/length for most targets.
|
||||
let keep_params = matches!(
|
||||
base.as_str(),
|
||||
"VARCHAR"
|
||||
| "CHARACTER VARYING"
|
||||
| "CHAR"
|
||||
| "CHARACTER"
|
||||
| "NVARCHAR"
|
||||
| "NVARCHAR2"
|
||||
| "VARCHAR2"
|
||||
| "DECIMAL"
|
||||
| "NUMERIC"
|
||||
| "NUMBER"
|
||||
| "FLOAT"
|
||||
| "DOUBLE"
|
||||
| "TIME"
|
||||
| "TIMESTAMP"
|
||||
| "DATETIME"
|
||||
| "ENUM"
|
||||
| "SET"
|
||||
);
|
||||
if keep_params {
|
||||
// Reconstruct from the original slice so casing stays source-faithful.
|
||||
let orig_base = trimmed.split('(').next().unwrap_or(trimmed).trim();
|
||||
let open = trimmed.find('(').unwrap_or(orig_base.len());
|
||||
let close = trimmed.rfind(')').unwrap_or(trimmed.len());
|
||||
let orig_params = if open < close { trimmed[open + 1..close].trim() } else { params.as_str() };
|
||||
format!("{orig_base}({orig_params})")
|
||||
} else {
|
||||
// INT(11), BIGINT(20), TINYINT(2), YEAR(4), … — normalized uppercase base.
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
/// Special-case: TINYINT(1) often means boolean on MySQL-like sources.
|
||||
fn tinyint1_as_boolean_key(base: &str, params: Option<&str>) -> Option<&'static str> {
|
||||
if base.eq_ignore_ascii_case("TINYINT") && params == Some("1") {
|
||||
Some("BOOL")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlserver_decimal_type(params: Option<&str>) -> String {
|
||||
let Some(params) = params else {
|
||||
return "DECIMAL".to_string();
|
||||
};
|
||||
let values = params.split(',').map(str::trim).collect::<Vec<_>>();
|
||||
let Ok(precision) = values.first().copied().unwrap_or_default().parse::<u32>() else {
|
||||
return "DECIMAL".to_string();
|
||||
};
|
||||
let precision = precision.clamp(1, 38);
|
||||
if values.len() == 1 {
|
||||
return format!("DECIMAL({precision})");
|
||||
}
|
||||
let Ok(scale) = values[1].parse::<u32>() else {
|
||||
return format!("DECIMAL({precision})");
|
||||
};
|
||||
format!("DECIMAL({precision},{})", scale.min(precision))
|
||||
}
|
||||
|
||||
fn sqlserver_length_type(target_type: &str, params: Option<&str>, max_length: u32) -> String {
|
||||
let Some(param) = params.map(str::trim).filter(|param| !param.is_empty()) else {
|
||||
return if matches!(target_type, "VARCHAR" | "NVARCHAR" | "VARBINARY") {
|
||||
format!("{target_type}(MAX)")
|
||||
} else {
|
||||
target_type.to_string()
|
||||
};
|
||||
};
|
||||
if param.eq_ignore_ascii_case("MAX") {
|
||||
return match target_type {
|
||||
"NCHAR" => "NVARCHAR(MAX)".to_string(),
|
||||
"CHAR" => "VARCHAR(MAX)".to_string(),
|
||||
"BINARY" => "VARBINARY(MAX)".to_string(),
|
||||
_ => format!("{target_type}(MAX)"),
|
||||
};
|
||||
}
|
||||
match param.parse::<u32>() {
|
||||
Ok(length) if length > max_length => match target_type {
|
||||
"NCHAR" | "NVARCHAR" => "NVARCHAR(MAX)".to_string(),
|
||||
"BINARY" | "VARBINARY" => "VARBINARY(MAX)".to_string(),
|
||||
_ => "VARCHAR(MAX)".to_string(),
|
||||
},
|
||||
Ok(length) => format!("{target_type}({})", length.max(1)),
|
||||
Err(_) => format!("{target_type}(MAX)"),
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlserver_temporal_type(target_type: &str, params: Option<&str>) -> String {
|
||||
match params.and_then(|param| param.trim().parse::<u32>().ok()) {
|
||||
Some(precision) => format!("{target_type}({})", precision.min(7)),
|
||||
None => target_type.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert common source types to native SQL Server types before the generic
|
||||
/// mapping matrix runs. This is source-dialect aware because names such as
|
||||
/// `timestamp` mean a date/time in MySQL/PostgreSQL but `rowversion` in SQL Server.
|
||||
fn rewrite_cross_dialect_to_sqlserver(source_type: &str, source: DialectKind) -> Option<String> {
|
||||
if source == DialectKind::SqlServer {
|
||||
return None;
|
||||
}
|
||||
|
||||
let source_upper = source_type.trim().to_ascii_uppercase();
|
||||
// MySQL places UNSIGNED/ZEROFILL after an optional parameter list, so it
|
||||
// cannot be recovered from `raw_base` alone (`INT(11) UNSIGNED`). ZEROFILL
|
||||
// also implies UNSIGNED in MySQL.
|
||||
let mysql_unsigned = source_upper.split_whitespace().any(|part| matches!(part, "UNSIGNED" | "ZEROFILL"));
|
||||
let (raw_base, params) = split_type_base_params(source_type);
|
||||
let base = raw_base
|
||||
.split_whitespace()
|
||||
.filter(|part| !part.eq_ignore_ascii_case("UNSIGNED") && !part.eq_ignore_ascii_case("ZEROFILL"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let params = params.as_deref();
|
||||
|
||||
let mapped = match source {
|
||||
DialectKind::Mysql => match base.as_str() {
|
||||
"BOOL" | "BOOLEAN" => "BIT".to_string(),
|
||||
"TINYINT" if params == Some("1") && !mysql_unsigned => "BIT".to_string(),
|
||||
// Preserve the complete MySQL value range. SQL Server TINYINT is
|
||||
// unsigned, while MySQL TINYINT is signed unless explicitly marked.
|
||||
"TINYINT" if mysql_unsigned => "TINYINT".to_string(),
|
||||
"TINYINT" => "SMALLINT".to_string(),
|
||||
"SMALLINT" if mysql_unsigned => "INT".to_string(),
|
||||
"SMALLINT" => "SMALLINT".to_string(),
|
||||
"MEDIUMINT" => "INT".to_string(),
|
||||
"INT" | "INTEGER" if mysql_unsigned => "BIGINT".to_string(),
|
||||
"INT" | "INTEGER" => "INT".to_string(),
|
||||
"BIGINT" if mysql_unsigned => "DECIMAL(20,0)".to_string(),
|
||||
"BIGINT" => "BIGINT".to_string(),
|
||||
"DECIMAL" | "NUMERIC" => sqlserver_decimal_type(params),
|
||||
"FLOAT" | "DOUBLE" | "DOUBLE PRECISION" | "REAL" => "FLOAT".to_string(),
|
||||
"CHAR" | "VARCHAR" => sqlserver_length_type(&base, params, 8000),
|
||||
"NCHAR" | "NVARCHAR" => sqlserver_length_type(&base, params, 4000),
|
||||
"TINYTEXT" | "TEXT" | "MEDIUMTEXT" | "LONGTEXT" | "JSON" | "ENUM" | "SET" => "NVARCHAR(MAX)".to_string(),
|
||||
"BINARY" | "VARBINARY" => sqlserver_length_type(&base, params, 8000),
|
||||
"TINYBLOB" | "BLOB" | "MEDIUMBLOB" | "LONGBLOB" => "VARBINARY(MAX)".to_string(),
|
||||
"DATE" => "DATE".to_string(),
|
||||
"TIME" => sqlserver_temporal_type("TIME", params),
|
||||
"DATETIME" | "TIMESTAMP" => sqlserver_temporal_type("DATETIME2", params),
|
||||
"YEAR" => "SMALLINT".to_string(),
|
||||
"GEOMETRY" => "GEOMETRY".to_string(),
|
||||
_ => return None,
|
||||
},
|
||||
DialectKind::Postgres => match base.as_str() {
|
||||
"BOOL" | "BOOLEAN" => "BIT".to_string(),
|
||||
"SMALLINT" | "INT2" | "SMALLSERIAL" => "SMALLINT".to_string(),
|
||||
"INTEGER" | "INT" | "INT4" | "SERIAL" => "INT".to_string(),
|
||||
"BIGINT" | "INT8" | "BIGSERIAL" => "BIGINT".to_string(),
|
||||
"NUMERIC" | "DECIMAL" => sqlserver_decimal_type(params),
|
||||
"REAL" | "FLOAT4" => "REAL".to_string(),
|
||||
"DOUBLE PRECISION" | "FLOAT8" => "FLOAT".to_string(),
|
||||
"CHAR" | "CHARACTER" | "VARCHAR" | "CHARACTER VARYING" => {
|
||||
sqlserver_length_type("NVARCHAR", params.or(Some("MAX")), 4000)
|
||||
}
|
||||
"TEXT" | "JSON" | "JSONB" | "ARRAY" => "NVARCHAR(MAX)".to_string(),
|
||||
"BIT VARYING" | "VARBIT" => "VARBINARY(MAX)".to_string(),
|
||||
"BYTEA" => "VARBINARY(MAX)".to_string(),
|
||||
"UUID" => "UNIQUEIDENTIFIER".to_string(),
|
||||
"DATE" => "DATE".to_string(),
|
||||
"TIME" if source_upper.contains("WITH TIME ZONE") && !source_upper.contains("WITHOUT TIME ZONE") => {
|
||||
sqlserver_temporal_type("DATETIMEOFFSET", params)
|
||||
}
|
||||
"TIME" | "TIME WITHOUT TIME ZONE" => sqlserver_temporal_type("TIME", params),
|
||||
"TIMETZ" | "TIME WITH TIME ZONE" => sqlserver_temporal_type("DATETIMEOFFSET", params),
|
||||
"TIMESTAMP" if source_upper.contains("WITH TIME ZONE") && !source_upper.contains("WITHOUT TIME ZONE") => {
|
||||
sqlserver_temporal_type("DATETIMEOFFSET", params)
|
||||
}
|
||||
"TIMESTAMP" | "TIMESTAMP WITHOUT TIME ZONE" => sqlserver_temporal_type("DATETIME2", params),
|
||||
"TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" => sqlserver_temporal_type("DATETIMEOFFSET", params),
|
||||
"XML" => "XML".to_string(),
|
||||
"MONEY" => "MONEY".to_string(),
|
||||
"INET" | "CIDR" | "MACADDR" | "MACADDR8" | "INTERVAL" => "NVARCHAR(100)".to_string(),
|
||||
_ if base.ends_with("[]") => "NVARCHAR(MAX)".to_string(),
|
||||
_ => return None,
|
||||
},
|
||||
DialectKind::Sqlite | DialectKind::DuckDb => match base.as_str() {
|
||||
"BOOL" | "BOOLEAN" => "BIT".to_string(),
|
||||
"TINYINT" => "TINYINT".to_string(),
|
||||
"SMALLINT" => "SMALLINT".to_string(),
|
||||
"INT" | "INTEGER" => "BIGINT".to_string(),
|
||||
"REAL" | "DOUBLE" | "DOUBLE PRECISION" | "FLOAT" => "FLOAT".to_string(),
|
||||
"DECIMAL" | "NUMERIC" => sqlserver_decimal_type(params),
|
||||
"CHAR" | "VARCHAR" => sqlserver_length_type("NVARCHAR", params.or(Some("MAX")), 4000),
|
||||
"TEXT" | "JSON" => "NVARCHAR(MAX)".to_string(),
|
||||
"BLOB" | "BYTEA" => "VARBINARY(MAX)".to_string(),
|
||||
"DATE" => "DATE".to_string(),
|
||||
"TIME" => sqlserver_temporal_type("TIME", params),
|
||||
"DATETIME" | "TIMESTAMP" => sqlserver_temporal_type("DATETIME2", params),
|
||||
"UUID" => "UNIQUEIDENTIFIER".to_string(),
|
||||
_ => return None,
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
Some(mapped)
|
||||
}
|
||||
|
||||
/// Rewrite a source column type for `target` database.
|
||||
///
|
||||
/// User field mappings must be applied by the caller *before* this function.
|
||||
pub fn rewrite_column_type(source_type: &str, target: DatabaseType, source_dialect: Option<DialectKind>) -> String {
|
||||
let profile = profile_for(target);
|
||||
if target == DatabaseType::SqlServer {
|
||||
let source = source_dialect.unwrap_or(DialectKind::SqlServer);
|
||||
if source == DialectKind::SqlServer {
|
||||
// Same-dialect metadata already contains a valid native type. The
|
||||
// generic display-width normalizer would otherwise truncate valid
|
||||
// SQL Server parameters such as VARBINARY(MAX), DATETIME2(7), and
|
||||
// DATETIMEOFFSET(7).
|
||||
return source_type.trim().to_string();
|
||||
}
|
||||
if let Some(mapped) = rewrite_cross_dialect_to_sqlserver(source_type, source) {
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
let (base, params) = split_type_base_params(source_type);
|
||||
|
||||
// Profile type_map (includes synthetic BOOL for TINYINT(1) when mapped)
|
||||
let lookup_key = tinyint1_as_boolean_key(&base, params.as_deref()).unwrap_or(base.as_str());
|
||||
if let Some(template) = profile.lookup_type(lookup_key) {
|
||||
return apply_template(template, params.as_deref(), profile.max_varchar_len);
|
||||
}
|
||||
// Also try original base if we used BOOL alias miss
|
||||
if lookup_key != base && profile.lookup_type(&base).is_some() {
|
||||
if let Some(template) = profile.lookup_type(&base) {
|
||||
return apply_template(template, params.as_deref(), profile.max_varchar_len);
|
||||
}
|
||||
}
|
||||
|
||||
// Dialect-kind matrix
|
||||
if let Some(src) = source_dialect {
|
||||
let target_kind = DialectKind::from_database_type(target);
|
||||
if src != target_kind {
|
||||
let matrix = TypeMappingMatrix::for_dialects(src, target_kind);
|
||||
let (converted, _) = matrix.convert_type(source_type);
|
||||
if converted != source_type {
|
||||
return strip_display_width_if_needed(&converted, &profile);
|
||||
}
|
||||
// No matrix rule for this cross-dialect pair, and it's a
|
||||
// *varying*-length string type reported without a length — e.g.
|
||||
// Postgres/Kingbase's unbounded `character varying` via
|
||||
// format_type(). A bare passthrough would be invalid DDL for a
|
||||
// target that requires an explicit VARCHAR length (MySQL and its
|
||||
// family: VARCHAR/CHARACTER VARYING with no length is a syntax
|
||||
// error there). Default to 255, matching the same fallback
|
||||
// apply_template() already uses when a mapped type's length is
|
||||
// missing. CHAR/CHARACTER are deliberately excluded: MySQL
|
||||
// accepts a bare CHAR as CHAR(1), so defaulting it to 255 would
|
||||
// silently change valid output into a different, wrong length.
|
||||
// VARCHAR2/NVARCHAR2 are also excluded: they aren't valid MySQL
|
||||
// type names at all, with or without a length, so defaulting
|
||||
// their length fixes nothing.
|
||||
if params.is_none()
|
||||
&& profile.requires_explicit_varchar_length
|
||||
&& profile.max_varchar_len.is_some()
|
||||
&& matches!(base.as_str(), "VARCHAR" | "CHARACTER VARYING" | "NVARCHAR")
|
||||
{
|
||||
return format!("{}(255)", source_type.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
strip_display_width_if_needed(source_type, &profile)
|
||||
}
|
||||
|
||||
/// Whether the column metadata indicates auto-increment / identity / serial.
|
||||
pub fn column_is_auto_increment(col: &ColumnInfo) -> bool {
|
||||
col.extra.as_deref().is_some_and(|extra| {
|
||||
let lower = extra.to_ascii_lowercase();
|
||||
lower.contains("auto_increment")
|
||||
|| lower.contains("autoincrement")
|
||||
|| lower.contains("identity")
|
||||
|| lower.contains("serial")
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a rewritten type looks integer-like (for PK auto-inc heuristics).
|
||||
pub fn type_looks_integer(mapped_type: &str) -> bool {
|
||||
let lower = mapped_type.to_ascii_lowercase();
|
||||
lower.contains("int")
|
||||
|| lower.contains("integer")
|
||||
|| lower.contains("serial")
|
||||
|| lower.eq_ignore_ascii_case("counter")
|
||||
|| lower.eq_ignore_ascii_case("byte")
|
||||
|| lower.starts_with("decimal")
|
||||
|| lower.starts_with("number")
|
||||
|| lower.starts_with("numeric")
|
||||
}
|
||||
|
||||
/// Build column type + optional auto-inc contribution using profile only (no DatabaseType branches).
|
||||
pub fn apply_auto_inc_to_column_def(
|
||||
profile: &DdlDialectProfile,
|
||||
quoted_name: &str,
|
||||
_mapped_type: &str,
|
||||
col: &ColumnInfo,
|
||||
is_integer_like: bool,
|
||||
) -> AutoIncColumnBuild {
|
||||
let wants_auto = col.is_primary_key
|
||||
&& (column_is_auto_increment(col)
|
||||
|| (is_integer_like
|
||||
&& !matches!(
|
||||
profile.auto_inc,
|
||||
crate::sql_dialect::ddl_profile::AutoIncSyntax::Suffix(" AUTOINCREMENT")
|
||||
)));
|
||||
|
||||
match profile.auto_inc {
|
||||
crate::sql_dialect::ddl_profile::AutoIncSyntax::ReplaceTypeWith(type_name)
|
||||
if wants_auto && col.is_primary_key =>
|
||||
{
|
||||
let mut def = format!("{quoted_name} {type_name}");
|
||||
if !col.is_nullable {
|
||||
def.push_str(" NOT NULL");
|
||||
}
|
||||
AutoIncColumnBuild::Complete { def, skip_default: true }
|
||||
}
|
||||
// SQLite's AUTOINCREMENT changes semantics (sqlite_sequence, no rowid
|
||||
// reuse), so it must only be emitted for sources that explicitly ask
|
||||
// for auto-increment; other suffixes keep the implicit PK heuristic.
|
||||
crate::sql_dialect::ddl_profile::AutoIncSyntax::Suffix(suffix)
|
||||
if col.is_primary_key && is_integer_like && (wants_auto || suffix != " AUTOINCREMENT") =>
|
||||
{
|
||||
AutoIncColumnBuild::AppendSuffix { suffix, skip_default: false, postgres_sequence: false }
|
||||
}
|
||||
crate::sql_dialect::ddl_profile::AutoIncSyntax::PostgresSequence if col.is_primary_key && is_integer_like => {
|
||||
AutoIncColumnBuild::AppendSuffix { suffix: "", skip_default: false, postgres_sequence: true }
|
||||
}
|
||||
_ => AutoIncColumnBuild::Normal { skip_default: false },
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AutoIncColumnBuild {
|
||||
/// Full column definition already built (e.g. COUNTER).
|
||||
Complete {
|
||||
def: String,
|
||||
skip_default: bool,
|
||||
},
|
||||
/// Continue normal type def; optionally append suffix; maybe register PG sequence.
|
||||
AppendSuffix {
|
||||
suffix: &'static str,
|
||||
skip_default: bool,
|
||||
postgres_sequence: bool,
|
||||
},
|
||||
Normal {
|
||||
skip_default: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn access_rewrites_mysql_types() {
|
||||
let t = DatabaseType::Access;
|
||||
let src = Some(DialectKind::Mysql);
|
||||
assert_eq!(rewrite_column_type("int(11)", t, src), "INTEGER");
|
||||
assert_eq!(rewrite_column_type("varchar(120)", t, src), "TEXT(120)");
|
||||
assert_eq!(rewrite_column_type("tinyint(2)", t, src), "BYTE");
|
||||
assert_eq!(rewrite_column_type("tinyint(1)", t, src), "YESNO");
|
||||
assert_eq!(rewrite_column_type("datetime", t, src), "DATETIME");
|
||||
assert_eq!(rewrite_column_type("longtext", t, src), "LONGTEXT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_keeps_identity_path_not_counter() {
|
||||
let profile = profile_for(DatabaseType::SqlServer);
|
||||
assert!(matches!(
|
||||
profile.auto_inc,
|
||||
crate::sql_dialect::ddl_profile::AutoIncSyntax::Suffix(s) if s.contains("IDENTITY")
|
||||
));
|
||||
// MySQL display width is not part of the SQL Server type.
|
||||
let t = rewrite_column_type("int(11)", DatabaseType::SqlServer, Some(DialectKind::Mysql));
|
||||
assert_eq!(t, "INT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_rewrites_common_mysql_types() {
|
||||
let target = DatabaseType::SqlServer;
|
||||
let source = Some(DialectKind::Mysql);
|
||||
let cases = [
|
||||
("tinyint(1)", "BIT"),
|
||||
("tinyint", "SMALLINT"),
|
||||
("tinyint unsigned", "TINYINT"),
|
||||
("tinyint(1) unsigned", "TINYINT"),
|
||||
("tinyint(1) zerofill", "TINYINT"),
|
||||
("smallint unsigned", "INT"),
|
||||
("mediumint unsigned", "INT"),
|
||||
("int(11) unsigned", "BIGINT"),
|
||||
("integer zerofill", "BIGINT"),
|
||||
("bigint unsigned", "DECIMAL(20,0)"),
|
||||
("double", "FLOAT"),
|
||||
("decimal(65,30)", "DECIMAL(38,30)"),
|
||||
("varchar(9000)", "VARCHAR(MAX)"),
|
||||
("nvarchar(5000)", "NVARCHAR(MAX)"),
|
||||
("longtext", "NVARCHAR(MAX)"),
|
||||
("json", "NVARCHAR(MAX)"),
|
||||
("longblob", "VARBINARY(MAX)"),
|
||||
("datetime(9)", "DATETIME2(7)"),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
assert_eq!(rewrite_column_type(input, target, source), expected, "{input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_rewrites_common_postgres_types_without_rewriting_its_own_timestamp() {
|
||||
let target = DatabaseType::SqlServer;
|
||||
let postgres = Some(DialectKind::Postgres);
|
||||
let cases = [
|
||||
("serial", "INT"),
|
||||
("boolean", "BIT"),
|
||||
("character varying(64)", "NVARCHAR(64)"),
|
||||
("text", "NVARCHAR(MAX)"),
|
||||
("bytea", "VARBINARY(MAX)"),
|
||||
("uuid", "UNIQUEIDENTIFIER"),
|
||||
("time(4) with time zone", "DATETIMEOFFSET(4)"),
|
||||
("timestamp(6) without time zone", "DATETIME2(6)"),
|
||||
("timestamp(6) with time zone", "DATETIMEOFFSET(6)"),
|
||||
("jsonb", "NVARCHAR(MAX)"),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
assert_eq!(rewrite_column_type(input, target, postgres), expected, "{input}");
|
||||
}
|
||||
assert_eq!(rewrite_column_type("timestamp", target, Some(DialectKind::SqlServer)), "timestamp");
|
||||
assert_eq!(rewrite_column_type("varbinary(max)", target, None), "varbinary(max)");
|
||||
assert_eq!(rewrite_column_type("datetime2(7)", target, Some(DialectKind::SqlServer)), "datetime2(7)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_keeps_display_width() {
|
||||
let t = rewrite_column_type("int(11)", DatabaseType::Mysql, None);
|
||||
assert_eq!(t, "int(11)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_defaults_missing_length_on_cross_dialect_varchar() {
|
||||
// Postgres/Kingbase report an unbounded varchar column as a bare
|
||||
// `character varying` (no length) via format_type(). MySQL's
|
||||
// VARCHAR/CHARACTER VARYING requires an explicit length, so passing
|
||||
// that through verbatim is a syntax error (issue #8011).
|
||||
let source = Some(DialectKind::Postgres);
|
||||
assert_eq!(rewrite_column_type("character varying", DatabaseType::Mysql, source), "character varying(255)");
|
||||
assert_eq!(rewrite_column_type("varchar", DatabaseType::Mysql, source), "varchar(255)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_leaves_bare_char_untouched() {
|
||||
// Unlike VARCHAR, a bare CHAR/CHARACTER is already valid MySQL
|
||||
// syntax (implicitly CHAR(1)); defaulting it to 255 would silently
|
||||
// change correct output into a different, wrong length.
|
||||
let source = Some(DialectKind::Postgres);
|
||||
assert_eq!(rewrite_column_type("character", DatabaseType::Mysql, source), "character");
|
||||
assert_eq!(rewrite_column_type("char", DatabaseType::Mysql, source), "char");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_keeps_explicit_length_on_cross_dialect_varchar() {
|
||||
let source = Some(DialectKind::Postgres);
|
||||
assert_eq!(
|
||||
rewrite_column_type("character varying(120)", DatabaseType::Mysql, source),
|
||||
"character varying(120)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_same_dialect_varchar_is_untouched_regardless_of_length() {
|
||||
// Same-dialect metadata (or unknown source dialect) must never be
|
||||
// rewritten — only a genuine cross-dialect gap should trigger the
|
||||
// missing-length default.
|
||||
assert_eq!(rewrite_column_type("varchar(255)", DatabaseType::Mysql, Some(DialectKind::Mysql)), "varchar(255)");
|
||||
assert_eq!(rewrite_column_type("varchar(255)", DatabaseType::Mysql, None), "varchar(255)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_len_params_strips_oracle_char_unit() {
|
||||
assert_eq!(normalize_len_params("(6 CHAR)"), "(6)");
|
||||
assert_eq!(normalize_len_params("(50 char)"), "(50)");
|
||||
assert_eq!(normalize_len_params("(50 CHAR)"), "(50)");
|
||||
assert_eq!(normalize_len_params("(20 BYTE)"), "(20)");
|
||||
assert_eq!(normalize_len_params("(20 byte)"), "(20)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_len_params_leaves_non_unit_params_untouched() {
|
||||
// Plain length without a unit qualifier.
|
||||
assert_eq!(normalize_len_params("(50)"), "(50)");
|
||||
// Precision/scale must never be disturbed.
|
||||
assert_eq!(normalize_len_params("(10,2)"), "(10,2)");
|
||||
// Enum/set lists must never be disturbed.
|
||||
assert_eq!(normalize_len_params("('a','b')"), "('a','b')");
|
||||
// Non-numeric head (defensive) is left alone.
|
||||
assert_eq!(normalize_len_params("(MAX CHAR)"), "(MAX CHAR)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_len_params_handles_malformed_input() {
|
||||
assert_eq!(normalize_len_params("varchar(50)"), "varchar(50)");
|
||||
assert_eq!(normalize_len_params("no-parens"), "no-parens");
|
||||
assert_eq!(normalize_len_params("(unclosed"), "(unclosed");
|
||||
assert_eq!(normalize_len_params(")"), ")");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
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> {
|
||||
pub database_type: Option<DatabaseType>,
|
||||
pub schema: Option<&'a str>,
|
||||
pub table_name: &'a str,
|
||||
pub columns: &'a [String],
|
||||
pub order_columns: &'a [String],
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, 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 driver_profile: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub identifier_quote: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub table_name: String,
|
||||
/// Doris / StarRocks multi-catalog: when set to a non-`internal` catalog,
|
||||
/// the FROM clause is prefixed with the catalog
|
||||
/// (`<catalog>.<database>.<table>`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub catalog: Option<String>,
|
||||
/// Doris / StarRocks multi-catalog: the database under the external
|
||||
/// catalog, used as the middle segment of the 3-part qualified name when
|
||||
/// `schema` is absent (Doris/StarRocks have no separate schema concept).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub table_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub primary_keys: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub columns: Vec<String>,
|
||||
/// Database type names parallel to `columns`. Table-data callers can use
|
||||
/// these to request bounded server-side projections for variable-length
|
||||
/// values without changing arbitrary query execution.
|
||||
#[serde(default)]
|
||||
pub column_types: Vec<String>,
|
||||
/// Maximum characters (text-like values) or bytes (binary values) kept in
|
||||
/// a table-data preview. Omitted for exports and ordinary SQL queries.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub large_value_preview_size: Option<usize>,
|
||||
#[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>,
|
||||
/// The JDBC driver advances the ResultSet before collecting the page.
|
||||
/// This keeps database-specific identifier rules while avoiding SQL
|
||||
/// pagination syntax that the selected driver does not support.
|
||||
#[serde(default)]
|
||||
pub use_driver_row_offset: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub where_input: Option<String>,
|
||||
/// Time-series quick-open ergonomics: inject a rolling `time` window
|
||||
/// when no WHERE was supplied. Opt-in so sampling and export callers
|
||||
/// keep the historical unbounded scan semantics.
|
||||
#[serde(default)]
|
||||
pub inject_default_time_series_where: bool,
|
||||
#[serde(default)]
|
||||
pub include_row_id: bool,
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,255 @@
|
||||
//! Driver-reported SQL error positions (currently PostgreSQL only).
|
||||
//!
|
||||
//! PostgreSQL reports a 1-based *character* cursor position for many SQL
|
||||
//! errors (`tokio_postgres::error::ErrorPosition::Original`). The native
|
||||
//! driver error path in `db::postgres` only exposes a `String` to the query
|
||||
//! layer, so the raw cursor is carried across that `String` ABI as a small
|
||||
//! machine-readable suffix and resolved back into a typed [`SqlErrorPosition`]
|
||||
//! as soon as the executed statement text is available.
|
||||
//!
|
||||
//! The suffix is a position carrier, not an error classifier: nothing about the
|
||||
//! error category, retryability or recovery is inferred from it, and
|
||||
//! [`strip_marker`] guarantees it can never surface in a user-facing message.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Suffix appended to a driver error message to carry a raw PostgreSQL cursor
|
||||
/// position across the `Result<_, String>` boundary of the `db` layer.
|
||||
pub const SQL_ERROR_POSITION_MARKER: &str = "\nDBX_SQL_ERROR_POSITION:";
|
||||
|
||||
/// A SQL error position relative to the statement text that was actually sent
|
||||
/// to the database.
|
||||
///
|
||||
/// `line`/`column` are 1-based and counted in Unicode scalar values (matching
|
||||
/// PostgreSQL's character-based cursor positions); `offset` is the 0-based
|
||||
/// scalar-value index into the statement text.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SqlErrorPosition {
|
||||
pub line: u32,
|
||||
pub column: u32,
|
||||
pub offset: u32,
|
||||
}
|
||||
|
||||
impl SqlErrorPosition {
|
||||
/// Convert a PostgreSQL 1-based character cursor into a line/column/offset.
|
||||
///
|
||||
/// Returns `None` when the cursor is unusable (zero, non-positive, or the
|
||||
/// statement is empty). A cursor past the end (`position == len + 1`, e.g.
|
||||
/// "unexpected end of input") is clamped to the last character.
|
||||
pub fn from_pg_cursor(statement_sql: &str, cursor: u32) -> Option<Self> {
|
||||
if cursor == 0 {
|
||||
return None;
|
||||
}
|
||||
let total = statement_sql.chars().count() as u32;
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
let target = (cursor - 1).min(total - 1);
|
||||
let mut line = 1u32;
|
||||
let mut column = 1u32;
|
||||
for (index, ch) in statement_sql.chars().enumerate() {
|
||||
if index as u32 == target {
|
||||
return Some(Self { line, column, offset: target });
|
||||
}
|
||||
if ch == '\n' {
|
||||
line += 1;
|
||||
column = 1;
|
||||
} else {
|
||||
column += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a raw cursor as the transport suffix.
|
||||
pub fn encode_marker(cursor: u32) -> String {
|
||||
format!("{SQL_ERROR_POSITION_MARKER}{cursor}")
|
||||
}
|
||||
|
||||
/// Remove every marker from `message` and return the cursors they carried.
|
||||
///
|
||||
/// Markers are removed even when followed by more text (the read-only
|
||||
/// transaction path can append a cleanup error after one, e.g.
|
||||
/// `...<marker>15; rollback failed`), so the trailing text is preserved. A
|
||||
/// malformed or absent suffix leaves `message` untouched and is not collected.
|
||||
fn take_all_markers(message: &mut String) -> Vec<u32> {
|
||||
let mut cursors = Vec::new();
|
||||
while let Some(cursor) = take_marker(message) {
|
||||
cursors.push(cursor);
|
||||
}
|
||||
cursors
|
||||
}
|
||||
|
||||
/// Remove a marker from `message` and return the raw cursor it carried.
|
||||
///
|
||||
/// See [`take_all_markers`] for the multi-marker behaviour.
|
||||
pub fn take_marker(message: &mut String) -> Option<u32> {
|
||||
let index = message.rfind(SQL_ERROR_POSITION_MARKER)?;
|
||||
let digits_start = index + SQL_ERROR_POSITION_MARKER.len();
|
||||
let digits_end = message[digits_start..]
|
||||
.find(|ch: char| !ch.is_ascii_digit())
|
||||
.map_or(message.len(), |offset| digits_start + offset);
|
||||
let cursor = message[digits_start..digits_end].parse::<u32>().ok()?;
|
||||
message.replace_range(index..digits_end, "");
|
||||
Some(cursor)
|
||||
}
|
||||
|
||||
/// Return `message` with every transport suffix stripped.
|
||||
pub fn strip_marker(message: &str) -> String {
|
||||
let mut owned = message.to_string();
|
||||
let _ = take_all_markers(&mut owned);
|
||||
owned
|
||||
}
|
||||
|
||||
/// Strip all transport suffixes from `message` and, when possible, resolve the
|
||||
/// carried cursor against the executed statement text into a typed position.
|
||||
///
|
||||
/// The returned message is always marker-free. The position is only resolved
|
||||
/// when exactly one marker was present: a message carrying several markers (e.g.
|
||||
/// a user-statement error merged with an infrastructure error) cannot be
|
||||
/// attributed to a single statement, and guessing would point at the wrong spot.
|
||||
pub fn take_message_position(message: &str, statement_sql: &str) -> (String, Option<SqlErrorPosition>) {
|
||||
let mut cleaned = message.to_string();
|
||||
let cursors = take_all_markers(&mut cleaned);
|
||||
if cursors.len() != 1 {
|
||||
return (cleaned, None);
|
||||
}
|
||||
let position = SqlErrorPosition::from_pg_cursor(statement_sql, cursors[0]);
|
||||
(cleaned, position)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cursor_maps_to_line_and_column() {
|
||||
let sql = "SELECT *\nFROM no_such_table";
|
||||
// 1-based cursor of the 'n' in no_such_table (second line, column 6).
|
||||
let cursor = sql.find("no_such_table").unwrap() as u32 + 1;
|
||||
let position = SqlErrorPosition::from_pg_cursor(sql, cursor).unwrap();
|
||||
assert_eq!(position.line, 2);
|
||||
assert_eq!(position.column, 6);
|
||||
assert_eq!(position.offset, sql[..sql.find("no_such_table").unwrap()].chars().count() as u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_character_is_line_one_column_one() {
|
||||
let position = SqlErrorPosition::from_pg_cursor("SELECT 1", 1).unwrap();
|
||||
assert_eq!((position.line, position.column, position.offset), (1, 1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crlf_treats_carriage_return_as_a_column() {
|
||||
let sql = "SELECT 1\r\nFROM t";
|
||||
let cursor = sql.find("FROM").unwrap() as u32 + 1;
|
||||
let position = SqlErrorPosition::from_pg_cursor(sql, cursor).unwrap();
|
||||
assert_eq!(position.line, 2);
|
||||
assert_eq!(position.column, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_byte_characters_count_as_one_column() {
|
||||
// '表' is 3 UTF-8 bytes; cursor positions are character based.
|
||||
let sql = "SELECT '表' FROM t";
|
||||
let from_byte = sql.find("FROM").unwrap();
|
||||
let cursor = sql[..from_byte].chars().count() as u32 + 1;
|
||||
let position = SqlErrorPosition::from_pg_cursor(sql, cursor).unwrap();
|
||||
assert_eq!(position.line, 1);
|
||||
assert_eq!(position.column, 12);
|
||||
assert_eq!(position.offset, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_past_end_is_clamped() {
|
||||
let sql = "SELECT";
|
||||
let position = SqlErrorPosition::from_pg_cursor(sql, 999).unwrap();
|
||||
assert_eq!(position.offset, 5);
|
||||
assert_eq!(position.column, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_cursor_and_empty_sql_are_rejected() {
|
||||
assert!(SqlErrorPosition::from_pg_cursor("SELECT 1", 0).is_none());
|
||||
assert!(SqlErrorPosition::from_pg_cursor("", 1).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_round_trips_and_strips() {
|
||||
let mut message = format!("ERROR: relation does not exist{}", encode_marker(15));
|
||||
assert_eq!(take_marker(&mut message), Some(15));
|
||||
assert_eq!(message, "ERROR: relation does not exist");
|
||||
|
||||
let mut without_marker = "ERROR: nope".to_string();
|
||||
assert_eq!(take_marker(&mut without_marker), None);
|
||||
assert_eq!(without_marker, "ERROR: nope");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_marker_is_left_untouched() {
|
||||
let text = format!("{SQL_ERROR_POSITION_MARKER}not-a-number");
|
||||
let mut message = text.clone();
|
||||
assert_eq!(take_marker(&mut message), None);
|
||||
assert_eq!(message, text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_followed_by_text_is_stripped_and_preserves_the_tail() {
|
||||
let mut message = format!("ERROR: boom{}; rollback failed", encode_marker(7));
|
||||
assert_eq!(take_marker(&mut message), Some(7));
|
||||
assert_eq!(message, "ERROR: boom; rollback failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_message_position_strips_marker_and_resolves_position() {
|
||||
let sql = "SELECT 1\nFROM missing";
|
||||
let cursor = sql.find("missing").unwrap() as u32 + 1;
|
||||
let raw = format!("ERROR: relation \"missing\" does not exist{}", encode_marker(cursor));
|
||||
let (message, position) = take_message_position(&raw, sql);
|
||||
assert_eq!(message, "ERROR: relation \"missing\" does not exist");
|
||||
let position = position.unwrap();
|
||||
assert_eq!(position.line, 2);
|
||||
assert_eq!(position.column, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_message_position_still_strips_when_the_cursor_is_unusable() {
|
||||
// cursor 0 cannot be resolved, but the marker must not leak.
|
||||
let raw = format!("ERROR: boom{}", encode_marker(0));
|
||||
let (message, position) = take_message_position(&raw, "SELECT 1");
|
||||
assert_eq!(message, "ERROR: boom");
|
||||
assert!(position.is_none());
|
||||
|
||||
// Empty statement: nothing to map onto.
|
||||
let raw = format!("ERROR: boom{}", encode_marker(5));
|
||||
let (message, position) = take_message_position(&raw, "");
|
||||
assert_eq!(message, "ERROR: boom");
|
||||
assert!(position.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_message_position_without_marker_is_identity() {
|
||||
let (message, position) = take_message_position("ERROR: x", "SELECT 1");
|
||||
assert_eq!(message, "ERROR: x");
|
||||
assert!(position.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_markers_are_all_stripped_and_never_resolved() {
|
||||
// A user-statement error merged with an infrastructure error carries two
|
||||
// cursors; the position cannot be attributed to a single statement.
|
||||
let raw =
|
||||
format!("ERROR: relation missing does not exist{}; cleanup failed{}", encode_marker(15), encode_marker(3));
|
||||
let (message, position) = take_message_position(&raw, "SELECT * FROM missing");
|
||||
assert_eq!(message, "ERROR: relation missing does not exist; cleanup failed");
|
||||
assert!(position.is_none());
|
||||
assert_eq!(strip_marker(&raw), "ERROR: relation missing does not exist; cleanup failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_marker_without_marker_is_identity() {
|
||||
assert_eq!(strip_marker("ERROR: x"), "ERROR: x");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlparser::ast::Statement;
|
||||
use sqlparser::dialect::GenericDialect;
|
||||
use sqlparser::parser::Parser;
|
||||
|
||||
use crate::schema_diff::SchemaDiffPreparationOptions;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FilterAction {
|
||||
Allow,
|
||||
Deny(String),
|
||||
}
|
||||
|
||||
pub trait AstFilter {
|
||||
fn filter_statement(&self, stmt: &Statement) -> FilterAction;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FilterResult {
|
||||
pub allowed: Vec<String>,
|
||||
pub denied: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SandboxResult {
|
||||
pub allowed: Vec<String>,
|
||||
pub denied: Vec<String>,
|
||||
pub nesting_violations: Vec<String>,
|
||||
pub is_pure: bool,
|
||||
pub stats: SandboxStats,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SandboxMode {
|
||||
Permissive,
|
||||
#[default]
|
||||
Strict,
|
||||
Isolation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SandboxStats {
|
||||
pub total_statements: usize,
|
||||
pub allowed_count: usize,
|
||||
pub denied_count: usize,
|
||||
pub mode: String,
|
||||
pub max_nesting_depth: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AstSandbox {
|
||||
pub mode: SandboxMode,
|
||||
pub max_nesting_depth: usize,
|
||||
}
|
||||
|
||||
impl Default for AstSandbox {
|
||||
fn default() -> Self {
|
||||
Self { mode: SandboxMode::Strict, max_nesting_depth: 10 }
|
||||
}
|
||||
}
|
||||
|
||||
impl AstSandbox {
|
||||
pub fn new(mode: SandboxMode, max_nesting_depth: usize) -> Self {
|
||||
Self { mode, max_nesting_depth }
|
||||
}
|
||||
|
||||
pub fn filter_sql(&self, sql: &str, dialect_str: &str) -> Result<SandboxResult, String> {
|
||||
let base = AstTransmitFilter::filter_sql(sql, dialect_str)?;
|
||||
|
||||
let stats = SandboxStats {
|
||||
total_statements: base.allowed.len() + base.denied.len(),
|
||||
allowed_count: base.allowed.len(),
|
||||
denied_count: base.denied.len(),
|
||||
mode: match self.mode {
|
||||
SandboxMode::Permissive => "permissive".to_string(),
|
||||
SandboxMode::Strict => "strict".to_string(),
|
||||
SandboxMode::Isolation => "isolation".to_string(),
|
||||
},
|
||||
max_nesting_depth: self.max_nesting_depth,
|
||||
};
|
||||
|
||||
let (further_blocked, nest_violations) = match self.mode {
|
||||
SandboxMode::Permissive => (Vec::new(), Vec::new()),
|
||||
SandboxMode::Strict | SandboxMode::Isolation => self.check_nesting(&base.allowed, self.max_nesting_depth),
|
||||
};
|
||||
|
||||
let is_pure = match self.mode {
|
||||
SandboxMode::Permissive => base.denied.is_empty(),
|
||||
SandboxMode::Strict => base.denied.is_empty() && further_blocked.is_empty(),
|
||||
SandboxMode::Isolation => {
|
||||
base.denied.is_empty() && further_blocked.is_empty() && nest_violations.is_empty()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(SandboxResult {
|
||||
allowed: base.allowed,
|
||||
denied: base.denied.iter().chain(further_blocked.iter()).cloned().collect(),
|
||||
nesting_violations: nest_violations,
|
||||
is_pure,
|
||||
stats,
|
||||
})
|
||||
}
|
||||
|
||||
fn check_nesting(&self, statements: &[String], max_depth: usize) -> (Vec<String>, Vec<String>) {
|
||||
let mut further_blocked = Vec::new();
|
||||
let mut violations = Vec::new();
|
||||
for stmt in statements {
|
||||
let depth = self.count_nesting(stmt);
|
||||
if depth > max_depth {
|
||||
further_blocked.push(stmt.clone());
|
||||
violations.push(format!("Nesting depth {depth} exceeds max {max_depth}: {stmt}"));
|
||||
}
|
||||
}
|
||||
(further_blocked, violations)
|
||||
}
|
||||
|
||||
fn count_nesting(&self, stmt: &str) -> usize {
|
||||
let mut max_depth = 0usize;
|
||||
let mut current = 0usize;
|
||||
for ch in stmt.chars() {
|
||||
match ch {
|
||||
'(' => {
|
||||
current += 1;
|
||||
max_depth = max_depth.max(current);
|
||||
}
|
||||
')' => {
|
||||
current = current.saturating_sub(1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
max_depth
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AstTransmitFilter;
|
||||
|
||||
impl AstTransmitFilter {
|
||||
pub fn filter_sql(sql: &str, dialect_str: &str) -> Result<FilterResult, String> {
|
||||
let dialect = resolve_dialect(dialect_str);
|
||||
let stmts =
|
||||
Parser::parse_sql(dialect.as_ref(), sql).map_err(|e| format!("SQL parse error in AST filter: {e}"))?;
|
||||
|
||||
let filter = Self;
|
||||
let mut allowed = Vec::new();
|
||||
let mut denied: Vec<String> = Vec::new();
|
||||
|
||||
for stmt in &stmts {
|
||||
match filter.filter_statement(stmt) {
|
||||
FilterAction::Allow => {
|
||||
allowed.push(stmt.to_string());
|
||||
}
|
||||
FilterAction::Deny(reason) => {
|
||||
denied.push(format!("Blocked: {reason} — {stmt}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(FilterResult { allowed, denied })
|
||||
}
|
||||
|
||||
fn is_dangerous_body_node(&self, stmt: &Statement) -> bool {
|
||||
matches!(
|
||||
stmt,
|
||||
Statement::CreateFunction { .. } | Statement::CreateProcedure { .. } | Statement::CreateTrigger { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Pass-through for schema-diff preparation options.
|
||||
///
|
||||
/// Routine definitions are compared as metadata and must be preserved here.
|
||||
/// Dangerous statement bodies are still blocked by `filter_sql` /
|
||||
/// `is_dangerous_body_node` when SQL is whitelisted for transmit/sync.
|
||||
pub fn filter_diff_preparation_options(
|
||||
options: SchemaDiffPreparationOptions,
|
||||
_dialect: &str,
|
||||
) -> SchemaDiffPreparationOptions {
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_dialect(dialect: &str) -> Box<dyn sqlparser::dialect::Dialect> {
|
||||
match dialect.to_ascii_lowercase().as_str() {
|
||||
"postgres" | "postgresql" => Box::new(sqlparser::dialect::PostgreSqlDialect {}),
|
||||
"mysql" | "mariadb" | "tidb" => Box::new(sqlparser::dialect::MySqlDialect {}),
|
||||
"sqlite" => Box::new(sqlparser::dialect::SQLiteDialect {}),
|
||||
"sqlserver" | "mssql" => Box::new(sqlparser::dialect::MsSqlDialect {}),
|
||||
"clickhouse" => Box::new(sqlparser::dialect::ClickHouseDialect {}),
|
||||
"duckdb" => Box::new(sqlparser::dialect::DuckDbDialect {}),
|
||||
_ => Box::new(GenericDialect {}),
|
||||
}
|
||||
}
|
||||
|
||||
impl AstFilter for AstTransmitFilter {
|
||||
fn filter_statement(&self, stmt: &Statement) -> FilterAction {
|
||||
if self.is_dangerous_body_node(stmt) {
|
||||
return FilterAction::Deny("Function/procedure/trigger body not allowed in schema diff".into());
|
||||
}
|
||||
|
||||
match stmt {
|
||||
Statement::CreateTable { .. }
|
||||
| Statement::CreateView { .. }
|
||||
| Statement::CreateIndex { .. }
|
||||
| Statement::CreateSchema { .. }
|
||||
| Statement::CreateSequence { .. }
|
||||
| Statement::AlterTable { .. }
|
||||
| Statement::AlterIndex { .. }
|
||||
| Statement::AlterView { .. }
|
||||
| Statement::Drop { .. }
|
||||
| Statement::Truncate { .. } => FilterAction::Allow,
|
||||
|
||||
_ => FilterAction::Deny("Statement type not in whitelist for schema diff".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::FunctionInfo;
|
||||
|
||||
#[test]
|
||||
fn allows_create_table() {
|
||||
let result =
|
||||
AstTransmitFilter::filter_sql("CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(100))", "generic")
|
||||
.unwrap();
|
||||
assert!(!result.allowed.is_empty());
|
||||
assert!(result.allowed[0].contains("CREATE TABLE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_create_index() {
|
||||
let result = AstTransmitFilter::filter_sql("CREATE INDEX idx_users_name ON users (name)", "generic").unwrap();
|
||||
assert!(!result.allowed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_alter_table() {
|
||||
let result = AstTransmitFilter::filter_sql("ALTER TABLE users ADD COLUMN age INT", "generic").unwrap();
|
||||
assert!(!result.allowed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_drop_table() {
|
||||
let result = AstTransmitFilter::filter_sql("DROP TABLE users", "generic").unwrap();
|
||||
assert!(!result.allowed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_create_function() {
|
||||
let result = AstTransmitFilter::filter_sql(
|
||||
"CREATE FUNCTION add(a INT, b INT) RETURNS INT LANGUAGE SQL RETURN a + b",
|
||||
"generic",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(result.allowed.is_empty());
|
||||
assert!(!result.denied.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_create_procedure() {
|
||||
let result = AstTransmitFilter::filter_sql(
|
||||
"CREATE PROCEDURE test_proc() AS LANGUAGE SQL BEGIN ATOMIC SELECT 1; END",
|
||||
"postgres",
|
||||
);
|
||||
// parse failure on some platforms is acceptable
|
||||
if let Ok(r) = result {
|
||||
assert!(r.allowed.is_empty());
|
||||
assert!(!r.denied.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_create_trigger() {
|
||||
let result = AstTransmitFilter::filter_sql(
|
||||
"CREATE TRIGGER test_trigger BEFORE INSERT ON users FOR EACH ROW EXECUTE FUNCTION log_change()",
|
||||
"postgres",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(result.allowed.is_empty());
|
||||
assert!(!result.denied.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_multiple_ddl_statements() {
|
||||
let result = AstTransmitFilter::filter_sql(
|
||||
"CREATE TABLE a (id INT); CREATE TABLE b (id INT); ALTER TABLE a ADD COLUMN x INT;",
|
||||
"generic",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.allowed.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_mixed_ddl_and_function() {
|
||||
let result = AstTransmitFilter::filter_sql(
|
||||
"CREATE TABLE t (id INT); CREATE FUNCTION f() RETURNS INT AS $$ SELECT 1 $$ LANGUAGE SQL",
|
||||
"postgres",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.allowed.len(), 1);
|
||||
assert_eq!(result.denied.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_select_statement() {
|
||||
let result = AstTransmitFilter::filter_sql("SELECT * FROM users", "generic").unwrap();
|
||||
assert!(result.allowed.is_empty());
|
||||
assert!(!result.denied.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_create_view() {
|
||||
let result = AstTransmitFilter::filter_sql(
|
||||
"CREATE VIEW active_users AS SELECT * FROM users WHERE active = 1",
|
||||
"generic",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!result.allowed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_insert_statement() {
|
||||
let result = AstTransmitFilter::filter_sql("INSERT INTO users VALUES (1, 'a')", "generic").unwrap();
|
||||
assert!(result.allowed.is_empty());
|
||||
assert!(!result.denied.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_options_preserves_routine_definitions() {
|
||||
let opts = SchemaDiffPreparationOptions {
|
||||
source_functions: vec![
|
||||
FunctionInfo {
|
||||
name: "f1".into(),
|
||||
definition: "CREATE FUNCTION f1() RETURNS INT BEGIN RETURN 1; END".into(),
|
||||
function_type: "FUNCTION".into(),
|
||||
data_type: "int".into(),
|
||||
arguments: "".into(),
|
||||
},
|
||||
FunctionInfo {
|
||||
name: "p1".into(),
|
||||
definition: "CREATE PROCEDURE p1() BEGIN SELECT 1; END".into(),
|
||||
function_type: "PROCEDURE".into(),
|
||||
data_type: "".into(),
|
||||
arguments: "".into(),
|
||||
},
|
||||
],
|
||||
source_tables: vec![],
|
||||
target_tables: vec![],
|
||||
source_details: vec![],
|
||||
target_details: vec![],
|
||||
target_functions: vec![],
|
||||
source_sequences: vec![],
|
||||
target_sequences: vec![],
|
||||
source_rules: vec![],
|
||||
target_rules: vec![],
|
||||
source_owners: vec![],
|
||||
target_owners: vec![],
|
||||
database_type: crate::models::connection::DatabaseType::Mysql,
|
||||
target_schema: None,
|
||||
ignore_comments: false,
|
||||
cascade_delete: false,
|
||||
compare_column_order: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let filtered = AstTransmitFilter::filter_diff_preparation_options(opts, "mysql");
|
||||
assert_eq!(filtered.source_functions.len(), 2);
|
||||
assert_eq!(filtered.source_functions[0].name, "f1");
|
||||
assert_eq!(filtered.source_functions[1].name, "p1");
|
||||
assert!(filtered.source_functions[0].definition.contains("BEGIN"));
|
||||
assert!(filtered.source_functions[1].definition.contains("PROCEDURE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_strict_mode_blocks_dangerous_statement() {
|
||||
let sandbox = AstSandbox::new(SandboxMode::Strict, 10);
|
||||
let result = sandbox
|
||||
.filter_sql(
|
||||
"CREATE TABLE t (id INT); CREATE FUNCTION f() RETURNS INT AS $$ SELECT 1 $$ LANGUAGE SQL",
|
||||
"postgres",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.allowed.len(), 1);
|
||||
assert!(!result.denied.is_empty());
|
||||
assert!(!result.is_pure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_isolation_mode_checks_nesting() {
|
||||
let sandbox = AstSandbox::new(SandboxMode::Isolation, 4);
|
||||
let result = sandbox
|
||||
.filter_sql("CREATE TABLE t (a INT, b INT, c INT, d INT, e INT DEFAULT (((1))))", "generic")
|
||||
.unwrap();
|
||||
assert!(result.nesting_violations.is_empty(), "Should pass with nesting depth 4");
|
||||
assert!(result.is_pure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_isolation_blocks_deeply_nested() {
|
||||
let sandbox = AstSandbox::new(SandboxMode::Isolation, 1);
|
||||
let result = sandbox.filter_sql("CREATE TABLE t (a INT DEFAULT ((((1)))))", "generic").unwrap();
|
||||
assert!(!result.nesting_violations.is_empty());
|
||||
assert!(!result.is_pure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_permissive_accepts_everything_allowed() {
|
||||
let sandbox = AstSandbox::new(SandboxMode::Permissive, 10);
|
||||
let result = sandbox
|
||||
.filter_sql("CREATE TABLE t (id INT); CREATE FUNCTION f() RETURNS INT LANGUAGE SQL RETURN 1", "generic")
|
||||
.unwrap();
|
||||
assert_eq!(result.allowed.len(), 1, "Permissive allows CREATE TABLE but blocks CREATE FUNCTION");
|
||||
assert!(!result.denied.is_empty(), "Permissive denies function but not table");
|
||||
assert!(!result.is_pure, "Permissive is not pure when function is denied by base filter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_permissive_no_nesting_check() {
|
||||
let sandbox = AstSandbox::new(SandboxMode::Permissive, 0);
|
||||
let result = sandbox.filter_sql("CREATE TABLE t (a INT DEFAULT ((((1)))))", "generic").unwrap();
|
||||
assert!(!result.allowed.is_empty());
|
||||
assert!(result.nesting_violations.is_empty());
|
||||
assert!(result.is_pure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_stats_correct() {
|
||||
let sandbox = AstSandbox::new(SandboxMode::Strict, 10);
|
||||
let result = sandbox
|
||||
.filter_sql(
|
||||
"CREATE TABLE t1 (id INT); CREATE TABLE t2 (id INT); DROP TABLE t3; SELECT * FROM t1",
|
||||
"generic",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.stats.total_statements, 4);
|
||||
assert_eq!(result.stats.allowed_count, 3);
|
||||
assert_eq!(result.stats.denied_count, 1);
|
||||
assert_eq!(result.stats.mode, "strict");
|
||||
assert_eq!(result.stats.max_nesting_depth, 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::schema_diff::SchemaDiffPreparationOptions;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffEntry {
|
||||
pub file_path: String,
|
||||
pub sql_content: String,
|
||||
pub change_type: ChangeType,
|
||||
pub old_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ChangeType {
|
||||
Added,
|
||||
Modified,
|
||||
Deleted,
|
||||
Renamed,
|
||||
}
|
||||
|
||||
pub struct GitDiffScanner {
|
||||
repo_path: String,
|
||||
base_commit: String,
|
||||
target_commit: String,
|
||||
}
|
||||
|
||||
impl GitDiffScanner {
|
||||
pub fn new(repo_path: &Path, base_commit: &str, target_commit: &str) -> Result<Self, String> {
|
||||
if base_commit.is_empty() || target_commit.is_empty() {
|
||||
return Err("Git commit refs cannot be empty".into());
|
||||
}
|
||||
let git_path = repo_path.join(".git");
|
||||
let is_git_repo =
|
||||
git_path.is_dir() || (git_path.is_file() && git_path.exists()) || repo_path.join("../.git").exists();
|
||||
if !is_git_repo {
|
||||
return Err(format!("Not a git repository: {}", repo_path.display()));
|
||||
}
|
||||
Ok(Self {
|
||||
repo_path: repo_path.to_string_lossy().to_string(),
|
||||
base_commit: base_commit.to_string(),
|
||||
target_commit: target_commit.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn scan(&self) -> Result<Vec<DiffEntry>, String> {
|
||||
let output = self.run_git_diff()?;
|
||||
|
||||
let sql_filter = self.build_sql_filter()?;
|
||||
|
||||
let raw_entries = self.parse_diff_output(&output, &sql_filter)?;
|
||||
|
||||
let entries = raw_entries
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
if entry.change_type == ChangeType::Deleted {
|
||||
return Some(entry);
|
||||
}
|
||||
let path = Path::new(&self.repo_path).join(&entry.file_path);
|
||||
let content = std::fs::read_to_string(&path).ok()?;
|
||||
Some(DiffEntry { sql_content: content, ..entry })
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn run_git_diff(&self) -> Result<String, String> {
|
||||
let output = std::process::Command::new("git")
|
||||
.arg("diff")
|
||||
.arg("--diff-filter=ACMR")
|
||||
.arg(&self.base_commit)
|
||||
.arg(&self.target_commit)
|
||||
.arg("--")
|
||||
.arg("*.sql")
|
||||
.arg("*.ddl")
|
||||
.current_dir(&self.repo_path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git diff: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git diff failed: {stderr}"));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
fn build_sql_filter(&self) -> Result<HashSet<String>, String> {
|
||||
let mut patterns = HashSet::new();
|
||||
let gitattributes_path = Path::new(&self.repo_path).join(".gitattributes");
|
||||
if gitattributes_path.exists() {
|
||||
let content = std::fs::read_to_string(&gitattributes_path)
|
||||
.map_err(|e| format!("Failed to read .gitattributes: {e}"))?;
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 2 && parts[1] == "linguist-language=SQL" {
|
||||
let pattern = parts[0].trim_matches('*');
|
||||
if !pattern.is_empty() {
|
||||
patterns.insert(pattern.to_string());
|
||||
} else {
|
||||
patterns.insert("sql".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if patterns.is_empty() {
|
||||
patterns.insert("sql".to_string());
|
||||
}
|
||||
Ok(patterns)
|
||||
}
|
||||
|
||||
fn parse_diff_output(&self, output: &str, _filter: &HashSet<String>) -> Result<Vec<DiffEntry>, String> {
|
||||
let mut entries = Vec::new();
|
||||
let mut current_file: Option<String> = None;
|
||||
let mut current_status: Option<ChangeType> = None;
|
||||
let mut current_old_path: Option<String> = None;
|
||||
|
||||
for line in output.lines() {
|
||||
if line.starts_with("diff --git ") {
|
||||
Self::flush_raw_entry(&mut current_file, &mut current_status, &mut current_old_path, &mut entries);
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 4 {
|
||||
let b_path = parts.get(3).copied().unwrap_or("");
|
||||
let b_path = b_path.trim_start_matches("b/");
|
||||
current_file = Some(b_path.to_string());
|
||||
current_status = Some(ChangeType::Modified);
|
||||
}
|
||||
} else if line.starts_with("--- ") {
|
||||
let path = line.trim_start_matches("--- ").trim_start_matches("a/");
|
||||
if path != "/dev/null" {
|
||||
current_old_path = Some(path.to_string());
|
||||
}
|
||||
} else if line.starts_with("+++ ") {
|
||||
let path = line.trim_start_matches("+++ ").trim_start_matches("b/");
|
||||
if path != "/dev/null" {
|
||||
current_file = Some(path.to_string());
|
||||
}
|
||||
} else if line.starts_with("new file mode") {
|
||||
current_status = Some(ChangeType::Added);
|
||||
} else if line.starts_with("deleted file mode") {
|
||||
current_status = Some(ChangeType::Deleted);
|
||||
} else if line.starts_with("rename from ") {
|
||||
current_old_path = Some(line.trim_start_matches("rename from ").to_string());
|
||||
} else if line.starts_with("rename to ") {
|
||||
current_file = Some(line.trim_start_matches("rename to ").to_string());
|
||||
current_status = Some(ChangeType::Renamed);
|
||||
}
|
||||
}
|
||||
|
||||
Self::flush_raw_entry(&mut current_file, &mut current_status, &mut current_old_path, &mut entries);
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn flush_raw_entry(
|
||||
file: &mut Option<String>,
|
||||
status: &mut Option<ChangeType>,
|
||||
old_path: &mut Option<String>,
|
||||
entries: &mut Vec<DiffEntry>,
|
||||
) {
|
||||
let file_path = file.take();
|
||||
let st = status.take().unwrap_or(ChangeType::Modified);
|
||||
let op = old_path.take();
|
||||
if let Some(path) = file_path {
|
||||
entries.push(DiffEntry { file_path: path, sql_content: String::new(), change_type: st, old_path: op });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bind_to_commit(options: SchemaDiffPreparationOptions, _commit_id: &str) -> SchemaDiffPreparationOptions {
|
||||
options
|
||||
}
|
||||
|
||||
pub fn list_commits(&self, max_count: usize) -> Result<Vec<String>, String> {
|
||||
let output = std::process::Command::new("git")
|
||||
.arg("log")
|
||||
.arg("--oneline")
|
||||
.arg("-n")
|
||||
.arg(max_count.to_string())
|
||||
.arg("--")
|
||||
.arg("*.sql")
|
||||
.arg("*.ddl")
|
||||
.current_dir(&self.repo_path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git log: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(String::from_utf8_lossy(&output.stderr).to_string());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout.lines().map(|l| l.to_string()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_non_git_directory() {
|
||||
let tmp = std::env::temp_dir().join("dbx_test_not_a_repo");
|
||||
let _ = std::fs::create_dir_all(&tmp);
|
||||
let result = GitDiffScanner::new(&tmp, "HEAD~1", "HEAD");
|
||||
assert!(result.is_err());
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_diff_output_with_added_file() {
|
||||
let scanner =
|
||||
GitDiffScanner { repo_path: "/tmp/repo".into(), base_commit: "abc".into(), target_commit: "def".into() };
|
||||
let output = "diff --git a/schema.sql b/schema.sql\nnew file mode 100644\n--- /dev/null\n+++ b/schema.sql\n@@ -0,0 +1 @@\n+CREATE TABLE t (id INT);\n";
|
||||
let mut filter = HashSet::new();
|
||||
filter.insert("sql".to_string());
|
||||
let entries = scanner.parse_diff_output(output, &filter).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].file_path, "schema.sql");
|
||||
assert_eq!(entries[0].change_type, ChangeType::Added);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_diff_output_with_modified_file() {
|
||||
let scanner =
|
||||
GitDiffScanner { repo_path: "/tmp/repo".into(), base_commit: "abc".into(), target_commit: "def".into() };
|
||||
let output = "diff --git a/schema.sql b/schema.sql\n--- a/schema.sql\n+++ b/schema.sql\n@@ -1 +1 @@\n-CREATE TABLE t (id INT);\n+CREATE TABLE t (id BIGINT);\n";
|
||||
let mut filter = HashSet::new();
|
||||
filter.insert("sql".to_string());
|
||||
let entries = scanner.parse_diff_output(output, &filter).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].change_type, ChangeType::Modified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_diff_output_with_renamed_file() {
|
||||
let scanner =
|
||||
GitDiffScanner { repo_path: "/tmp/repo".into(), base_commit: "abc".into(), target_commit: "def".into() };
|
||||
let output = "diff --git a/old.sql b/new.sql\nrename from old.sql\nrename to new.sql\n";
|
||||
let mut filter = HashSet::new();
|
||||
filter.insert("sql".to_string());
|
||||
let entries = scanner.parse_diff_output(output, &filter).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].change_type, ChangeType::Renamed);
|
||||
assert_eq!(entries[0].old_path, Some("old.sql".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_gitattributes_for_sql_patterns() {
|
||||
let tmp = std::env::temp_dir().join("dbx_test_gitattributes");
|
||||
let _ = std::fs::create_dir_all(&tmp);
|
||||
let gitattributes = tmp.join(".gitattributes");
|
||||
std::fs::write(&gitattributes, "*.sql linguist-language=SQL\n*.ddl linguist-language=SQL\n").unwrap();
|
||||
|
||||
let scanner = GitDiffScanner {
|
||||
repo_path: tmp.to_string_lossy().to_string(),
|
||||
base_commit: "abc".into(),
|
||||
target_commit: "def".into(),
|
||||
};
|
||||
let filter = scanner.build_sql_filter().unwrap();
|
||||
assert!(!filter.is_empty());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::models::connection::ConnectionConfig;
|
||||
use crate::schema_diff::SchemaDiffPreparationOptions;
|
||||
use crate::sql_parser::meta::{validate_sql_meta_consistency, MetaData, MetaReader};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum InputSource {
|
||||
DdlFiles(Vec<PathBuf>),
|
||||
DatabaseConnection(ConnectionConfig),
|
||||
GitDiff { repo_path: PathBuf, base_commit: String, target_commit: String },
|
||||
MetaData(MetaData),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedInput {
|
||||
pub source: InputSource,
|
||||
pub meta: Option<MetaData>,
|
||||
pub ddl_sql: Option<String>,
|
||||
pub has_errors: bool,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct InputResolver;
|
||||
|
||||
impl InputResolver {
|
||||
pub fn discover_ddl_files(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
if !dir.is_dir() {
|
||||
return files;
|
||||
}
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return files,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
files.extend(Self::discover_ddl_files(&path));
|
||||
continue;
|
||||
}
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
if matches!(ext, "sql" | "ddl" | "txt") {
|
||||
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if !file_name.starts_with('.') {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
pub fn load_meta_for(sql_path: &Path) -> Option<MetaData> {
|
||||
let dir = sql_path.parent()?;
|
||||
let stem = sql_path.file_stem().and_then(|s| s.to_str())?;
|
||||
for ext in &["meta.json", "meta.yaml", "meta.yml"] {
|
||||
let meta_path = dir.join(format!("{}.{}", stem, ext));
|
||||
if meta_path.exists() {
|
||||
if let Ok(meta) = MetaReader::from_file(&meta_path) {
|
||||
return Some(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
let meta_path = dir.join(".dbmeta.json");
|
||||
if meta_path.exists() {
|
||||
if let Ok(meta) = MetaReader::from_file(&meta_path) {
|
||||
return Some(meta);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn resolve(inputs: Vec<InputSource>) -> ResolvedInput {
|
||||
let mut meta: Option<MetaData> = None;
|
||||
let mut sql_parts: Vec<String> = Vec::new();
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
let mut live_db: Option<ConnectionConfig> = None;
|
||||
|
||||
for input in inputs {
|
||||
match input {
|
||||
InputSource::DdlFiles(files) => {
|
||||
for file in files {
|
||||
if let Ok(content) = std::fs::read_to_string(&file) {
|
||||
if let Some(file_meta) = Self::load_meta_for(&file) {
|
||||
let mw = validate_sql_meta_consistency(&content, &file_meta);
|
||||
warnings.extend(mw.into_iter().map(|w| format!("{}: {}", file.display(), w)));
|
||||
meta = Self::merge_meta(meta.take(), Some(file_meta));
|
||||
}
|
||||
sql_parts.push(content);
|
||||
} else {
|
||||
warnings.push(format!("Cannot read DDL file: {}", file.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
InputSource::DatabaseConnection(config) => {
|
||||
live_db = Some(config);
|
||||
}
|
||||
InputSource::GitDiff { repo_path, base_commit, target_commit } => {
|
||||
match crate::sql_parser::git::GitDiffScanner::new(&repo_path, &base_commit, &target_commit) {
|
||||
Ok(scanner) => match scanner.scan() {
|
||||
Ok(entries) => {
|
||||
for entry in entries {
|
||||
sql_parts.push(entry.sql_content);
|
||||
}
|
||||
}
|
||||
Err(e) => warnings.push(format!("Git diff scan error: {e}")),
|
||||
},
|
||||
Err(e) => warnings.push(format!("Git scanner init error: {e}")),
|
||||
}
|
||||
}
|
||||
InputSource::MetaData(m) => {
|
||||
meta = Self::merge_meta(meta.take(), Some(m));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref meta_data) = meta {
|
||||
let mw = MetaReader::validate_consistency(meta_data);
|
||||
warnings.extend(mw);
|
||||
}
|
||||
|
||||
let source = if let Some(config) = live_db {
|
||||
InputSource::DatabaseConnection(config)
|
||||
} else {
|
||||
InputSource::DdlFiles(Vec::new())
|
||||
};
|
||||
|
||||
ResolvedInput {
|
||||
source,
|
||||
meta,
|
||||
ddl_sql: if sql_parts.is_empty() { None } else { Some(sql_parts.join("\n\n")) },
|
||||
has_errors: warnings.iter().any(|w| w.starts_with("Cannot read") || w.contains("error")),
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_meta(existing: Option<MetaData>, incoming: Option<MetaData>) -> Option<MetaData> {
|
||||
match (existing, incoming) {
|
||||
(None, incoming) => incoming,
|
||||
(existing, None) => existing,
|
||||
(Some(mut e), Some(i)) => {
|
||||
e.dialect = e.dialect.or(i.dialect);
|
||||
e.version = e.version.or(i.version);
|
||||
e.charset = e.charset.or(i.charset);
|
||||
e.collation = e.collation.or(i.collation);
|
||||
e.sql_mode = e.sql_mode.or(i.sql_mode);
|
||||
e.explicit_dependencies.extend(i.explicit_dependencies);
|
||||
Some(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_to_diff_options(resolved: ResolvedInput) -> (SchemaDiffPreparationOptions, Vec<String>) {
|
||||
let mut options = SchemaDiffPreparationOptions {
|
||||
source_tables: Vec::new(),
|
||||
target_tables: Vec::new(),
|
||||
source_details: Vec::new(),
|
||||
target_details: Vec::new(),
|
||||
source_functions: Vec::new(),
|
||||
target_functions: Vec::new(),
|
||||
source_sequences: Vec::new(),
|
||||
target_sequences: Vec::new(),
|
||||
source_rules: Vec::new(),
|
||||
target_rules: Vec::new(),
|
||||
source_owners: Vec::new(),
|
||||
target_owners: Vec::new(),
|
||||
database_type: crate::models::connection::DatabaseType::Mysql,
|
||||
target_schema: None,
|
||||
ignore_comments: false,
|
||||
cascade_delete: false,
|
||||
compare_column_order: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Some(ref meta) = resolved.meta {
|
||||
if let Some(ref dialect) = meta.dialect {
|
||||
let db_type = dialect_to_database_type(dialect);
|
||||
options.database_type = db_type;
|
||||
}
|
||||
}
|
||||
|
||||
(options, resolved.warnings)
|
||||
}
|
||||
}
|
||||
|
||||
fn dialect_to_database_type(dialect: &str) -> crate::models::connection::DatabaseType {
|
||||
match dialect.to_ascii_lowercase().as_str() {
|
||||
"mysql" | "mariadb" | "tidb" => crate::models::connection::DatabaseType::Mysql,
|
||||
"postgres" | "postgresql" => crate::models::connection::DatabaseType::Postgres,
|
||||
"sqlite" => crate::models::connection::DatabaseType::Sqlite,
|
||||
"sqlserver" | "mssql" => crate::models::connection::DatabaseType::SqlServer,
|
||||
"clickhouse" => crate::models::connection::DatabaseType::ClickHouse,
|
||||
"duckdb" => crate::models::connection::DatabaseType::DuckDb,
|
||||
"oracle" => crate::models::connection::DatabaseType::Oracle,
|
||||
_ => crate::models::connection::DatabaseType::Mysql,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn discovers_sql_files_in_directory() {
|
||||
let dir = std::env::temp_dir().join("dbx_test_ddl_discover");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let mut f1 = std::fs::File::create(dir.join("schema.sql")).unwrap();
|
||||
f1.write_all(b"CREATE TABLE t (id INT);").unwrap();
|
||||
let mut f2 = std::fs::File::create(dir.join("data.ddl")).unwrap();
|
||||
f2.write_all(b"INSERT INTO t VALUES (1);").unwrap();
|
||||
let _hidden = std::fs::File::create(dir.join(".ignore.sql")).unwrap();
|
||||
|
||||
let files = InputResolver::discover_ddl_files(&dir);
|
||||
let names: Vec<String> =
|
||||
files.iter().filter_map(|f| f.file_name().and_then(|n| n.to_str()).map(|s| s.to_string())).collect();
|
||||
assert!(names.contains(&"schema.sql".to_string()));
|
||||
assert!(names.contains(&"data.ddl".to_string()));
|
||||
assert!(!names.contains(&".ignore.sql".to_string()));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_meta_file_for_sql() {
|
||||
let dir = std::env::temp_dir().join("dbx_test_meta_load");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let sql_path = dir.join("test.sql");
|
||||
let meta_path = dir.join("test.meta.json");
|
||||
std::fs::write(&sql_path, "CREATE TABLE t (id INT);").unwrap();
|
||||
std::fs::write(&meta_path, r#"{"dialect": "postgres", "version": "15"}"#).unwrap();
|
||||
|
||||
let meta = InputResolver::load_meta_for(&sql_path);
|
||||
assert!(meta.is_some());
|
||||
assert_eq!(meta.unwrap().dialect.as_deref(), Some("postgres"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_uses_meta_dialect() {
|
||||
let resolved = ResolvedInput {
|
||||
source: InputSource::DdlFiles(Vec::new()),
|
||||
meta: Some(MetaData {
|
||||
dialect: Some("postgres".into()),
|
||||
version: None,
|
||||
charset: None,
|
||||
collation: None,
|
||||
sql_mode: None,
|
||||
explicit_dependencies: std::collections::HashMap::new(),
|
||||
}),
|
||||
ddl_sql: Some("CREATE TABLE t (id INT);".into()),
|
||||
has_errors: false,
|
||||
warnings: Vec::new(),
|
||||
};
|
||||
let (options, _) = InputResolver::normalize_to_diff_options(resolved);
|
||||
assert_eq!(options.database_type, crate::models::connection::DatabaseType::Postgres);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_adds_has_errors_on_failed_read() {
|
||||
let resolved = InputResolver::resolve(vec![InputSource::DdlFiles(vec![PathBuf::from(
|
||||
r"C:\non_existent_dir\nonexistent.sql",
|
||||
)])]);
|
||||
assert!(resolved.has_errors);
|
||||
assert_eq!(resolved.warnings.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_merges_meta_from_multiple_sources() {
|
||||
let meta1 = InputSource::MetaData(MetaData {
|
||||
dialect: Some("mysql".into()),
|
||||
version: Some("8.0".into()),
|
||||
charset: None,
|
||||
collation: None,
|
||||
sql_mode: None,
|
||||
explicit_dependencies: std::collections::HashMap::new(),
|
||||
});
|
||||
let meta2 = InputSource::MetaData(MetaData {
|
||||
dialect: None,
|
||||
version: None,
|
||||
charset: Some("utf8mb4".into()),
|
||||
collation: None,
|
||||
sql_mode: None,
|
||||
explicit_dependencies: std::collections::HashMap::new(),
|
||||
});
|
||||
let resolved = InputResolver::resolve(vec![meta1, meta2]);
|
||||
assert_eq!(resolved.meta.as_ref().and_then(|m| m.dialect.as_deref()), Some("mysql"));
|
||||
assert_eq!(resolved.meta.as_ref().and_then(|m| m.charset.as_deref()), Some("utf8mb4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_ddl_dir_returns_no_files() {
|
||||
let dir = std::env::temp_dir().join("dbx_test_empty_ddl");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let files = InputResolver::discover_ddl_files(&dir);
|
||||
assert!(files.is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetaData {
|
||||
#[serde(default)]
|
||||
pub dialect: Option<String>,
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub charset: Option<String>,
|
||||
#[serde(default)]
|
||||
pub collation: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sql_mode: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub explicit_dependencies: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
pub struct MetaReader;
|
||||
|
||||
impl MetaReader {
|
||||
pub fn from_file(path: &Path) -> Result<MetaData, String> {
|
||||
let content = std::fs::read_to_string(path).map_err(|e| format!("Failed to read meta file: {e}"))?;
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
match ext {
|
||||
"json" => Self::from_json(&content),
|
||||
"yaml" | "yml" => Self::from_yaml(&content),
|
||||
_ => Self::from_json(&content),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_json(content: &str) -> Result<MetaData, String> {
|
||||
serde_json::from_str(content).map_err(|e| format!("Invalid meta JSON: {e}"))
|
||||
}
|
||||
|
||||
pub fn from_yaml(content: &str) -> Result<MetaData, String> {
|
||||
serde_yaml::from_str(content).map_err(|e| format!("Invalid meta YAML: {e}"))
|
||||
}
|
||||
|
||||
pub fn to_json(meta: &MetaData) -> Result<String, String> {
|
||||
serde_json::to_string_pretty(meta).map_err(|e| format!("Serialize meta error: {e}"))
|
||||
}
|
||||
|
||||
pub fn validate_consistency(meta: &MetaData) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
if let Some(ref dialect) = meta.dialect {
|
||||
let lower = dialect.to_ascii_lowercase();
|
||||
let known = [
|
||||
"mysql",
|
||||
"postgres",
|
||||
"postgresql",
|
||||
"sqlite",
|
||||
"mssql",
|
||||
"sqlserver",
|
||||
"clickhouse",
|
||||
"duckdb",
|
||||
"oracle",
|
||||
"tidb",
|
||||
"mariadb",
|
||||
];
|
||||
if !known.contains(&lower.as_str()) {
|
||||
warnings.push(format!("Unknown dialect '{}' in metadata", meta.dialect.as_ref().unwrap()));
|
||||
}
|
||||
}
|
||||
if let Some(ref version) = meta.version {
|
||||
let cleaned = version.trim_start_matches(|c: char| c.is_ascii_alphabetic() || c == 'v' || c == 'V');
|
||||
let is_valid = !cleaned.is_empty()
|
||||
&& cleaned.chars().all(|c| c.is_ascii_digit() || c == '.' || c == '-')
|
||||
&& cleaned.chars().any(|c| c.is_ascii_digit());
|
||||
if !is_valid {
|
||||
warnings.push(format!("Unusual version format '{}'", version));
|
||||
}
|
||||
}
|
||||
warnings
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_sql_meta_consistency(sql: &str, meta: &MetaData) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
if let Some(ref charset) = meta.charset {
|
||||
if !charset_mentions_match(sql, charset, meta.dialect.as_deref()) {
|
||||
warnings.push(format!("SQL content does not reference charset '{}' from metadata", charset));
|
||||
}
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
fn charset_mentions_match(sql: &str, charset: &str, dialect: Option<&str>) -> bool {
|
||||
let sql_upper = sql.to_ascii_uppercase();
|
||||
let charset_upper = charset.to_ascii_uppercase();
|
||||
let is_pg = dialect.is_some_and(|d| matches!(d.to_ascii_lowercase().as_str(), "postgres" | "postgresql"));
|
||||
if is_pg {
|
||||
sql_upper.contains(&format!("ENCODING '{}'", charset_upper))
|
||||
|| sql_upper.contains(&format!("ENCODING '{}'", charset_upper.to_ascii_lowercase()))
|
||||
} else {
|
||||
let pattern = format!("CHARACTER SET {}", charset_upper);
|
||||
sql_upper.contains(&pattern) || sql_upper.contains(&format!("CHARSET {}", charset_upper))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_valid_json_metadata() {
|
||||
let json = r#"{
|
||||
"dialect": "mysql",
|
||||
"version": "8.0",
|
||||
"charset": "utf8mb4",
|
||||
"explicitDependencies": {
|
||||
"view_orders": ["orders", "users"]
|
||||
}
|
||||
}"#;
|
||||
let meta = MetaReader::from_json(json).unwrap();
|
||||
assert_eq!(meta.dialect.as_deref(), Some("mysql"));
|
||||
assert_eq!(meta.version.as_deref(), Some("8.0"));
|
||||
assert_eq!(
|
||||
meta.explicit_dependencies.get("view_orders").map(|v| v.as_slice()),
|
||||
Some(&["orders".to_string(), "users".to_string()][..])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_valid_yaml_metadata() {
|
||||
let yaml = r#"
|
||||
dialect: postgres
|
||||
version: "15"
|
||||
charset: UTF8
|
||||
explicitDependencies:
|
||||
expensive_view: [table_a, table_b]
|
||||
"#;
|
||||
let meta = MetaReader::from_yaml(yaml).unwrap();
|
||||
assert_eq!(meta.dialect.as_deref(), Some("postgres"));
|
||||
assert_eq!(meta.explicit_dependencies.get("expensive_view").map(|v| v.len()), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_known_dialect() {
|
||||
let meta = MetaData {
|
||||
dialect: Some("mysql".into()),
|
||||
version: Some("8.0".into()),
|
||||
charset: None,
|
||||
collation: None,
|
||||
sql_mode: None,
|
||||
explicit_dependencies: HashMap::new(),
|
||||
};
|
||||
assert!(MetaReader::validate_consistency(&meta).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warns_unknown_dialect() {
|
||||
let meta = MetaData {
|
||||
dialect: Some("fakedb".into()),
|
||||
version: None,
|
||||
charset: None,
|
||||
collation: None,
|
||||
sql_mode: None,
|
||||
explicit_dependencies: HashMap::new(),
|
||||
};
|
||||
let warnings = MetaReader::validate_consistency(&meta);
|
||||
assert!(!warnings.is_empty());
|
||||
assert!(warnings[0].contains("fakedb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warns_unusual_version_format() {
|
||||
let meta = MetaData {
|
||||
dialect: Some("mysql".into()),
|
||||
version: Some("latest".into()),
|
||||
charset: None,
|
||||
collation: None,
|
||||
sql_mode: None,
|
||||
explicit_dependencies: HashMap::new(),
|
||||
};
|
||||
let warnings = MetaReader::validate_consistency(&meta);
|
||||
assert!(!warnings.is_empty());
|
||||
assert!(warnings[0].contains("latest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charset_consistency_check() {
|
||||
let meta = MetaData {
|
||||
dialect: Some("mysql".into()),
|
||||
version: Some("8.0".into()),
|
||||
charset: Some("utf8mb4".into()),
|
||||
collation: None,
|
||||
sql_mode: None,
|
||||
explicit_dependencies: HashMap::new(),
|
||||
};
|
||||
let sql = "CREATE TABLE t (c VARCHAR(100)) CHARACTER SET utf8mb4";
|
||||
assert!(validate_sql_meta_consistency(sql, &meta).is_empty());
|
||||
|
||||
let sql_no_charset = "CREATE TABLE t (c VARCHAR(100))";
|
||||
let warnings = validate_sql_meta_consistency(sql_no_charset, &meta);
|
||||
assert!(!warnings.is_empty());
|
||||
assert!(warnings[0].contains("utf8mb4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_meta_has_no_warnings() {
|
||||
let meta = MetaData {
|
||||
dialect: None,
|
||||
version: None,
|
||||
charset: None,
|
||||
collation: None,
|
||||
sql_mode: None,
|
||||
explicit_dependencies: HashMap::new(),
|
||||
};
|
||||
assert!(MetaReader::validate_consistency(&meta).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_and_deserializes_roundtrip() {
|
||||
let meta = MetaData {
|
||||
dialect: Some("postgres".into()),
|
||||
version: Some("16".into()),
|
||||
charset: Some("UTF8".into()),
|
||||
collation: Some("en_US.UTF-8".into()),
|
||||
sql_mode: None,
|
||||
explicit_dependencies: HashMap::from([("v".into(), vec!["a".into(), "b".into()])]),
|
||||
};
|
||||
let json = MetaReader::to_json(&meta).unwrap();
|
||||
let restored = MetaReader::from_json(&json).unwrap();
|
||||
assert_eq!(restored.dialect, meta.dialect);
|
||||
assert_eq!(restored.version, meta.version);
|
||||
assert_eq!(restored.explicit_dependencies.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod ast_filter;
|
||||
pub mod git;
|
||||
pub mod input;
|
||||
pub mod meta;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,223 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SqlServerTemporalKind {
|
||||
Date,
|
||||
Time { scale: u8 },
|
||||
SmallDateTime,
|
||||
DateTime,
|
||||
DateTime2 { scale: u8 },
|
||||
DateTimeOffset { scale: u8 },
|
||||
}
|
||||
|
||||
pub fn format_sqlserver_datetime_display(value: &chrono::NaiveDateTime) -> String {
|
||||
let rounded = value.checked_add_signed(chrono::Duration::microseconds(500)).unwrap_or(*value);
|
||||
let base = rounded.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
let millis = rounded.and_utc().timestamp_subsec_millis();
|
||||
if millis == 0 {
|
||||
base
|
||||
} else {
|
||||
format!("{base}.{millis:03}")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_sqlserver_temporal_literal(value: &str, column_type: Option<&str>) -> Option<String> {
|
||||
let kind = sqlserver_temporal_kind(column_type?)?;
|
||||
let parts = parse_sqlserver_temporal(value)?;
|
||||
match kind {
|
||||
SqlServerTemporalKind::Date => Some(parts.date),
|
||||
SqlServerTemporalKind::Time { scale } => Some(format_sqlserver_time_text(&parts.time, scale)),
|
||||
SqlServerTemporalKind::SmallDateTime => {
|
||||
Some(format!("{} {}", parts.date, format_sqlserver_time_text(&parts.time, 0)))
|
||||
}
|
||||
SqlServerTemporalKind::DateTime => Some(format_sqlserver_datetime_parts(&parts.date, &parts.time)),
|
||||
SqlServerTemporalKind::DateTime2 { scale } => {
|
||||
Some(format!("{} {}", parts.date, format_sqlserver_time_text(&parts.time, scale)))
|
||||
}
|
||||
SqlServerTemporalKind::DateTimeOffset { scale } => {
|
||||
let offset = parts.offset?;
|
||||
Some(format!("{} {}{}", parts.date, format_sqlserver_time_text(&parts.time, scale), offset))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlserver_temporal_kind(column_type: &str) -> Option<SqlServerTemporalKind> {
|
||||
let lower = column_type.trim().to_ascii_lowercase();
|
||||
let base = lower.split(['(', ' ', '\t', '\n']).next().unwrap_or("");
|
||||
match base {
|
||||
"date" | "daten" => Some(SqlServerTemporalKind::Date),
|
||||
"smalldatetime" => Some(SqlServerTemporalKind::SmallDateTime),
|
||||
"datetime" | "datetimen" => Some(SqlServerTemporalKind::DateTime),
|
||||
"datetime4" => Some(SqlServerTemporalKind::SmallDateTime),
|
||||
"datetime2" => Some(SqlServerTemporalKind::DateTime2 { scale: temporal_scale(&lower).unwrap_or(7) }),
|
||||
"time" | "timen" => Some(SqlServerTemporalKind::Time { scale: temporal_scale(&lower).unwrap_or(7) }),
|
||||
"datetimeoffset" | "datetimeoffsetn" => {
|
||||
Some(SqlServerTemporalKind::DateTimeOffset { scale: temporal_scale(&lower).unwrap_or(7) })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn temporal_scale(column_type: &str) -> Option<u8> {
|
||||
let start = column_type.find('(')?;
|
||||
let end = column_type[start + 1..].find(')')? + start + 1;
|
||||
let scale = column_type[start + 1..end].trim().parse::<u8>().ok()?;
|
||||
Some(scale.min(7))
|
||||
}
|
||||
|
||||
struct SqlServerTemporalParts {
|
||||
date: String,
|
||||
time: String,
|
||||
offset: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_sqlserver_temporal(value: &str) -> Option<SqlServerTemporalParts> {
|
||||
let trimmed = value.trim();
|
||||
let bytes = trimmed.as_bytes();
|
||||
if bytes.len() >= 10 && is_date_prefix(bytes) {
|
||||
let date = trimmed[..10].to_string();
|
||||
let mut rest = trimmed[10..].trim_start();
|
||||
if rest.starts_with('T') || rest.starts_with('t') {
|
||||
rest = rest[1..].trim_start();
|
||||
}
|
||||
if rest.is_empty() {
|
||||
return Some(SqlServerTemporalParts { date, time: "00:00:00".to_string(), offset: None });
|
||||
}
|
||||
let (time, offset) = split_time_and_offset(rest)?;
|
||||
return Some(SqlServerTemporalParts { date, time, offset });
|
||||
}
|
||||
|
||||
let (time, offset) = split_time_and_offset(trimmed)?;
|
||||
Some(SqlServerTemporalParts { date: "1900-01-01".to_string(), time, offset })
|
||||
}
|
||||
|
||||
fn is_date_prefix(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 10
|
||||
&& bytes[4] == b'-'
|
||||
&& bytes[7] == b'-'
|
||||
&& bytes[0..4].iter().all(u8::is_ascii_digit)
|
||||
&& bytes[5..7].iter().all(u8::is_ascii_digit)
|
||||
&& bytes[8..10].iter().all(u8::is_ascii_digit)
|
||||
}
|
||||
|
||||
fn split_time_and_offset(value: &str) -> Option<(String, Option<String>)> {
|
||||
if value.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.get(2) != Some(&b':') || bytes.get(5) != Some(&b':') {
|
||||
return None;
|
||||
}
|
||||
if !bytes[0..2].iter().all(u8::is_ascii_digit)
|
||||
|| !bytes[3..5].iter().all(u8::is_ascii_digit)
|
||||
|| !bytes[6..8].iter().all(u8::is_ascii_digit)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut end = 8;
|
||||
if bytes.get(end) == Some(&b'.') {
|
||||
end += 1;
|
||||
let fraction_start = end;
|
||||
while end < bytes.len() && bytes[end].is_ascii_digit() {
|
||||
end += 1;
|
||||
}
|
||||
if end == fraction_start {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let time = value[..end].to_string();
|
||||
let suffix = value[end..].trim();
|
||||
if suffix.is_empty() {
|
||||
return Some((time, None));
|
||||
}
|
||||
if suffix.eq_ignore_ascii_case("z") {
|
||||
return Some((time, Some("+00:00".to_string())));
|
||||
}
|
||||
if is_timezone_offset(suffix) {
|
||||
return Some((time, Some(suffix.to_string())));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_timezone_offset(value: &str) -> bool {
|
||||
let bytes = value.as_bytes();
|
||||
bytes.len() == 6
|
||||
&& matches!(bytes[0], b'+' | b'-')
|
||||
&& bytes[3] == b':'
|
||||
&& bytes[1].is_ascii_digit()
|
||||
&& bytes[2].is_ascii_digit()
|
||||
&& bytes[4].is_ascii_digit()
|
||||
&& bytes[5].is_ascii_digit()
|
||||
}
|
||||
|
||||
fn format_sqlserver_time_text(value: &str, scale: u8) -> String {
|
||||
let (base, fraction) = value.split_once('.').unwrap_or((value, ""));
|
||||
if scale == 0 {
|
||||
return base.to_string();
|
||||
}
|
||||
let mut digits = fraction.chars().take_while(|ch| ch.is_ascii_digit()).take(scale as usize).collect::<String>();
|
||||
while digits.len() < scale as usize {
|
||||
digits.push('0');
|
||||
}
|
||||
if digits.chars().all(|ch| ch == '0') {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("{base}.{digits}")
|
||||
}
|
||||
}
|
||||
|
||||
fn format_sqlserver_datetime_parts(date: &str, time: &str) -> String {
|
||||
let value = format!("{date} {time}");
|
||||
chrono::NaiveDateTime::parse_from_str(&value, "%Y-%m-%d %H:%M:%S%.f")
|
||||
.map(|value| format_sqlserver_datetime_display(&value))
|
||||
.unwrap_or_else(|_| format!("{date} {}", format_sqlserver_time_text(time, 3)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn formats_sqlserver_datetime_display_with_millisecond_precision() {
|
||||
let value =
|
||||
chrono::NaiveDate::from_ymd_opt(2026, 6, 29).unwrap().and_hms_nano_opt(10, 11, 12, 896_666_666).unwrap();
|
||||
|
||||
assert_eq!(format_sqlserver_datetime_display(&value), "2026-06-29 10:11:12.897");
|
||||
|
||||
let boundary =
|
||||
chrono::NaiveDate::from_ymd_opt(2026, 6, 29).unwrap().and_hms_nano_opt(23, 59, 59, 999_600_000).unwrap();
|
||||
assert_eq!(format_sqlserver_datetime_display(&boundary), "2026-06-30 00:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_sqlserver_datetime_literals_by_column_type() {
|
||||
assert_eq!(
|
||||
normalize_sqlserver_temporal_literal("2026-06-29 10:11:12.896666666", Some("datetime")),
|
||||
Some("2026-06-29 10:11:12.897".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_sqlserver_temporal_literal("2026-06-29T10:11:12.8966666Z", Some("datetime2(7)")),
|
||||
Some("2026-06-29 10:11:12.8966666".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_sqlserver_temporal_literal("2026-06-29T10:11:12.8966666Z", Some("datetime2(3)")),
|
||||
Some("2026-06-29 10:11:12.896".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_sqlserver_temporal_literal("2026-06-29 10:11:12.8966666+08:00", Some("datetimeoffset(4)")),
|
||||
Some("2026-06-29 10:11:12.8966+08:00".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_sqlserver_temporal_literal("10:11:12.8966666", Some("time(2)")),
|
||||
Some("10:11:12.89".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_sqlserver_temporal_literal("2026-06-29 10:11:12.8966666", Some("date")),
|
||||
Some("2026-06-29".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_sqlserver_temporal_literal("2026-06-29 10:11:12.8966666", Some("smalldatetime")),
|
||||
Some("2026-06-29 10:11:12".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
mod column_alter;
|
||||
mod column_format;
|
||||
mod columns;
|
||||
mod comments;
|
||||
mod create_table;
|
||||
mod dialect;
|
||||
mod foreign_keys;
|
||||
mod indexes;
|
||||
mod mysql_engine;
|
||||
mod owner;
|
||||
mod triggers;
|
||||
mod types;
|
||||
mod util;
|
||||
mod validation;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use column_alter::build_single_column_alter_sql;
|
||||
pub use create_table::build_create_table_sql;
|
||||
pub use owner::build_table_owner_change_sql;
|
||||
pub use types::*;
|
||||
|
||||
pub use column_alter::{
|
||||
build_sqlserver_alter_column_preserving_default_sql, build_sqlserver_drop_default_constraint_sql,
|
||||
};
|
||||
pub use comments::{build_sqlserver_column_comment_sql, build_sqlserver_table_comment_sql};
|
||||
pub use util::{oracle_new_object_reference, sqlserver_unicode_string_literal};
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
use column_format::strip_inherited_mysql_column_charsets;
|
||||
use columns::{build_column_sql, validate_primary_key_change_scope};
|
||||
use comments::build_table_comment_sql;
|
||||
use foreign_keys::build_foreign_key_sql;
|
||||
use indexes::build_index_sql;
|
||||
use mysql_engine::{build_mysql_engine_change_sql, validate_mysql_engine};
|
||||
use triggers::build_trigger_sql;
|
||||
use validation::validate_concurrent_index_scope;
|
||||
pub use validation::validate_draft;
|
||||
|
||||
pub fn build_table_structure_change_sql(mut options: TableStructureSqlOptions) -> TableStructureSqlResult {
|
||||
let mysql_engine_errors = validate_mysql_engine(&options);
|
||||
if !mysql_engine_errors.is_empty() {
|
||||
return TableStructureSqlResult { statements: Vec::new(), warnings: mysql_engine_errors };
|
||||
}
|
||||
// GaussDB M-mode uses MySQL-compatible SQL dialect with backtick quoting.
|
||||
// Map to StructureDialect::Mysql so DDL is generated correctly.
|
||||
if options.is_gaussdb_m_mode {
|
||||
options.database_type = Some(DatabaseType::Mysql);
|
||||
}
|
||||
strip_inherited_mysql_column_charsets(&mut options);
|
||||
let primary_key_errors = validate_primary_key_change_scope(&options);
|
||||
if !primary_key_errors.is_empty() {
|
||||
return TableStructureSqlResult { statements: Vec::new(), warnings: primary_key_errors };
|
||||
}
|
||||
// Fail closed: an unsupported concurrent-index request (existing index, or
|
||||
// partitioned parent table) must never degrade into blocking index DDL
|
||||
// behind the caller's back, so the whole plan is refused up front.
|
||||
let concurrent_errors = validate_concurrent_index_scope(&options);
|
||||
if !concurrent_errors.is_empty() {
|
||||
return TableStructureSqlResult { statements: Vec::new(), warnings: concurrent_errors };
|
||||
}
|
||||
let mut warnings = validate_draft(&options);
|
||||
let mut statements = build_column_sql(&options, &mut warnings);
|
||||
statements.extend(build_index_sql(&options, &mut warnings));
|
||||
statements.extend(build_foreign_key_sql(&options, &mut warnings));
|
||||
statements.extend(build_trigger_sql(&options, &mut warnings));
|
||||
statements.extend(build_table_comment_sql(&options, &mut warnings));
|
||||
statements.extend(build_mysql_engine_change_sql(&options));
|
||||
TableStructureSqlResult { statements, warnings }
|
||||
}
|
||||
|
||||
/// Whether this engine's structure editor can generate `COMMENT ON`/inline
|
||||
/// comment DDL for tables and columns — a DDL-generation capability, not an
|
||||
/// introspection one. The documentation collector uses it as a heuristic for
|
||||
/// "can this engine report comments at all", but the two questions can
|
||||
/// diverge: IRIS supports `%DESCRIPTION` while *defining* a table or column,
|
||||
/// but DBX's editor cannot ALTER an existing one, so this returns `false`
|
||||
/// for IRIS even though IRIS still reports descriptions on introspection.
|
||||
/// Callers using this as an introspection signal must corroborate it against
|
||||
/// what was actually collected rather than trust the flag alone.
|
||||
pub fn supports_comments(database_type: crate::models::connection::DatabaseType) -> bool {
|
||||
dialect::capabilities_for(Some(database_type), None).comment
|
||||
}
|
||||
|
||||
/// Whether this engine's structure editor can generate foreign key DDL — a
|
||||
/// DDL-generation capability, not an introspection one, used here as a
|
||||
/// heuristic for "does this engine report foreign key metadata at all".
|
||||
/// Engines like ClickHouse and Doris genuinely report none, so their ER
|
||||
/// diagrams have no edges by necessity rather than by accident, but a
|
||||
/// mismatch analogous to the IRIS comment case is possible for any future
|
||||
/// engine where DDL support and introspection support diverge — callers
|
||||
/// should corroborate against what was actually collected.
|
||||
pub fn supports_foreign_keys(database_type: crate::models::connection::DatabaseType) -> bool {
|
||||
dialect::capabilities_for(Some(database_type), None).foreign_key
|
||||
}
|
||||
|
||||
/// Canonical display label for a database engine (e.g. `postgres`,
|
||||
/// `sqlserver`, `mongodb`) — the same identifier already used throughout
|
||||
/// this module's own warning prose. The documentation collector uses it for
|
||||
/// `database_type` and its engine-capability warnings instead of the Rust
|
||||
/// `Debug` spelling (`Postgres`, `SqlServer`, `MongoDb`), which is an
|
||||
/// implementation detail, not something a consumer like dbdocs/dbdiagram
|
||||
/// should key off.
|
||||
pub fn database_type_label(database_type: crate::models::connection::DatabaseType) -> String {
|
||||
dialect::database_label(Some(database_type))
|
||||
}
|
||||
|
||||
pub use indexes::has_existing_index_change;
|
||||
pub use util::{clean, normalize_default, original_comment, original_default};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,535 @@
|
||||
use super::dialect::{is_oracle_like, StructureDialect};
|
||||
use super::types::{EditableStructureColumn, TableStructureSqlOptions};
|
||||
use super::util::{clean, format_default_for_sql, normalize_default, quote_ident, quote_string};
|
||||
|
||||
/// Drops the `CHARACTER SET` / `COLLATE` inputs of MySQL columns that merely
|
||||
/// inherit the table's default collation, so the generated DDL does not spell
|
||||
/// out clauses the server would apply anyway.
|
||||
///
|
||||
/// MySQL reports the *effective* collation of every character column and never
|
||||
/// records whether it was written out explicitly, so "equals the table default"
|
||||
/// is the only signal available. Omitting the clauses is equivalent to keeping
|
||||
/// them: a column definition without `CHARACTER SET` takes the table default,
|
||||
/// which is exactly the value being dropped here. This runs on the DDL inputs
|
||||
/// only — introspection (`get_columns`) still reports the real values so the
|
||||
/// structure editor can show the column's current charset and collation.
|
||||
///
|
||||
/// The original snapshot is normalized alongside the draft so that an untouched
|
||||
/// column does not register as a charset change and trigger a needless `MODIFY`.
|
||||
pub(super) fn strip_inherited_mysql_column_charsets(options: &mut TableStructureSqlOptions) {
|
||||
let Some(table_collation) = options.table_collation.as_deref().map(str::trim).filter(|value| !value.is_empty())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let inherits = |collation: &str| collation.trim().eq_ignore_ascii_case(table_collation);
|
||||
for column in &mut options.columns {
|
||||
if inherits(&column.collation) {
|
||||
column.character_set = String::new();
|
||||
column.collation = String::new();
|
||||
}
|
||||
if let Some(original) = column.original.as_mut() {
|
||||
if original.collation.as_deref().is_some_and(inherits) {
|
||||
original.character_set = None;
|
||||
original.collation = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn column_definition(dialect: StructureDialect, column: &EditableStructureColumn) -> String {
|
||||
let data_type = column_data_type(dialect, column);
|
||||
let mut parts = vec![quote_ident(dialect, &column.name), data_type];
|
||||
// QuestDB SQL Syntax: ```ALTER TABLE tableName ADD COLUMN [IF NOT EXISTS] columnName typeDef```
|
||||
if dialect == StructureDialect::Questdb {
|
||||
parts.insert(0, "IF NOT EXISTS".to_string());
|
||||
return parts.join(" ");
|
||||
}
|
||||
// CHARACTER SET / COLLATE — MySQL character data types only
|
||||
if dialect == StructureDialect::Mysql && is_mysql_character_data_type(&column.data_type) {
|
||||
if !column.character_set.trim().is_empty() {
|
||||
parts.push(format!("CHARACTER SET {}", quote_ident(dialect, &column.character_set)));
|
||||
}
|
||||
if !column.collation.trim().is_empty() {
|
||||
parts.push(format!("COLLATE {}", quote_ident(dialect, &column.collation)));
|
||||
}
|
||||
}
|
||||
let mysql_generated_clause =
|
||||
(dialect == StructureDialect::Mysql).then(|| original_mysql_generated_clause(column)).flatten();
|
||||
if let Some(generated_clause) = mysql_generated_clause.as_ref() {
|
||||
parts.push(generated_clause.clone());
|
||||
}
|
||||
if !column.is_nullable && !is_oracle_like(dialect) && dialect != StructureDialect::ClickHouse {
|
||||
parts.push("NOT NULL".to_string());
|
||||
} else if column.is_nullable
|
||||
&& dialect == StructureDialect::Mysql
|
||||
&& mysql_generated_clause.is_none()
|
||||
&& is_mysql_timestamp_type(&column.data_type)
|
||||
{
|
||||
parts.push("NULL".to_string());
|
||||
}
|
||||
if mysql_generated_clause.is_none() {
|
||||
if let Some(extra_clause) = column_extra_clause(dialect, column) {
|
||||
parts.push(extra_clause);
|
||||
}
|
||||
}
|
||||
let default_value = normalize_default(Some(&column.default_value));
|
||||
if mysql_generated_clause.is_none() && !default_value.is_empty() {
|
||||
parts.push(format!("DEFAULT {}", format_default_for_sql(dialect, &column.data_type, &default_value)));
|
||||
}
|
||||
if mysql_generated_clause.is_none() {
|
||||
if let Some(on_update) = column.extra.as_ref().and_then(|e| e.on_update_current_timestamp).filter(|v| *v) {
|
||||
if on_update && dialect == StructureDialect::Mysql {
|
||||
parts.push(mysql_on_update_current_timestamp_clause(&column.data_type));
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(dialect, StructureDialect::Mysql | StructureDialect::GaussdbM | StructureDialect::Doris)
|
||||
&& !clean(&column.comment).is_empty()
|
||||
{
|
||||
parts.push(format!("COMMENT {}", quote_string(&clean(&column.comment))));
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
pub(super) fn original_mysql_generated_clause(column: &EditableStructureColumn) -> Option<String> {
|
||||
let extra = column.original.as_ref()?.extra.as_deref()?.trim();
|
||||
let normalized = extra.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
(normalized.starts_with("generated ") && normalized.contains(" as (")).then(|| extra.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn original_is_mysql_generated_column(column: &EditableStructureColumn) -> bool {
|
||||
let Some(extra) = column.original.as_ref().and_then(|original| original.extra.as_deref()) else {
|
||||
return false;
|
||||
};
|
||||
let mut tokens = extra.split_whitespace();
|
||||
let first = tokens.next();
|
||||
first.is_some_and(|token| token.eq_ignore_ascii_case("generated"))
|
||||
|| matches!(first, Some(token) if token.eq_ignore_ascii_case("virtual")
|
||||
|| token.eq_ignore_ascii_case("stored")
|
||||
|| token.eq_ignore_ascii_case("persistent"))
|
||||
&& tokens.next().is_some_and(|token| token.eq_ignore_ascii_case("generated"))
|
||||
}
|
||||
|
||||
pub(super) fn column_extra_clause(dialect: StructureDialect, column: &EditableStructureColumn) -> Option<String> {
|
||||
let extra = column.extra.as_ref()?;
|
||||
match dialect {
|
||||
StructureDialect::Sqlite => {
|
||||
if extra.auto_increment.unwrap_or(false) && column.is_primary_key {
|
||||
Some("AUTOINCREMENT".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
StructureDialect::Mysql => {
|
||||
let mut clauses = Vec::new();
|
||||
if extra.auto_increment.unwrap_or(false) {
|
||||
clauses.push("AUTO_INCREMENT".to_string());
|
||||
}
|
||||
if clauses.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(clauses.join(" "))
|
||||
}
|
||||
}
|
||||
StructureDialect::Postgres | StructureDialect::H2 => {
|
||||
if let Some(identity) = &extra.identity {
|
||||
let generation = identity.generation.as_deref().unwrap_or("BY DEFAULT");
|
||||
let mut clause = format!("GENERATED {generation} AS IDENTITY");
|
||||
if identity.seed.is_some() || identity.increment.is_some() {
|
||||
let start = identity.seed.unwrap_or(1);
|
||||
let inc = identity.increment.unwrap_or(1);
|
||||
clause.push_str(&format!(" (START WITH {start} INCREMENT BY {inc})"));
|
||||
}
|
||||
Some(clause)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
StructureDialect::SqlServer => {
|
||||
if extra.auto_increment.unwrap_or(false) || extra.identity.is_some() {
|
||||
let seed = extra.identity.as_ref().and_then(|i| i.seed).unwrap_or(1);
|
||||
let increment = extra.identity.as_ref().and_then(|i| i.increment).unwrap_or(1);
|
||||
Some(format!("IDENTITY({seed}, {increment})"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
StructureDialect::Dameng => {
|
||||
if extra.auto_increment.unwrap_or(false) || extra.identity.is_some() {
|
||||
let seed = extra.identity.as_ref().and_then(|i| i.seed).unwrap_or(1);
|
||||
let increment = extra.identity.as_ref().and_then(|i| i.increment).unwrap_or(1);
|
||||
Some(format!("IDENTITY({seed}, {increment})"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn has_dameng_identity(column: &EditableStructureColumn) -> bool {
|
||||
column.extra.as_ref().is_some_and(|extra| extra.auto_increment.unwrap_or(false) || extra.identity.is_some())
|
||||
}
|
||||
|
||||
pub(super) fn is_dameng_identity_compatible_type(data_type: &str) -> bool {
|
||||
let trimmed = data_type.trim();
|
||||
let (base_type, params) = match trimmed.find('(') {
|
||||
Some(open_index) => {
|
||||
let close_index = trimmed.rfind(')').unwrap_or(trimmed.len());
|
||||
(&trimmed[..open_index], trimmed.get(open_index + 1..close_index).unwrap_or(""))
|
||||
}
|
||||
None => (trimmed, ""),
|
||||
};
|
||||
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
if matches!(normalized.as_str(), "tinyint" | "smallint" | "int" | "integer" | "bigint") {
|
||||
return true;
|
||||
}
|
||||
if !matches!(normalized.as_str(), "number" | "numeric" | "decimal" | "dec") {
|
||||
return false;
|
||||
}
|
||||
let normalized_params = params.split_whitespace().collect::<String>();
|
||||
if normalized_params.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let parts = normalized_params.split(',').collect::<Vec<_>>();
|
||||
match parts.as_slice() {
|
||||
[precision] => precision.parse::<u32>().is_ok(),
|
||||
[precision, scale] => precision.parse::<u32>().is_ok() && *scale == "0",
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn column_data_type(dialect: StructureDialect, column: &EditableStructureColumn) -> String {
|
||||
if dialect == StructureDialect::ClickHouse {
|
||||
return clickhouse_column_type(column);
|
||||
}
|
||||
if dialect == StructureDialect::ManticoreSearch {
|
||||
return manticore_column_type(column);
|
||||
}
|
||||
if dialect == StructureDialect::Questdb {
|
||||
return questdb_column_type(column);
|
||||
}
|
||||
let normalized = normalize_column_data_type(dialect, &column.data_type);
|
||||
// Dameng only recognizes its built-in type keywords in the canonical
|
||||
// upper-case form: a lower-case `varchar(50)` in DDL is stored as a
|
||||
// USER-DEFINED type instead of VARCHAR (issue #7343). Uppercase after the
|
||||
// dialect-specific normalization so its rewrites still apply.
|
||||
if dialect == StructureDialect::Dameng {
|
||||
return normalized.to_uppercase();
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn manticore_column_type(column: &EditableStructureColumn) -> String {
|
||||
let data_type = normalize_column_data_type(StructureDialect::ManticoreSearch, &column.data_type);
|
||||
let normalized = data_type.trim().to_ascii_lowercase();
|
||||
if normalized == "json" {
|
||||
let Some(extra) = column.extra.as_ref() else {
|
||||
return data_type;
|
||||
};
|
||||
if extra.manticore_secondary_index.unwrap_or(false) {
|
||||
return format!("{data_type} secondary_index='1'");
|
||||
}
|
||||
return data_type;
|
||||
}
|
||||
if !matches!(normalized.as_str(), "text" | "string") {
|
||||
return data_type;
|
||||
}
|
||||
|
||||
let Some(extra) = column.extra.as_ref() else {
|
||||
return data_type;
|
||||
};
|
||||
let mut parts = vec![data_type];
|
||||
if extra.manticore_stored.unwrap_or(false) {
|
||||
parts.push("stored".to_string());
|
||||
}
|
||||
if extra.manticore_attribute.unwrap_or(false) {
|
||||
parts.push("attribute".to_string());
|
||||
}
|
||||
if extra.manticore_indexed.unwrap_or(false) {
|
||||
parts.push("indexed".to_string());
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
pub(super) fn normalize_column_data_type(dialect: StructureDialect, data_type: &str) -> String {
|
||||
let trimmed = data_type.trim();
|
||||
let Some(open_index) = trimmed.find('(') else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
if !trimmed.ends_with(')') {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
|
||||
let base_type = trimmed[..open_index].trim();
|
||||
let params = trimmed[open_index + 1..trimmed.len() - 1].trim();
|
||||
if base_type.is_empty() || params.is_empty() {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
|
||||
if dialect == StructureDialect::Mysql {
|
||||
if let Some(normalized) = normalize_mysql_numeric_attribute_type(base_type, params) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if is_oracle_like(dialect) && is_oracle_lengthless_type(base_type) {
|
||||
// Dameng/Oracle integer aliases do not accept MySQL-style display widths like INTEGER(11).
|
||||
return base_type.to_string();
|
||||
}
|
||||
|
||||
if dialect == StructureDialect::SqlServer && is_sqlserver_lengthless_type(base_type) {
|
||||
// SQL Server exact integer and legacy LOB types do not accept MySQL-style display widths.
|
||||
return base_type.to_string();
|
||||
}
|
||||
|
||||
if dialect == StructureDialect::SqlServer && is_sqlserver_float_with_scale(base_type, params) {
|
||||
// SQL Server float only accepts a single integer mantissa bit count (1–53),
|
||||
// not comma-separated precision/scale like float(10,2).
|
||||
return base_type.to_string();
|
||||
}
|
||||
|
||||
if is_temporal_precision_type(dialect, base_type) {
|
||||
return if is_valid_temporal_precision(params, dialect) {
|
||||
format!("{base_type}({params})")
|
||||
} else {
|
||||
base_type.to_string()
|
||||
};
|
||||
}
|
||||
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
fn is_oracle_lengthless_type(base_type: &str) -> bool {
|
||||
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
matches!(
|
||||
normalized.as_str(),
|
||||
"binary_double"
|
||||
| "binary_float"
|
||||
| "bigint"
|
||||
| "boolean"
|
||||
| "bool"
|
||||
| "byte"
|
||||
| "date"
|
||||
| "double"
|
||||
| "double precision"
|
||||
| "float"
|
||||
| "integer"
|
||||
| "int"
|
||||
| "long"
|
||||
| "long raw"
|
||||
| "nclob"
|
||||
| "real"
|
||||
| "smallint"
|
||||
| "text"
|
||||
| "tinyint"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_sqlserver_lengthless_type(base_type: &str) -> bool {
|
||||
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
matches!(
|
||||
normalized.as_str(),
|
||||
"bigint"
|
||||
| "bit"
|
||||
| "date"
|
||||
| "datetime"
|
||||
| "image"
|
||||
| "int"
|
||||
| "integer"
|
||||
| "money"
|
||||
| "ntext"
|
||||
| "real"
|
||||
| "smalldatetime"
|
||||
| "smallint"
|
||||
| "smallmoney"
|
||||
| "sql_variant"
|
||||
| "text"
|
||||
| "timestamp"
|
||||
| "tinyint"
|
||||
| "uniqueidentifier"
|
||||
| "xml"
|
||||
)
|
||||
}
|
||||
|
||||
/// SQL Server `float` accepts a single integer mantissa bit count (1–53)
|
||||
/// but rejects comma-separated precision/scale like `float(10,2)`.
|
||||
fn is_sqlserver_float_with_scale(base_type: &str, params: &str) -> bool {
|
||||
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
normalized == "float" && params.contains(',')
|
||||
}
|
||||
|
||||
fn normalize_mysql_numeric_attribute_type(base_type: &str, params: &str) -> Option<String> {
|
||||
let mut parts: Vec<&str> = base_type.split_whitespace().collect();
|
||||
let type_name = parts.first().copied()?.to_ascii_lowercase();
|
||||
if !matches!(
|
||||
type_name.as_str(),
|
||||
"tinyint"
|
||||
| "smallint"
|
||||
| "mediumint"
|
||||
| "int"
|
||||
| "integer"
|
||||
| "bigint"
|
||||
| "real"
|
||||
| "double"
|
||||
| "float"
|
||||
| "decimal"
|
||||
| "numeric"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let split_index = parts.iter().position(|part| {
|
||||
let normalized = part.to_ascii_lowercase();
|
||||
matches!(normalized.as_str(), "signed" | "unsigned" | "zerofill")
|
||||
})?;
|
||||
if !parts[split_index..].iter().all(|part| {
|
||||
let normalized = part.to_ascii_lowercase();
|
||||
matches!(normalized.as_str(), "signed" | "unsigned" | "zerofill")
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let attrs = parts.split_off(split_index).join(" ");
|
||||
let base = parts.join(" ");
|
||||
Some(format!("{base}({params}) {attrs}"))
|
||||
}
|
||||
|
||||
pub(super) fn is_temporal_precision_type(dialect: StructureDialect, base_type: &str) -> bool {
|
||||
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
match dialect {
|
||||
StructureDialect::Mysql => matches!(normalized.as_str(), "time" | "datetime" | "timestamp"),
|
||||
StructureDialect::Postgres => matches!(
|
||||
normalized.as_str(),
|
||||
"time"
|
||||
| "time without time zone"
|
||||
| "time with time zone"
|
||||
| "timestamp"
|
||||
| "timestamp without time zone"
|
||||
| "timestamp with time zone"
|
||||
),
|
||||
StructureDialect::SqlServer => matches!(normalized.as_str(), "time" | "datetime2" | "datetimeoffset"),
|
||||
StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Oscar => {
|
||||
matches!(normalized.as_str(), "timestamp" | "timestamp with time zone" | "timestamp with local time zone")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_valid_temporal_precision(params: &str, dialect: StructureDialect) -> bool {
|
||||
let Ok(value) = params.parse::<u8>() else {
|
||||
return false;
|
||||
};
|
||||
let max = if matches!(dialect, StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Oscar) {
|
||||
9
|
||||
} else {
|
||||
6
|
||||
};
|
||||
value <= max && params == value.to_string()
|
||||
}
|
||||
|
||||
/// Fractional-seconds precision of a MySQL temporal column type, e.g. `3` for
|
||||
/// `datetime(3)`. MySQL requires every `CURRENT_TIMESTAMP` in a column
|
||||
/// definition to carry the same precision the column type declares: a
|
||||
/// `datetime(3) ... DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`
|
||||
/// definition is rejected with `ERROR 1067 (42000): Invalid default value`
|
||||
/// while the `(3)`-suffixed clauses succeed. The editor model keeps only an
|
||||
/// on-update flag, so the precision has to be recovered from the data type.
|
||||
/// Non-temporal types and invalid precisions fall back to `None` (no suffix).
|
||||
pub(super) fn mysql_temporal_precision(data_type: &str) -> Option<String> {
|
||||
let trimmed = data_type.trim();
|
||||
let open_index = trimmed.find('(')?;
|
||||
if !trimmed.ends_with(')') {
|
||||
return None;
|
||||
}
|
||||
let base_type = trimmed[..open_index].trim();
|
||||
let params = trimmed[open_index + 1..trimmed.len() - 1].trim();
|
||||
if !is_temporal_precision_type(StructureDialect::Mysql, base_type)
|
||||
|| !is_valid_temporal_precision(params, StructureDialect::Mysql)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(params.to_string())
|
||||
}
|
||||
|
||||
/// `ON UPDATE CURRENT_TIMESTAMP` clause for a MySQL column, carrying the
|
||||
/// column's temporal precision (if any) so the clause matches the type.
|
||||
pub(super) fn mysql_on_update_current_timestamp_clause(data_type: &str) -> String {
|
||||
match mysql_temporal_precision(data_type) {
|
||||
Some(fsp) => format!("ON UPDATE CURRENT_TIMESTAMP({fsp})"),
|
||||
None => "ON UPDATE CURRENT_TIMESTAMP".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn clickhouse_column_type(column: &EditableStructureColumn) -> String {
|
||||
let data_type = column.data_type.trim();
|
||||
if column.is_nullable {
|
||||
if data_type.to_ascii_lowercase().starts_with("nullable") {
|
||||
data_type.to_string()
|
||||
} else {
|
||||
format!("Nullable({data_type})")
|
||||
}
|
||||
} else {
|
||||
unwrap_clickhouse_nullable_type(data_type)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn unwrap_clickhouse_nullable_type(data_type: &str) -> String {
|
||||
let trimmed = data_type.trim();
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if lower.starts_with("nullable(") && trimmed.ends_with(')') {
|
||||
trimmed[trimmed.find('(').unwrap_or(0) + 1..trimmed.len() - 1].trim().to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// QuestDB 类型处理
|
||||
/// geohash, decimal 这2种类型才能带()
|
||||
pub(super) fn questdb_column_type(column: &EditableStructureColumn) -> String {
|
||||
let data_type = column.data_type.trim().to_ascii_lowercase();
|
||||
let length_types: [&str; 2] = ["geohash", "decimal"];
|
||||
match data_type.find('(') {
|
||||
Some(pos) => {
|
||||
let base_type = &data_type[..pos];
|
||||
let params = &data_type[pos..];
|
||||
if length_types.contains(&base_type) {
|
||||
format!("{}{}", base_type, params)
|
||||
} else {
|
||||
base_type.to_string()
|
||||
}
|
||||
}
|
||||
None => data_type,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when the data type is a MySQL character/string type that accepts
|
||||
/// `CHARACTER SET` and `COLLATE` clauses in column definitions.
|
||||
///
|
||||
/// Character types that support per-column charset/collation:
|
||||
/// `char`, `varchar`, `tinytext`, `text`, `mediumtext`, `longtext`, `enum`, `set`
|
||||
///
|
||||
/// Numeric, temporal, spatial, binary/blob, and JSON types do NOT support these clauses.
|
||||
pub(super) fn is_mysql_character_data_type(data_type: &str) -> bool {
|
||||
let trimmed = data_type.trim();
|
||||
let base_type = match trimmed.find('(') {
|
||||
Some(open_index) => trimmed[..open_index].trim(),
|
||||
None => trimmed,
|
||||
};
|
||||
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
matches!(normalized.as_str(), "char" | "varchar" | "tinytext" | "text" | "mediumtext" | "longtext" | "enum" | "set")
|
||||
}
|
||||
|
||||
/// MySQL silently rewrites a nullable `TIMESTAMP` column to `NOT NULL` when the
|
||||
/// generated DDL omits an explicit `NULL` keyword — regardless of whether a
|
||||
/// `DEFAULT` is present. With the (still common) `explicit_defaults_for_timestamp`
|
||||
/// server default of `OFF`, this can outright fail with `ERROR 1067 (42000):
|
||||
/// Invalid default value` for any non-first TIMESTAMP column. `DATETIME` is not
|
||||
/// affected and must not be touched.
|
||||
pub(super) fn is_mysql_timestamp_type(data_type: &str) -> bool {
|
||||
let trimmed = data_type.trim();
|
||||
let base_type = match trimmed.find('(') {
|
||||
Some(open_index) => trimmed[..open_index].trim(),
|
||||
None => trimmed,
|
||||
};
|
||||
base_type.eq_ignore_ascii_case("timestamp")
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
use super::column_alter::{
|
||||
build_clickhouse_existing_column_sql, build_dameng_existing_column_sql, build_doris_existing_column_sql,
|
||||
build_h2_existing_column_sql, build_informix_existing_column_sql, build_iris_existing_column_sql,
|
||||
build_mysql_existing_column_clause, build_oracle_like_existing_column_sql, build_oscar_existing_column_sql,
|
||||
build_postgres_existing_column_sql, build_questdb_existing_column_sql, build_sqlite_existing_column_sql,
|
||||
build_sqlserver_existing_column_sql, build_xugu_existing_column_sql, dameng_drops_identity,
|
||||
has_column_extra_change, has_existing_column_attribute_change, validate_dameng_existing_identity_change,
|
||||
};
|
||||
use super::column_format::{
|
||||
column_definition, has_dameng_identity, is_dameng_identity_compatible_type, is_mysql_character_data_type,
|
||||
original_is_mysql_generated_column, original_mysql_generated_clause,
|
||||
};
|
||||
use super::comments::build_sqlserver_column_comment_sql_for_profile;
|
||||
use super::dialect::{capabilities_for, database_label, is_oracle_like, StructureDialect};
|
||||
use super::indexes::has_existing_index_change;
|
||||
use super::types::{EditableStructureColumn, TableStructureSqlOptions};
|
||||
use super::util::{
|
||||
clean, is_protected_manticore_id_column, normalize_default, original_comment, original_default, qualified_table,
|
||||
quote_ident, quote_string,
|
||||
};
|
||||
use crate::models::connection::DatabaseType;
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub(super) fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mut Vec<String>) -> Vec<String> {
|
||||
let capabilities = capabilities_for(options.database_type, options.driver_profile.as_deref());
|
||||
let dialect = capabilities.dialect;
|
||||
let table = qualified_table(dialect, options.schema.as_deref(), &options.table_name);
|
||||
let database_label = database_label(options.database_type);
|
||||
let active_columns: Vec<_> = options.columns.iter().filter(|column| !column.marked_for_drop).collect();
|
||||
if dialect == StructureDialect::Dameng {
|
||||
let identity_columns: Vec<_> = active_columns.iter().filter(|column| has_dameng_identity(column)).collect();
|
||||
if identity_columns.len() > 1
|
||||
|| identity_columns.iter().any(|column| {
|
||||
column.extra.as_ref().and_then(|extra| extra.identity.as_ref()).and_then(|identity| identity.increment)
|
||||
== Some(0)
|
||||
})
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
let mut valid_identity_changes = true;
|
||||
for column in &active_columns {
|
||||
if has_dameng_identity(column) && !is_dameng_identity_compatible_type(&column.data_type) {
|
||||
warnings.push(format!(
|
||||
"Dameng identity column \"{}\" must use tinyint, smallint, int, integer, bigint, number, numeric, or decimal/dec with scale 0.",
|
||||
column.name
|
||||
));
|
||||
valid_identity_changes = false;
|
||||
} else if column.original.is_some() && !validate_dameng_existing_identity_change(column, warnings) {
|
||||
valid_identity_changes = false;
|
||||
}
|
||||
}
|
||||
if !valid_identity_changes {
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
if is_oracle_like(dialect)
|
||||
&& active_columns.is_empty()
|
||||
&& options.columns.iter().any(|column| column.marked_for_drop)
|
||||
{
|
||||
warnings.push("Oracle does not allow dropping all columns from a table. Keep at least one column or drop the table instead.".to_string());
|
||||
return Vec::new();
|
||||
}
|
||||
let has_original_column_positions = active_columns.iter().any(|column| column.original_position.is_some());
|
||||
let mut simulated_column_order =
|
||||
if has_original_column_positions { original_active_column_order(&active_columns) } else { Vec::new() };
|
||||
// Pre-compute the minimal set of existing columns that really need an explicit move.
|
||||
// For MySQL/ClickHouse we keep the largest already-ordered subset in place and only
|
||||
// emit FIRST/AFTER SQL for columns outside that subset.
|
||||
let reordered_existing_column_ids =
|
||||
if has_original_column_positions && matches!(dialect, StructureDialect::Mysql | StructureDialect::ClickHouse) {
|
||||
planned_existing_column_move_ids(&active_columns)
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
let mysql_primary_key_change = if options.database_type == Some(DatabaseType::Mysql) && !options.is_gaussdb_m_mode {
|
||||
primary_key_change(options)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// MySQL validates AUTO_INCREMENT against the statement's final index layout. Keep the
|
||||
// dependent column and key clauses together so no implicit commit exposes an invalid middle state.
|
||||
let mysql_coalesced_primary_key_change = mysql_primary_key_change
|
||||
.as_ref()
|
||||
.filter(|change| options.columns.iter().any(|column| mysql_auto_increment_touches_primary_key(column, change)));
|
||||
let mut mysql_primary_key_column_clauses = Vec::new();
|
||||
let mut statements = Vec::new();
|
||||
// DM8 owns identity at table level, so remove the old identity before any per-column ADD,
|
||||
// even when the target column appears earlier in the submitted draft.
|
||||
if dialect == StructureDialect::Dameng
|
||||
&& options.columns.iter().any(|column| !column.marked_for_drop && dameng_drops_identity(column))
|
||||
{
|
||||
statements.push(format!("ALTER TABLE {table} DROP IDENTITY;"));
|
||||
}
|
||||
|
||||
for column in &options.columns {
|
||||
if column.marked_for_drop {
|
||||
let Some(original) = &column.original else {
|
||||
continue;
|
||||
};
|
||||
if !capabilities.drop_column {
|
||||
warnings.push(format!("Dropping columns is not supported for {database_label} from this editor."));
|
||||
continue;
|
||||
}
|
||||
if original.is_primary_key {
|
||||
warnings.push(format!("Primary key column \"{}\" cannot be dropped from this editor.", original.name));
|
||||
continue;
|
||||
}
|
||||
if is_protected_manticore_id_column(dialect, &original.name) {
|
||||
warnings.push("Manticore Search id column cannot be dropped from this editor.".to_string());
|
||||
continue;
|
||||
}
|
||||
statements.push(build_drop_column_sql(dialect, &table, &original.name));
|
||||
continue;
|
||||
}
|
||||
|
||||
let active_index = active_columns.iter().position(|active| active.id == column.id).unwrap_or(0);
|
||||
let position_clause = if has_original_column_positions {
|
||||
column_position_clause(dialect, &active_columns, active_index)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let desired_previous_column_id = active_previous_column_id(&active_columns, active_index);
|
||||
// A position change only matters when this column is part of the planned move set
|
||||
// and its predecessor still differs in the simulated order.
|
||||
let has_position_change = has_original_column_positions
|
||||
&& matches!(dialect, StructureDialect::Mysql | StructureDialect::ClickHouse)
|
||||
&& reordered_existing_column_ids.contains(&column.id)
|
||||
&& column.original.is_some()
|
||||
&& column.original_position.is_some()
|
||||
&& simulated_column_position_changed(&simulated_column_order, &column.id, desired_previous_column_id);
|
||||
|
||||
if column.original.is_none() {
|
||||
if !capabilities.add_column {
|
||||
warnings.push(format!("Adding columns is not supported for {database_label} from this editor."));
|
||||
continue;
|
||||
}
|
||||
if dialect == StructureDialect::SqlServer
|
||||
&& has_sqlserver_identity(column)
|
||||
&& !is_sqlserver_identity_compatible_type(&column.data_type)
|
||||
{
|
||||
warnings.push(format!(
|
||||
"SQL Server identity column \"{}\" must use tinyint, smallint, int, bigint, or decimal/numeric with scale 0.",
|
||||
column.name
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if !capabilities.comment && !clean(&column.comment).is_empty() {
|
||||
warnings.push(format!(
|
||||
"Column comments are not supported for {database_label} from this editor; the comment for \"{}\" was ignored.",
|
||||
column.name
|
||||
));
|
||||
}
|
||||
if mysql_coalesced_primary_key_change
|
||||
.is_some_and(|change| mysql_auto_increment_touches_primary_key(column, change))
|
||||
{
|
||||
mysql_primary_key_column_clauses.push(format!(
|
||||
"ADD COLUMN {}{position_clause}",
|
||||
column_definition(StructureDialect::Mysql, column)
|
||||
));
|
||||
} else {
|
||||
statements.extend(build_add_column_sql(
|
||||
dialect,
|
||||
options.database_type,
|
||||
capabilities.comment,
|
||||
&table,
|
||||
column,
|
||||
&position_clause,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
}
|
||||
if has_original_column_positions
|
||||
&& matches!(dialect, StructureDialect::Mysql | StructureDialect::ClickHouse)
|
||||
{
|
||||
apply_simulated_column_position(&mut simulated_column_order, &column.id, desired_previous_column_id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// A column handing its primary key membership over to another column keeps its
|
||||
// AUTO_INCREMENT flag in the submitted draft, which MySQL rejects (ERROR 1075) once
|
||||
// the column is no longer keyed. Rewrite it inside the same coalesced ALTER even
|
||||
// though the draft reports no change on the column itself.
|
||||
let clears_orphaned_auto_increment = mysql_coalesced_primary_key_change
|
||||
.is_some_and(|change| mysql_orphans_auto_increment_column(options, column, change));
|
||||
|
||||
if !has_existing_column_attribute_change(column)
|
||||
&& !has_column_extra_change(column)
|
||||
&& !has_position_change
|
||||
&& !clears_orphaned_auto_increment
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let original = column.original.as_ref().unwrap();
|
||||
let has_rename = column.name != original.name;
|
||||
let has_comment_change = clean(&column.comment) != original_comment(column);
|
||||
let has_attribute_change = column.data_type.trim() != original.data_type.trim()
|
||||
|| column.is_nullable != original.is_nullable
|
||||
|| normalize_default(Some(&column.default_value)) != original_default(column)
|
||||
|| (has_comment_change && capabilities.comment)
|
||||
|| (is_mysql_character_data_type(&column.data_type)
|
||||
&& (column.character_set.trim() != original.character_set.as_deref().unwrap_or("")
|
||||
|| column.collation.trim() != original.collation.as_deref().unwrap_or("")))
|
||||
|| has_column_extra_change(column);
|
||||
if has_comment_change && !capabilities.comment {
|
||||
warnings.push(format!(
|
||||
"Column comments are not supported for {database_label} from this editor; the comment change for \"{}\" was ignored.",
|
||||
original.name
|
||||
));
|
||||
}
|
||||
if has_position_change && !capabilities.reorder_column {
|
||||
warnings.push(format!("Reordering columns is not supported for {database_label} from this editor."));
|
||||
}
|
||||
if has_rename && !capabilities.rename_column {
|
||||
warnings.push(format!("Renaming columns is not supported for {database_label} from this editor."));
|
||||
}
|
||||
if has_attribute_change && !capabilities.alter_existing_column && dialect != StructureDialect::Sqlite {
|
||||
warnings.push(format!("Editing existing columns is not supported for {database_label} yet."));
|
||||
}
|
||||
if (has_position_change && !capabilities.reorder_column)
|
||||
|| (has_rename && !capabilities.rename_column)
|
||||
|| (has_attribute_change && !capabilities.alter_existing_column && dialect != StructureDialect::Sqlite)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if dialect == StructureDialect::Mysql
|
||||
&& original_is_mysql_generated_column(column)
|
||||
&& original_mysql_generated_clause(column).is_none()
|
||||
{
|
||||
warnings.push(format!(
|
||||
"Column \"{}\" is generated, but its generation expression could not be loaded; no ALTER statement was generated to avoid removing the generated-column definition.",
|
||||
original.name
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if !has_rename && !has_attribute_change && !has_position_change && !clears_orphaned_auto_increment {
|
||||
continue;
|
||||
}
|
||||
|
||||
match dialect {
|
||||
StructureDialect::Mysql => {
|
||||
let rewritten;
|
||||
let effective_column = if clears_orphaned_auto_increment {
|
||||
rewritten = without_auto_increment(column);
|
||||
&rewritten
|
||||
} else {
|
||||
column
|
||||
};
|
||||
let clause = build_mysql_existing_column_clause(
|
||||
effective_column,
|
||||
if has_position_change { &position_clause } else { "" },
|
||||
);
|
||||
if mysql_coalesced_primary_key_change
|
||||
.is_some_and(|change| mysql_auto_increment_touches_primary_key(column, change))
|
||||
{
|
||||
mysql_primary_key_column_clauses.push(clause);
|
||||
} else {
|
||||
statements.push(format!("ALTER TABLE {table} {clause};"));
|
||||
}
|
||||
}
|
||||
StructureDialect::Doris => statements.extend(build_doris_existing_column_sql(&table, column, "")),
|
||||
StructureDialect::Postgres => statements.extend(build_postgres_existing_column_sql(&table, column)),
|
||||
StructureDialect::Oracle => {
|
||||
if options.database_type == Some(crate::models::connection::DatabaseType::Iris) {
|
||||
statements.extend(build_iris_existing_column_sql(&table, column));
|
||||
} else if options.database_type == Some(crate::models::connection::DatabaseType::Xugu) {
|
||||
statements.extend(build_xugu_existing_column_sql(&table, column));
|
||||
} else {
|
||||
statements.extend(build_oracle_like_existing_column_sql(dialect, &table, column))
|
||||
}
|
||||
}
|
||||
StructureDialect::Dameng => {
|
||||
statements.extend(build_dameng_existing_column_sql(&table, column, false, warnings))
|
||||
}
|
||||
// 神通 MODIFY 语法与 Oracle 有差异(NULL/NOT NULL 须单独一条),用专属实现。
|
||||
StructureDialect::Oscar => statements.extend(build_oscar_existing_column_sql(dialect, &table, column)),
|
||||
StructureDialect::H2 => statements.extend(build_h2_existing_column_sql(&table, column)),
|
||||
StructureDialect::ClickHouse => statements.extend(build_clickhouse_existing_column_sql(
|
||||
&table,
|
||||
column,
|
||||
if has_position_change { &position_clause } else { "" },
|
||||
)),
|
||||
StructureDialect::Informix => statements.extend(build_informix_existing_column_sql(&table, column)),
|
||||
StructureDialect::SqlServer => statements.extend(build_sqlserver_existing_column_sql(
|
||||
&table,
|
||||
column,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
options.driver_profile.as_deref(),
|
||||
warnings,
|
||||
)),
|
||||
StructureDialect::Sqlite => statements.extend(build_sqlite_existing_column_sql(&table, column, warnings)),
|
||||
StructureDialect::Questdb => statements.extend(build_questdb_existing_column_sql(&table, column)),
|
||||
_ => warnings.push(format!("Editing existing columns is not supported for {database_label} yet.")),
|
||||
}
|
||||
if has_position_change {
|
||||
apply_simulated_column_position(&mut simulated_column_order, &column.id, desired_previous_column_id);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(change) = mysql_coalesced_primary_key_change {
|
||||
let mut clauses = Vec::new();
|
||||
if !change.old_ids.is_empty() {
|
||||
clauses.push("DROP PRIMARY KEY".to_string());
|
||||
}
|
||||
clauses.append(&mut mysql_primary_key_column_clauses);
|
||||
if !change.new_names.is_empty() {
|
||||
let pk_list = change
|
||||
.new_names
|
||||
.iter()
|
||||
.map(|name| quote_ident(StructureDialect::Mysql, name))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
clauses.push(format!("ADD PRIMARY KEY ({pk_list})"));
|
||||
}
|
||||
statements.push(format!("ALTER TABLE {table} {};", clauses.join(", ")));
|
||||
} else {
|
||||
// Keep the existing key while column DDL validates. This avoids leaving a table
|
||||
// without a key when an incoming key column cannot be made valid.
|
||||
statements.extend(build_primary_key_sql(options, dialect, &table, warnings));
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
fn was_primary_key_column(column: &EditableStructureColumn) -> bool {
|
||||
column.original.as_ref().is_some_and(|original| original.is_primary_key)
|
||||
}
|
||||
|
||||
/// Columns that should appear in `ADD PRIMARY KEY (...)` (must remain on the table).
|
||||
fn appears_in_add_primary_key(column: &EditableStructureColumn) -> bool {
|
||||
column.is_primary_key && !column.marked_for_drop
|
||||
}
|
||||
|
||||
struct PrimaryKeyChange<'a> {
|
||||
old_ids: HashSet<&'a str>,
|
||||
new_ids: HashSet<&'a str>,
|
||||
new_names: Vec<&'a str>,
|
||||
}
|
||||
|
||||
fn primary_key_change(options: &TableStructureSqlOptions) -> Option<PrimaryKeyChange<'_>> {
|
||||
if options.columns.iter().any(|column| column.marked_for_drop && was_primary_key_column(column)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let old_ids: HashSet<&str> = options
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| was_primary_key_column(column))
|
||||
.map(|column| column.id.as_str())
|
||||
.collect();
|
||||
let new_ids: HashSet<&str> = options
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| appears_in_add_primary_key(column))
|
||||
.map(|column| column.id.as_str())
|
||||
.collect();
|
||||
if old_ids == new_ids {
|
||||
return None;
|
||||
}
|
||||
|
||||
let new_names = options
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| appears_in_add_primary_key(column))
|
||||
.map(|column| column.name.as_str())
|
||||
.collect();
|
||||
Some(PrimaryKeyChange { old_ids, new_ids, new_names })
|
||||
}
|
||||
|
||||
fn unsupported_primary_key_change_warning(options: &TableStructureSqlOptions, change: &PrimaryKeyChange<'_>) -> String {
|
||||
let action = if change.old_ids.is_empty() { "Adding" } else { "Changing" };
|
||||
format!("{action} primary keys is not supported for {} from this editor.", database_label(options.database_type))
|
||||
}
|
||||
|
||||
pub(super) fn validate_primary_key_change_scope(options: &TableStructureSqlOptions) -> Vec<String> {
|
||||
if let Some(original) = options
|
||||
.columns
|
||||
.iter()
|
||||
.find(|column| column.marked_for_drop && was_primary_key_column(column))
|
||||
.and_then(|column| column.original.as_ref())
|
||||
{
|
||||
return vec![format!("Primary key column \"{}\" cannot be dropped from this editor.", original.name)];
|
||||
}
|
||||
|
||||
let Some(change) = primary_key_change(options) else { return Vec::new() };
|
||||
let capabilities = capabilities_for(options.database_type, options.driver_profile.as_deref());
|
||||
let supported =
|
||||
if change.old_ids.is_empty() { capabilities.add_primary_key } else { capabilities.alter_primary_key };
|
||||
if supported {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![unsupported_primary_key_change_warning(options, &change)]
|
||||
}
|
||||
}
|
||||
|
||||
fn mysql_auto_increment_touches_primary_key(column: &EditableStructureColumn, change: &PrimaryKeyChange<'_>) -> bool {
|
||||
column.extra.as_ref().is_some_and(|extra| extra.auto_increment.unwrap_or(false))
|
||||
&& (change.old_ids.contains(column.id.as_str()) || change.new_ids.contains(column.id.as_str()))
|
||||
}
|
||||
|
||||
/// MySQL allows AUTO_INCREMENT only on a column that leads some key, so a column handing the
|
||||
/// primary key over to another column must lose the flag in the same statement — unless an
|
||||
/// already existing index that survives this change still keys it.
|
||||
fn mysql_orphans_auto_increment_column(
|
||||
options: &TableStructureSqlOptions,
|
||||
column: &EditableStructureColumn,
|
||||
change: &PrimaryKeyChange<'_>,
|
||||
) -> bool {
|
||||
column.original.is_some()
|
||||
&& column.extra.as_ref().is_some_and(|extra| extra.auto_increment.unwrap_or(false))
|
||||
&& change.old_ids.contains(column.id.as_str())
|
||||
&& !change.new_ids.contains(column.id.as_str())
|
||||
&& !mysql_column_leads_kept_index(options, column)
|
||||
}
|
||||
|
||||
/// Whether a persisted, non-primary index keys this column for the whole change.
|
||||
///
|
||||
/// Only an index the draft leaves completely untouched counts. One created by this draft is
|
||||
/// added after the column DDL, and an edited one is rebuilt as DROP + CREATE after it, so
|
||||
/// neither covers the column while the coalesced ALTER runs — and the DROP would hit the very
|
||||
/// same ERROR 1075. Matching is against the persisted names on both sides, so a draft that
|
||||
/// swaps two column names cannot credit one column's index to the other.
|
||||
///
|
||||
/// This applies the InnoDB rule that the auto column must *lead* an index. MyISAM also accepts
|
||||
/// it in a later position of a multi-column index; DBX has no engine information here, so such
|
||||
/// a table loses AUTO_INCREMENT rather than every InnoDB table keeping an invalid one.
|
||||
fn mysql_column_leads_kept_index(options: &TableStructureSqlOptions, column: &EditableStructureColumn) -> bool {
|
||||
let Some(original_name) = column.original.as_ref().map(|original| original.name.as_str()) else {
|
||||
return false;
|
||||
};
|
||||
options.indexes.iter().any(|index| {
|
||||
!index.marked_for_drop
|
||||
&& !has_existing_index_change(index)
|
||||
&& index.original.as_ref().is_some_and(|original| {
|
||||
!original.is_primary
|
||||
&& original.columns.first().is_some_and(|first| clean(first).eq_ignore_ascii_case(original_name))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn without_auto_increment(column: &EditableStructureColumn) -> EditableStructureColumn {
|
||||
let mut cleared = column.clone();
|
||||
if let Some(extra) = cleared.extra.as_mut() {
|
||||
extra.auto_increment = Some(false);
|
||||
}
|
||||
cleared
|
||||
}
|
||||
|
||||
pub(super) fn build_primary_key_sql(
|
||||
options: &TableStructureSqlOptions,
|
||||
dialect: StructureDialect,
|
||||
table: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Vec<String> {
|
||||
let capabilities = capabilities_for(options.database_type, options.driver_profile.as_deref());
|
||||
|
||||
// Membership by draft id (set equality): pure rename / local reorder of the same key
|
||||
// columns is not a PK change. A draft that drops a PK column is also rejected here.
|
||||
let Some(change) = primary_key_change(options) else { return Vec::new() };
|
||||
|
||||
let supported =
|
||||
if change.old_ids.is_empty() { capabilities.add_primary_key } else { capabilities.alter_primary_key };
|
||||
if !supported {
|
||||
warnings.push(unsupported_primary_key_change_warning(options, &change));
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let persisted_postgres_primary_key_name = if options.database_type == Some(DatabaseType::Postgres)
|
||||
&& !change.old_ids.is_empty()
|
||||
{
|
||||
let mut primary_indexes =
|
||||
options.indexes.iter().filter_map(|index| index.original.as_ref().filter(|original| original.is_primary));
|
||||
match (primary_indexes.next(), primary_indexes.next()) {
|
||||
(Some(primary_index), None) if !primary_index.name.is_empty() => Some(primary_index.name.as_str()),
|
||||
_ => {
|
||||
warnings.push(
|
||||
"Could not determine the existing PostgreSQL primary key constraint name. Refresh the table structure and try again."
|
||||
.to_string(),
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut statements = Vec::new();
|
||||
if !change.old_ids.is_empty() {
|
||||
let Some(drop_sql) = drop_primary_key_statement(dialect, table, options, persisted_postgres_primary_key_name)
|
||||
else {
|
||||
warnings.push(format!(
|
||||
"Changing primary keys is not supported for {} from this editor.",
|
||||
database_label(options.database_type)
|
||||
));
|
||||
return Vec::new();
|
||||
};
|
||||
statements.push(drop_sql);
|
||||
}
|
||||
|
||||
if !change.new_names.is_empty() {
|
||||
let pk_list = change.new_names.iter().map(|name| quote_ident(dialect, name)).collect::<Vec<_>>().join(", ");
|
||||
// DM8: ADD [CONSTRAINT name] PRIMARY KEY; anonymous form matches Navicat/DBeaver/MySQL editors.
|
||||
let constraint = persisted_postgres_primary_key_name
|
||||
.map(|name| format!("CONSTRAINT {} ", quote_ident(dialect, name)))
|
||||
.unwrap_or_default();
|
||||
statements.push(format!("ALTER TABLE {table} ADD {constraint}PRIMARY KEY ({pk_list});"));
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
/// Dialect-specific DROP for an existing primary key.
|
||||
///
|
||||
/// - MySQL: `DROP PRIMARY KEY`
|
||||
/// - Dameng (DM8): official `DROP PRIMARY KEY [RESTRICT|CASCADE]`; default RESTRICT
|
||||
/// (no CASCADE — dependent FKs should not be silently removed).
|
||||
/// System names (`CONS…`) are not stable; name-based DROP is avoided.
|
||||
/// Cluster primary keys cannot use this path (DM8 restriction) — left to the server.
|
||||
/// - Postgres: `DROP CONSTRAINT <persisted primary index name>` for PostgreSQL;
|
||||
/// other Postgres-compatible engines retain the existing default-name behavior.
|
||||
fn drop_primary_key_statement(
|
||||
dialect: StructureDialect,
|
||||
table: &str,
|
||||
options: &TableStructureSqlOptions,
|
||||
persisted_postgres_primary_key_name: Option<&str>,
|
||||
) -> Option<String> {
|
||||
match dialect {
|
||||
StructureDialect::Postgres => {
|
||||
let fallback_name;
|
||||
let pk_name = if let Some(name) = persisted_postgres_primary_key_name {
|
||||
name
|
||||
} else {
|
||||
let raw_table = options.table_name.split('.').next_back().unwrap_or(&options.table_name);
|
||||
fallback_name = format!("{}_pkey", clean(raw_table));
|
||||
&fallback_name
|
||||
};
|
||||
Some(format!("ALTER TABLE {table} DROP CONSTRAINT {};", quote_ident(dialect, pk_name)))
|
||||
}
|
||||
// 神通 Oscar 实测支持 `ALTER TABLE ... DROP PRIMARY KEY`(与 Dameng/MySQL 一致)。
|
||||
StructureDialect::Mysql | StructureDialect::Dameng | StructureDialect::Oscar => {
|
||||
Some(format!("ALTER TABLE {table} DROP PRIMARY KEY;"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_sqlserver_identity(column: &EditableStructureColumn) -> bool {
|
||||
column.extra.as_ref().is_some_and(|extra| extra.auto_increment.unwrap_or(false) || extra.identity.is_some())
|
||||
}
|
||||
|
||||
fn is_sqlserver_identity_compatible_type(data_type: &str) -> bool {
|
||||
let trimmed = data_type.trim();
|
||||
let (base_type, params) = match trimmed.find('(') {
|
||||
Some(open_index) => {
|
||||
let close_index = trimmed.rfind(')').unwrap_or(trimmed.len());
|
||||
(&trimmed[..open_index], trimmed.get(open_index + 1..close_index).unwrap_or(""))
|
||||
}
|
||||
None => (trimmed, ""),
|
||||
};
|
||||
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
if matches!(normalized.as_str(), "tinyint" | "smallint" | "int" | "integer" | "bigint") {
|
||||
return true;
|
||||
}
|
||||
if !matches!(normalized.as_str(), "decimal" | "numeric") {
|
||||
return false;
|
||||
}
|
||||
let normalized_params = params.split_whitespace().collect::<String>();
|
||||
if normalized_params.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let parts = normalized_params.split(',').collect::<Vec<_>>();
|
||||
match parts.as_slice() {
|
||||
[precision] => precision.parse::<u32>().is_ok(),
|
||||
[precision, scale] => precision.parse::<u32>().is_ok() && *scale == "0",
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_add_column_sql(
|
||||
dialect: StructureDialect,
|
||||
database_type: Option<crate::models::connection::DatabaseType>,
|
||||
supports_comments: bool,
|
||||
table: &str,
|
||||
column: &EditableStructureColumn,
|
||||
position_clause: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
driver_profile: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let definition = column_definition(dialect, column);
|
||||
let mut statements = if is_oracle_like(dialect) || dialect == StructureDialect::Informix {
|
||||
vec![format!("ALTER TABLE {table} ADD ({definition});")]
|
||||
} else {
|
||||
let add_keyword = if dialect == StructureDialect::SqlServer
|
||||
|| database_type == Some(crate::models::connection::DatabaseType::Kingbase)
|
||||
{
|
||||
"ADD"
|
||||
} else {
|
||||
"ADD COLUMN"
|
||||
};
|
||||
vec![format!("ALTER TABLE {table} {add_keyword} {definition}{position_clause};")]
|
||||
};
|
||||
if supports_comments
|
||||
&& matches!(
|
||||
dialect,
|
||||
StructureDialect::Postgres | StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Oscar
|
||||
)
|
||||
&& !clean(&column.comment).is_empty()
|
||||
{
|
||||
statements.push(format!(
|
||||
"COMMENT ON COLUMN {table}.{} IS {};",
|
||||
quote_ident(dialect, &column.name),
|
||||
quote_string(&clean(&column.comment))
|
||||
));
|
||||
}
|
||||
if dialect == StructureDialect::ClickHouse && !clean(&column.comment).is_empty() {
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} COMMENT COLUMN {} {};",
|
||||
quote_ident(dialect, &column.name),
|
||||
quote_string(&clean(&column.comment))
|
||||
));
|
||||
}
|
||||
if dialect == StructureDialect::SqlServer && !clean(&column.comment).is_empty() {
|
||||
statements.extend(build_sqlserver_column_comment_sql_for_profile(
|
||||
table,
|
||||
schema,
|
||||
table_name,
|
||||
&column.name,
|
||||
&column.comment,
|
||||
driver_profile,
|
||||
));
|
||||
}
|
||||
statements
|
||||
}
|
||||
|
||||
pub(super) fn build_drop_column_sql(dialect: StructureDialect, table: &str, column_name: &str) -> String {
|
||||
if dialect == StructureDialect::Informix {
|
||||
return format!("ALTER TABLE {table} DROP ({});", quote_ident(dialect, column_name));
|
||||
}
|
||||
format!("ALTER TABLE {table} DROP COLUMN {};", quote_ident(dialect, column_name))
|
||||
}
|
||||
|
||||
pub(super) fn column_position_clause(
|
||||
dialect: StructureDialect,
|
||||
columns: &[&EditableStructureColumn],
|
||||
index: usize,
|
||||
) -> String {
|
||||
if !matches!(dialect, StructureDialect::Mysql | StructureDialect::ClickHouse) {
|
||||
return String::new();
|
||||
}
|
||||
if index == 0 {
|
||||
return " FIRST".to_string();
|
||||
}
|
||||
format!(" AFTER {}", quote_ident(dialect, columns.get(index - 1).map(|column| column.name.as_str()).unwrap_or("")))
|
||||
}
|
||||
|
||||
pub(super) fn original_active_column_order(columns: &[&EditableStructureColumn]) -> Vec<String> {
|
||||
let mut original_columns: Vec<_> = columns
|
||||
.iter()
|
||||
.filter(|column| column.original.is_some() && column.original_position.is_some())
|
||||
.copied()
|
||||
.collect();
|
||||
original_columns.sort_by_key(|column| column.original_position.unwrap_or(0));
|
||||
original_columns.into_iter().map(|column| column.id.clone()).collect()
|
||||
}
|
||||
|
||||
/// Returns the ids of existing columns that must be explicitly moved to reach the target order.
|
||||
///
|
||||
/// The function keeps the longest subsequence of existing columns whose relative order is already
|
||||
/// correct, and marks only the remaining columns for FIRST/AFTER reordering SQL.
|
||||
pub(super) fn planned_existing_column_move_ids(columns: &[&EditableStructureColumn]) -> HashSet<String> {
|
||||
// Only existing columns with an original position participate in move planning.
|
||||
// Newly added columns are positioned directly from the target order.
|
||||
let reorderable_columns: Vec<_> = columns
|
||||
.iter()
|
||||
.filter_map(|column| {
|
||||
column
|
||||
.original
|
||||
.as_ref()
|
||||
.zip(column.original_position)
|
||||
.map(|_| (column.id.as_str(), column.original_position.unwrap_or(0)))
|
||||
})
|
||||
.collect();
|
||||
if reorderable_columns.len() < 2 {
|
||||
return HashSet::new();
|
||||
}
|
||||
|
||||
// Map the target order back to original positions, then keep the largest increasing subsequence.
|
||||
let original_positions: Vec<_> = reorderable_columns.iter().map(|(_, position)| *position).collect();
|
||||
// Columns inside the LIS can stay where they are; everything else needs an explicit move.
|
||||
let kept_indices: HashSet<_> = longest_increasing_subsequence_indices(&original_positions).into_iter().collect();
|
||||
|
||||
reorderable_columns
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(index, _)| !kept_indices.contains(index))
|
||||
.map(|(_, (column_id, _))| column_id.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the indices of one longest increasing subsequence within `values`.
|
||||
///
|
||||
/// In the reorder planner, an increasing subsequence represents existing columns whose relative
|
||||
/// order still matches the original table layout, so they can remain untouched.
|
||||
fn longest_increasing_subsequence_indices(values: &[usize]) -> Vec<usize> {
|
||||
if values.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// O(n^2) is sufficient here because table editors deal with relatively small column counts
|
||||
// and the simpler implementation is easier to maintain.
|
||||
let mut lengths = vec![1; values.len()];
|
||||
let mut previous = vec![None; values.len()];
|
||||
let mut best_end_index = 0;
|
||||
|
||||
for current_index in 0..values.len() {
|
||||
for previous_index in 0..current_index {
|
||||
if values[previous_index] < values[current_index] && lengths[previous_index] + 1 > lengths[current_index] {
|
||||
lengths[current_index] = lengths[previous_index] + 1;
|
||||
previous[current_index] = Some(previous_index);
|
||||
}
|
||||
}
|
||||
|
||||
if lengths[current_index] > lengths[best_end_index] {
|
||||
best_end_index = current_index;
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct the subsequence by following the predecessor chain backwards.
|
||||
let mut indices = Vec::new();
|
||||
let mut cursor = Some(best_end_index);
|
||||
while let Some(index) = cursor {
|
||||
indices.push(index);
|
||||
cursor = previous[index];
|
||||
}
|
||||
indices.reverse();
|
||||
indices
|
||||
}
|
||||
|
||||
pub(super) fn active_previous_column_id<'a>(columns: &[&'a EditableStructureColumn], index: usize) -> Option<&'a str> {
|
||||
if index == 0 {
|
||||
None
|
||||
} else {
|
||||
columns.get(index - 1).map(|column| column.id.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn simulated_column_position_changed(
|
||||
simulated_column_order: &[String],
|
||||
column_id: &str,
|
||||
desired_previous_column_id: Option<&str>,
|
||||
) -> bool {
|
||||
let Some(index) = simulated_column_order.iter().position(|id| id == column_id) else {
|
||||
return false;
|
||||
};
|
||||
let current_previous_column_id = if index == 0 { None } else { Some(simulated_column_order[index - 1].as_str()) };
|
||||
current_previous_column_id != desired_previous_column_id
|
||||
}
|
||||
|
||||
pub(super) fn apply_simulated_column_position(
|
||||
simulated_column_order: &mut Vec<String>,
|
||||
column_id: &str,
|
||||
desired_previous_column_id: Option<&str>,
|
||||
) {
|
||||
if let Some(index) = simulated_column_order.iter().position(|id| id == column_id) {
|
||||
simulated_column_order.remove(index);
|
||||
}
|
||||
let index = desired_previous_column_id
|
||||
.and_then(|previous_id| simulated_column_order.iter().position(|id| id == previous_id).map(|index| index + 1))
|
||||
.unwrap_or(0);
|
||||
simulated_column_order.insert(index, column_id.to_string());
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
use super::dialect::{capabilities_for, database_label, dialect_label, StructureDialect};
|
||||
use super::types::TableStructureSqlOptions;
|
||||
use super::util::{clean, qualified_table, quote_string};
|
||||
|
||||
pub(super) fn build_table_comment_sql(options: &TableStructureSqlOptions, warnings: &mut Vec<String>) -> Vec<String> {
|
||||
let capabilities = capabilities_for(options.database_type, options.driver_profile.as_deref());
|
||||
let new_comment = options.table_comment.as_deref().unwrap_or("");
|
||||
let original_comment = options.original_table_comment.as_deref().unwrap_or("");
|
||||
if clean(new_comment) == clean(original_comment) {
|
||||
return Vec::new();
|
||||
}
|
||||
if !capabilities.comment {
|
||||
warnings.push(format!(
|
||||
"Table comments are not supported for {} from this editor; the comment change was ignored.",
|
||||
database_label(options.database_type)
|
||||
));
|
||||
return Vec::new();
|
||||
}
|
||||
let dialect = capabilities.dialect;
|
||||
let table = qualified_table(dialect, options.schema.as_deref(), &options.table_name);
|
||||
let quoted = quote_string(&clean(new_comment));
|
||||
match dialect {
|
||||
StructureDialect::Mysql | StructureDialect::GaussdbM => {
|
||||
vec![format!("ALTER TABLE {table} COMMENT = {quoted};")]
|
||||
}
|
||||
StructureDialect::Postgres
|
||||
| StructureDialect::Oracle
|
||||
| StructureDialect::Dameng
|
||||
| StructureDialect::Oscar
|
||||
| StructureDialect::H2 => {
|
||||
vec![format!("COMMENT ON TABLE {table} IS {quoted};")]
|
||||
}
|
||||
StructureDialect::ClickHouse => {
|
||||
vec![format!("ALTER TABLE {table} MODIFY COMMENT {quoted};")]
|
||||
}
|
||||
StructureDialect::SqlServer => build_sqlserver_table_comment_sql_for_profile(
|
||||
&table,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
new_comment,
|
||||
options.driver_profile.as_deref(),
|
||||
),
|
||||
_ => {
|
||||
if !clean(new_comment).is_empty() {
|
||||
warnings
|
||||
.push(format!("Table comments are not supported for {} from this editor.", dialect_label(dialect)));
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sqlserver_schema_name(schema: Option<&str>) -> String {
|
||||
schema.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string()).unwrap_or_else(|| "dbo".to_string())
|
||||
}
|
||||
|
||||
fn build_sqlserver_extended_property_comment_sql(
|
||||
exists: &str,
|
||||
levels: &str,
|
||||
new_comment: &str,
|
||||
procedure_prefix: &str,
|
||||
) -> Vec<String> {
|
||||
let new_comment = clean(new_comment);
|
||||
if new_comment.is_empty() {
|
||||
return vec![format!(
|
||||
"IF {exists} EXEC {procedure_prefix}sp_dropextendedproperty @name=N'MS_Description', {levels};"
|
||||
)];
|
||||
}
|
||||
|
||||
let escaped_comment = new_comment.replace('\'', "''");
|
||||
vec![format!(
|
||||
"IF {exists} EXEC {procedure_prefix}sp_updateextendedproperty @name=N'MS_Description', @value=N'{escaped_comment}', {levels} ELSE EXEC {procedure_prefix}sp_addextendedproperty @name=N'MS_Description', @value=N'{escaped_comment}', {levels};"
|
||||
)]
|
||||
}
|
||||
|
||||
pub fn build_sqlserver_table_comment_sql(
|
||||
qualified_table: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
new_comment: &str,
|
||||
) -> Vec<String> {
|
||||
build_sqlserver_table_comment_sql_for_profile(qualified_table, schema, table_name, new_comment, None)
|
||||
}
|
||||
|
||||
pub fn build_sqlserver_table_comment_sql_for_profile(
|
||||
qualified_table: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
new_comment: &str,
|
||||
driver_profile: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let schema_name = sqlserver_schema_name(schema);
|
||||
let escaped_qualified = qualified_table.replace('\'', "''");
|
||||
let escaped_schema = schema_name.replace('\'', "''");
|
||||
let escaped_table = table_name.replace('\'', "''");
|
||||
let (exists, levels, procedure_prefix) = if is_sqlserver_legacy_profile(driver_profile) {
|
||||
(
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM ::fn_listextendedproperty(N'MS_Description', N'USER', N'{escaped_schema}', N'TABLE', N'{escaped_table}', NULL, NULL))"
|
||||
),
|
||||
format!(
|
||||
"@level0type=N'USER', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}'"
|
||||
),
|
||||
"",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM sys.extended_properties AS ep WHERE ep.class = 1 AND ep.major_id = OBJECT_ID(N'{escaped_qualified}') AND ep.minor_id = 0 AND ep.name = N'MS_Description')"
|
||||
),
|
||||
format!(
|
||||
"@level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}'"
|
||||
),
|
||||
"sys.",
|
||||
)
|
||||
};
|
||||
|
||||
build_sqlserver_extended_property_comment_sql(&exists, &levels, new_comment, procedure_prefix)
|
||||
}
|
||||
|
||||
pub(super) fn build_sqlserver_index_comment_sql_for_profile(
|
||||
qualified_table: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
index_name: &str,
|
||||
new_comment: &str,
|
||||
driver_profile: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let schema_name = sqlserver_schema_name(schema);
|
||||
let escaped_qualified = qualified_table.replace('\'', "''");
|
||||
let escaped_schema = schema_name.replace('\'', "''");
|
||||
let escaped_table = table_name.replace('\'', "''");
|
||||
let escaped_idx = index_name.replace('\'', "''");
|
||||
let (exists, levels, procedure_prefix) = if is_sqlserver_legacy_profile(driver_profile) {
|
||||
(
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM ::fn_listextendedproperty(N'MS_Description', N'USER', N'{escaped_schema}', N'TABLE', N'{escaped_table}', N'INDEX', N'{escaped_idx}'))"
|
||||
),
|
||||
format!(
|
||||
"@level0type=N'USER', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'INDEX', @level2name=N'{escaped_idx}'"
|
||||
),
|
||||
"",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM sys.extended_properties AS ep INNER JOIN sys.indexes AS i ON i.object_id = ep.major_id AND i.index_id = ep.minor_id WHERE ep.class = 7 AND ep.major_id = OBJECT_ID(N'{escaped_qualified}') AND i.name = N'{escaped_idx}' AND ep.name = N'MS_Description')"
|
||||
),
|
||||
format!(
|
||||
"@level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'INDEX', @level2name=N'{escaped_idx}'"
|
||||
),
|
||||
"sys.",
|
||||
)
|
||||
};
|
||||
|
||||
build_sqlserver_extended_property_comment_sql(&exists, &levels, new_comment, procedure_prefix)
|
||||
}
|
||||
|
||||
pub fn build_sqlserver_column_comment_sql(
|
||||
qualified_table: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
column_name: &str,
|
||||
new_comment: &str,
|
||||
) -> Vec<String> {
|
||||
build_sqlserver_column_comment_sql_for_profile(qualified_table, schema, table_name, column_name, new_comment, None)
|
||||
}
|
||||
|
||||
pub fn build_sqlserver_column_comment_sql_for_profile(
|
||||
qualified_table: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
column_name: &str,
|
||||
new_comment: &str,
|
||||
driver_profile: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let schema_name = sqlserver_schema_name(schema);
|
||||
let escaped_qualified = qualified_table.replace('\'', "''");
|
||||
let escaped_schema = schema_name.replace('\'', "''");
|
||||
let escaped_table = table_name.replace('\'', "''");
|
||||
let escaped_col = column_name.replace('\'', "''");
|
||||
let (exists, levels, procedure_prefix) = if is_sqlserver_legacy_profile(driver_profile) {
|
||||
(
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM ::fn_listextendedproperty(N'MS_Description', N'USER', N'{escaped_schema}', N'TABLE', N'{escaped_table}', N'COLUMN', N'{escaped_col}'))"
|
||||
),
|
||||
format!(
|
||||
"@level0type=N'USER', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'COLUMN', @level2name=N'{escaped_col}'"
|
||||
),
|
||||
"",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM sys.extended_properties AS ep WHERE ep.class = 1 AND ep.major_id = OBJECT_ID(N'{escaped_qualified}') AND ep.minor_id = COLUMNPROPERTY(OBJECT_ID(N'{escaped_qualified}'), N'{escaped_col}', 'ColumnId') AND ep.name = N'MS_Description')"
|
||||
),
|
||||
format!(
|
||||
"@level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'COLUMN', @level2name=N'{escaped_col}'"
|
||||
),
|
||||
"sys.",
|
||||
)
|
||||
};
|
||||
|
||||
build_sqlserver_extended_property_comment_sql(&exists, &levels, new_comment, procedure_prefix)
|
||||
}
|
||||
|
||||
fn is_sqlserver_legacy_profile(driver_profile: Option<&str>) -> bool {
|
||||
driver_profile.is_some_and(|profile| profile.trim().eq_ignore_ascii_case("sqlserver-legacy"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sqlserver_table_comment_updates_or_adds_without_dropping() {
|
||||
let statements = build_sqlserver_table_comment_sql("[app's].[user's]", Some("app's"), "user's", "owner's 新值");
|
||||
|
||||
assert_eq!(statements.len(), 1);
|
||||
let sql = &statements[0];
|
||||
assert!(sql.contains("ep.class = 1"), "table extended-property class: {sql}");
|
||||
assert!(sql.contains("sys.sp_updateextendedproperty"), "update existing comment: {sql}");
|
||||
assert!(sql.contains("ELSE EXEC sys.sp_addextendedproperty"), "add missing comment: {sql}");
|
||||
assert!(!sql.contains("sys.sp_dropextendedproperty"), "non-empty comments are not dropped first: {sql}");
|
||||
assert!(sql.contains("OBJECT_ID(N'[app''s].[user''s]')"), "object name escaping: {sql}");
|
||||
assert!(sql.contains("@value=N'owner''s 新值'"), "Unicode comment escaping: {sql}");
|
||||
assert_eq!(
|
||||
crate::sql::split_sql_statements_for_database(sql, crate::models::connection::DatabaseType::SqlServer)
|
||||
.len(),
|
||||
1,
|
||||
"IF/ELSE comment upsert must remain one executable statement: {sql}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_empty_column_comment_drops_only_when_present() {
|
||||
let statements = build_sqlserver_column_comment_sql("[dbo].[orders]", None, "orders", "owner'id", " ");
|
||||
|
||||
assert_eq!(statements.len(), 1);
|
||||
let sql = &statements[0];
|
||||
assert!(sql.contains("COLUMNPROPERTY(OBJECT_ID(N'[dbo].[orders]'), N'owner''id', 'ColumnId')"));
|
||||
assert!(sql.contains("sys.sp_dropextendedproperty"), "drop existing comment: {sql}");
|
||||
assert!(!sql.contains("sys.sp_updateextendedproperty"), "empty comment must not update: {sql}");
|
||||
assert!(!sql.contains("sys.sp_addextendedproperty"), "empty comment must not add: {sql}");
|
||||
assert!(sql.contains("@level0name=N'dbo'"), "default schema: {sql}");
|
||||
assert_eq!(
|
||||
crate::sql::split_sql_statements_for_database(sql, crate::models::connection::DatabaseType::SqlServer)
|
||||
.len(),
|
||||
1,
|
||||
"conditional comment drop must remain one executable statement: {sql}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_index_comment_uses_index_extended_property_identity() {
|
||||
let statements = build_sqlserver_index_comment_sql_for_profile(
|
||||
"[dbo].[orders]",
|
||||
None,
|
||||
"orders",
|
||||
"ix_owner's",
|
||||
"index comment",
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(statements.len(), 1);
|
||||
let sql = &statements[0];
|
||||
assert!(sql.contains("INNER JOIN sys.indexes AS i"), "index lookup: {sql}");
|
||||
assert!(sql.contains("ep.class = 7"), "index extended-property class: {sql}");
|
||||
assert!(sql.contains("i.name = N'ix_owner''s'"), "index name escaping: {sql}");
|
||||
assert!(sql.contains("sys.sp_updateextendedproperty"), "update existing comment: {sql}");
|
||||
assert!(sql.contains("ELSE EXEC sys.sp_addextendedproperty"), "add missing comment: {sql}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_legacy_column_comment_uses_sql_server_2000_compatibility_syntax() {
|
||||
let statements = build_sqlserver_column_comment_sql_for_profile(
|
||||
"[dbo].[Categories]",
|
||||
Some("dbo"),
|
||||
"Categories",
|
||||
"CategoryID",
|
||||
"test",
|
||||
Some("sqlserver-legacy"),
|
||||
);
|
||||
|
||||
assert_eq!(statements.len(), 1);
|
||||
let sql = &statements[0];
|
||||
assert!(sql.contains("::fn_listextendedproperty"), "legacy property lookup: {sql}");
|
||||
assert!(sql.contains("@level0type=N'USER'"), "legacy hierarchy: {sql}");
|
||||
assert!(sql.contains("EXEC sp_updateextendedproperty"), "legacy update procedure: {sql}");
|
||||
assert!(sql.contains("ELSE EXEC sp_addextendedproperty"), "legacy add procedure: {sql}");
|
||||
assert!(
|
||||
!sql.contains("sys.extended_properties"),
|
||||
"SQL Server 2005+ catalog view leaked into legacy SQL: {sql}"
|
||||
);
|
||||
assert!(!sql.contains("EXEC sys."), "SQL Server 2005+ procedure qualification leaked into legacy SQL: {sql}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
use super::column_format::{
|
||||
column_data_type, column_extra_clause, has_dameng_identity, is_dameng_identity_compatible_type,
|
||||
is_mysql_character_data_type, is_mysql_timestamp_type, mysql_on_update_current_timestamp_clause,
|
||||
strip_inherited_mysql_column_charsets,
|
||||
};
|
||||
use super::comments::{build_sqlserver_column_comment_sql_for_profile, build_sqlserver_table_comment_sql_for_profile};
|
||||
use super::dialect::{capabilities_for, database_label, StructureDialect};
|
||||
use super::foreign_keys::build_foreign_key_sql_for_new_table;
|
||||
use super::indexes::build_create_index_statements;
|
||||
use super::mysql_engine::{append_mysql_table_option, validate_mysql_engine};
|
||||
use super::triggers::build_trigger_sql_for_new_table;
|
||||
use super::types::{TableStructureSqlOptions, TableStructureSqlResult};
|
||||
use super::util::{
|
||||
clean, format_default_for_sql, normalize_default, qualified_new_table, quote_ident, quote_new_ident, quote_string,
|
||||
};
|
||||
use super::validation::{validate_columns, validate_concurrent_index_scope, validate_dameng_identity};
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
fn is_sqlite_integer_family_type(data_type: &str) -> bool {
|
||||
let normalized = data_type.trim().to_ascii_lowercase();
|
||||
["int", "integer", "tinyint", "smallint", "mediumint", "bigint"]
|
||||
.iter()
|
||||
.any(|candidate| normalized == *candidate || normalized.starts_with(&format!("{candidate}(")))
|
||||
}
|
||||
|
||||
pub fn build_create_table_sql(mut options: TableStructureSqlOptions) -> TableStructureSqlResult {
|
||||
let capabilities;
|
||||
let dialect;
|
||||
if options.is_gaussdb_m_mode {
|
||||
capabilities = super::dialect::gaussdb_m_capabilities();
|
||||
dialect = StructureDialect::GaussdbM;
|
||||
} else {
|
||||
capabilities = capabilities_for(options.database_type, options.driver_profile.as_deref());
|
||||
dialect = capabilities.dialect;
|
||||
}
|
||||
options.table_name = clean(&options.table_name);
|
||||
strip_inherited_mysql_column_charsets(&mut options);
|
||||
let mut warnings = Vec::new();
|
||||
warnings.extend(validate_mysql_engine(&options));
|
||||
// Fail closed: a concurrent-index request on a partitioned parent (or on an
|
||||
// existing index in a hand-built draft) is refused up front instead of
|
||||
// degrading into blocking index DDL.
|
||||
warnings.extend(validate_concurrent_index_scope(&options));
|
||||
if options.table_name.is_empty() {
|
||||
warnings.push("Table name is required.".to_string());
|
||||
}
|
||||
let active_columns: Vec<_> = options.columns.iter().filter(|column| !column.marked_for_drop).collect();
|
||||
if active_columns.is_empty() {
|
||||
warnings.push("At least one column is required.".to_string());
|
||||
}
|
||||
validate_columns(&active_columns, &mut warnings);
|
||||
validate_dameng_identity(&options, &active_columns, &mut warnings);
|
||||
if !warnings.is_empty() {
|
||||
return TableStructureSqlResult { statements: Vec::new(), warnings };
|
||||
}
|
||||
let table = qualified_new_table(options.database_type, dialect, options.schema.as_deref(), &options.table_name);
|
||||
if dialect == StructureDialect::Dameng {
|
||||
for column in &active_columns {
|
||||
if has_dameng_identity(column) && !is_dameng_identity_compatible_type(&column.data_type) {
|
||||
warnings.push(format!(
|
||||
"Dameng identity column \"{}\" must use tinyint, smallint, int, integer, bigint, number, numeric, or decimal/dec with scale 0.",
|
||||
column.name
|
||||
));
|
||||
}
|
||||
}
|
||||
if !warnings.is_empty() {
|
||||
return TableStructureSqlResult { statements: Vec::new(), warnings };
|
||||
}
|
||||
}
|
||||
if dialect == StructureDialect::Sqlite {
|
||||
let primary_key_count = active_columns.iter().filter(|column| column.is_primary_key).count();
|
||||
for column in &active_columns {
|
||||
if !column.is_primary_key || !column.extra.as_ref().is_some_and(|e| e.auto_increment.unwrap_or(false)) {
|
||||
continue;
|
||||
}
|
||||
if primary_key_count != 1 {
|
||||
warnings.push(
|
||||
"SQLite AUTOINCREMENT requires a single INTEGER PRIMARY KEY column; disable auto-increment on composite primary keys.".to_string(),
|
||||
);
|
||||
} else if !is_sqlite_integer_family_type(&column.data_type) {
|
||||
warnings.push(format!(
|
||||
"SQLite auto-increment column \"{}\" must use an integer type (normalized to INTEGER).",
|
||||
column.name
|
||||
));
|
||||
}
|
||||
}
|
||||
if !warnings.is_empty() {
|
||||
return TableStructureSqlResult { statements: Vec::new(), warnings };
|
||||
}
|
||||
}
|
||||
let mut statements = Vec::new();
|
||||
let mut column_definitions = Vec::new();
|
||||
|
||||
for column in &active_columns {
|
||||
let mut data_type = column_data_type(dialect, column);
|
||||
// SQLite accepts AUTOINCREMENT only on an exact INTEGER PRIMARY KEY,
|
||||
// so integer-family aliases are normalized when auto-increment is on.
|
||||
if dialect == StructureDialect::Sqlite
|
||||
&& column.is_primary_key
|
||||
&& column.extra.as_ref().is_some_and(|e| e.auto_increment.unwrap_or(false))
|
||||
{
|
||||
data_type = "INTEGER".to_string();
|
||||
}
|
||||
let mut parts = vec![quote_new_ident(options.database_type, dialect, &column.name), data_type];
|
||||
if options.database_type == Some(DatabaseType::Mysql) && is_mysql_character_data_type(&column.data_type) {
|
||||
if !column.character_set.trim().is_empty() {
|
||||
parts.push(format!("CHARACTER SET {}", quote_ident(dialect, &column.character_set)));
|
||||
}
|
||||
if !column.collation.trim().is_empty() {
|
||||
parts.push(format!("COLLATE {}", quote_ident(dialect, &column.collation)));
|
||||
}
|
||||
}
|
||||
if dialect == StructureDialect::Sqlite
|
||||
&& column.is_primary_key
|
||||
&& column.extra.as_ref().is_some_and(|e| e.auto_increment.unwrap_or(false))
|
||||
{
|
||||
parts.push("PRIMARY KEY".to_string());
|
||||
} else if !column.is_nullable
|
||||
&& !column.is_primary_key
|
||||
&& !matches!(dialect, StructureDialect::ClickHouse | StructureDialect::ManticoreSearch)
|
||||
{
|
||||
parts.push("NOT NULL".to_string());
|
||||
} else if column.is_nullable
|
||||
&& !column.is_primary_key
|
||||
&& dialect == StructureDialect::Mysql
|
||||
&& is_mysql_timestamp_type(&column.data_type)
|
||||
{
|
||||
parts.push("NULL".to_string());
|
||||
}
|
||||
if let Some(extra_clause) = column_extra_clause(dialect, column) {
|
||||
parts.push(extra_clause);
|
||||
}
|
||||
let default_value = normalize_default(Some(&column.default_value));
|
||||
if !default_value.is_empty() && dialect != StructureDialect::ManticoreSearch {
|
||||
parts.push(format!("DEFAULT {}", format_default_for_sql(dialect, &column.data_type, &default_value)));
|
||||
}
|
||||
if let Some(on_update) = column.extra.as_ref().and_then(|e| e.on_update_current_timestamp).filter(|v| *v) {
|
||||
if on_update && dialect == StructureDialect::Mysql {
|
||||
parts.push(mysql_on_update_current_timestamp_clause(&column.data_type));
|
||||
}
|
||||
}
|
||||
if dialect == StructureDialect::Mysql && capabilities.comment && !clean(&column.comment).is_empty() {
|
||||
parts.push(format!("COMMENT {}", quote_string(&clean(&column.comment))));
|
||||
}
|
||||
column_definitions.push(parts.join(" "));
|
||||
}
|
||||
|
||||
let pk_columns: Vec<_> = active_columns
|
||||
.iter()
|
||||
.filter(|column| {
|
||||
column.is_primary_key
|
||||
&& dialect != StructureDialect::ManticoreSearch
|
||||
&& !(dialect == StructureDialect::Sqlite
|
||||
&& column.extra.as_ref().is_some_and(|e| e.auto_increment.unwrap_or(false)))
|
||||
})
|
||||
.collect();
|
||||
if !pk_columns.is_empty() {
|
||||
let pk_list = pk_columns
|
||||
.iter()
|
||||
.map(|column| quote_new_ident(options.database_type, dialect, &column.name))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
column_definitions.push(format!("PRIMARY KEY ({pk_list})"));
|
||||
}
|
||||
|
||||
statements.push(format!("CREATE TABLE {table} (\n {}\n);", column_definitions.join(",\n ")));
|
||||
|
||||
if let Some(engine) = options.mysql_engine.as_deref().map(str::trim).filter(|engine| !engine.is_empty()) {
|
||||
if let Some(statement) = statements.last_mut() {
|
||||
append_mysql_table_option(statement, &format!("ENGINE = {engine}"));
|
||||
}
|
||||
}
|
||||
|
||||
if capabilities.comment {
|
||||
let table_comment = clean(options.table_comment.as_deref().unwrap_or(""));
|
||||
if !table_comment.is_empty() {
|
||||
if matches!(dialect, StructureDialect::Mysql | StructureDialect::GaussdbM) {
|
||||
if let Some(last) = statements.last_mut() {
|
||||
append_mysql_table_option(last, &format!("COMMENT = {}", quote_string(&table_comment)));
|
||||
}
|
||||
} else if matches!(
|
||||
dialect,
|
||||
StructureDialect::Postgres
|
||||
| StructureDialect::Oracle
|
||||
| StructureDialect::Dameng
|
||||
| StructureDialect::Oscar
|
||||
| StructureDialect::H2
|
||||
) {
|
||||
statements.push(format!("COMMENT ON TABLE {table} IS {};", quote_string(&table_comment)));
|
||||
} else if dialect == StructureDialect::ClickHouse {
|
||||
statements.push(format!("ALTER TABLE {table} MODIFY COMMENT {};", quote_string(&table_comment)));
|
||||
} else if dialect == StructureDialect::SqlServer {
|
||||
statements.extend(build_sqlserver_table_comment_sql_for_profile(
|
||||
&table,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
&table_comment,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if capabilities.comment
|
||||
&& matches!(
|
||||
dialect,
|
||||
StructureDialect::Postgres
|
||||
| StructureDialect::Oracle
|
||||
| StructureDialect::Dameng
|
||||
| StructureDialect::Oscar
|
||||
| StructureDialect::H2
|
||||
)
|
||||
{
|
||||
for column in &active_columns {
|
||||
if !clean(&column.comment).is_empty() {
|
||||
statements.push(format!(
|
||||
"COMMENT ON COLUMN {table}.{} IS {};",
|
||||
quote_new_ident(options.database_type, dialect, &column.name),
|
||||
quote_string(&clean(&column.comment))
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if capabilities.comment && dialect == StructureDialect::ClickHouse {
|
||||
for column in &active_columns {
|
||||
if !clean(&column.comment).is_empty() {
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} COMMENT COLUMN {} {};",
|
||||
quote_new_ident(options.database_type, dialect, &column.name),
|
||||
quote_string(&clean(&column.comment))
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if capabilities.comment && dialect == StructureDialect::SqlServer {
|
||||
for column in &active_columns {
|
||||
if !clean(&column.comment).is_empty() {
|
||||
statements.extend(build_sqlserver_column_comment_sql_for_profile(
|
||||
&table,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
&column.name,
|
||||
&column.comment,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for index in options.indexes.iter().filter(|index| !index.marked_for_drop && !index.is_primary) {
|
||||
if !capabilities.create_index {
|
||||
warnings.push(format!(
|
||||
"Creating indexes is not supported for {} from this editor.",
|
||||
database_label(options.database_type)
|
||||
));
|
||||
continue;
|
||||
}
|
||||
statements.extend(build_create_index_statements(
|
||||
options.database_type,
|
||||
dialect,
|
||||
&table,
|
||||
index,
|
||||
&mut warnings,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
false,
|
||||
capabilities.index_concurrent,
|
||||
true,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
statements.extend(build_foreign_key_sql_for_new_table(&options, &mut warnings));
|
||||
statements.extend(build_trigger_sql_for_new_table(&options, &mut warnings));
|
||||
|
||||
TableStructureSqlResult { statements, warnings }
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum StructureDialect {
|
||||
Mysql,
|
||||
Doris,
|
||||
Postgres,
|
||||
Sqlite,
|
||||
#[cfg(feature = "duckdb-sidecar")]
|
||||
DuckDb,
|
||||
SqlServer,
|
||||
Oracle,
|
||||
Dameng,
|
||||
// 神通 Oscar:Oracle 兼容方言,且支持 ALTER TABLE DROP/ADD PRIMARY KEY(与 Dameng 一致,
|
||||
// 不同于 Oracle)。DDL 生成行为与 Dameng 共享分支;单独成 dialect 以保证 label/反向映射准确。
|
||||
Oscar,
|
||||
H2,
|
||||
ClickHouse,
|
||||
ManticoreSearch,
|
||||
Informix,
|
||||
Questdb,
|
||||
// GaussDB M 模式:MySQL 兼容方言,使用反引号引用标识符。DDL 行为接近 MySQL,
|
||||
// 但使用 UBTREE 索引类型(而非 BTREE),不支持 INCLUDE/FILTER 等。
|
||||
// DROP INDEX 使用 PostgreSQL 风格(DROP INDEX name),而非 MySQL 的 DROP INDEX name ON table。
|
||||
GaussdbM,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) struct TableStructureCapabilities {
|
||||
pub(super) dialect: StructureDialect,
|
||||
pub(super) add_column: bool,
|
||||
pub(super) drop_column: bool,
|
||||
pub(super) rename_column: bool,
|
||||
pub(super) alter_existing_column: bool,
|
||||
pub(super) reorder_column: bool,
|
||||
pub(super) comment: bool,
|
||||
pub(super) create_index: bool,
|
||||
pub(super) drop_index: bool,
|
||||
pub(super) rebuild_index: bool,
|
||||
pub(super) index_type: bool,
|
||||
pub(super) index_include: bool,
|
||||
pub(super) index_filter: bool,
|
||||
pub(super) index_comment: bool,
|
||||
pub(super) index_concurrent: bool,
|
||||
pub(super) add_primary_key: bool,
|
||||
pub(super) alter_primary_key: bool,
|
||||
pub(super) foreign_key: bool,
|
||||
}
|
||||
|
||||
impl Default for TableStructureCapabilities {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dialect: StructureDialect::Unsupported,
|
||||
add_column: false,
|
||||
drop_column: false,
|
||||
rename_column: false,
|
||||
alter_existing_column: false,
|
||||
reorder_column: false,
|
||||
comment: false,
|
||||
create_index: false,
|
||||
drop_index: false,
|
||||
rebuild_index: false,
|
||||
index_type: false,
|
||||
index_include: false,
|
||||
index_filter: false,
|
||||
index_comment: false,
|
||||
index_concurrent: false,
|
||||
add_primary_key: false,
|
||||
alter_primary_key: false,
|
||||
foreign_key: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn gaussdb_m_capabilities() -> TableStructureCapabilities {
|
||||
TableStructureCapabilities {
|
||||
dialect: StructureDialect::GaussdbM,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
reorder_column: true,
|
||||
comment: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_type: true,
|
||||
index_comment: true,
|
||||
add_primary_key: true,
|
||||
alter_primary_key: true,
|
||||
..TableStructureCapabilities::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn capabilities_for(
|
||||
database_type: Option<DatabaseType>,
|
||||
driver_profile: Option<&str>,
|
||||
) -> TableStructureCapabilities {
|
||||
let base = TableStructureCapabilities::default();
|
||||
match database_type {
|
||||
Some(
|
||||
DatabaseType::Mysql
|
||||
| DatabaseType::StarRocks
|
||||
| DatabaseType::Goldendb
|
||||
| DatabaseType::Sundb
|
||||
| DatabaseType::Databend,
|
||||
) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Mysql,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
reorder_column: true,
|
||||
comment: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_type: true,
|
||||
index_comment: true,
|
||||
add_primary_key: true,
|
||||
alter_primary_key: true,
|
||||
foreign_key: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::Doris) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Doris,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
comment: true,
|
||||
..base
|
||||
},
|
||||
// GBase 8s is Informix-compatible, unlike the rest of the `Gbase`
|
||||
// family (GBase 8a and friends) which speak MySQL wire protocol and
|
||||
// SQL dialect. `driver_profile` is the only signal that distinguishes
|
||||
// them (see the same check in `table_export.rs` / `table_select.rs`).
|
||||
Some(DatabaseType::Gbase) if driver_profile.is_some_and(|profile| profile.eq_ignore_ascii_case("gbase8s")) => {
|
||||
TableStructureCapabilities {
|
||||
dialect: StructureDialect::Informix,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
..base
|
||||
}
|
||||
}
|
||||
Some(DatabaseType::Gbase) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Mysql,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
reorder_column: true,
|
||||
..base
|
||||
},
|
||||
Some(
|
||||
DatabaseType::Postgres
|
||||
| DatabaseType::Gaussdb
|
||||
| DatabaseType::Kwdb
|
||||
| DatabaseType::OpenGauss
|
||||
| DatabaseType::Highgo
|
||||
| DatabaseType::Uxdb
|
||||
| DatabaseType::Vastbase
|
||||
| DatabaseType::Kingbase
|
||||
| DatabaseType::Firebird,
|
||||
) => {
|
||||
let mut caps = TableStructureCapabilities {
|
||||
dialect: StructureDialect::Postgres,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
comment: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_type: true,
|
||||
index_include: true,
|
||||
index_filter: true,
|
||||
index_comment: true,
|
||||
add_primary_key: true,
|
||||
alter_primary_key: true,
|
||||
foreign_key: true,
|
||||
..base
|
||||
};
|
||||
// CREATE INDEX CONCURRENTLY is PostgreSQL-specific. Other PostgreSQL-family
|
||||
// engines (Kingbase, GaussDB, OpenGauss, HighGo, UXDB, Vastbase, KWDB,
|
||||
// Firebird) share the Postgres dialect but have not been verified against a
|
||||
// real environment, so they keep this capability off until individually
|
||||
// validated.
|
||||
if database_type == Some(DatabaseType::Postgres) {
|
||||
caps.index_concurrent = true;
|
||||
}
|
||||
caps
|
||||
}
|
||||
Some(DatabaseType::Questdb) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Questdb,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
reorder_column: false,
|
||||
comment: false,
|
||||
create_index: false,
|
||||
drop_index: false,
|
||||
rebuild_index: false,
|
||||
index_type: false,
|
||||
index_include: false,
|
||||
index_filter: false,
|
||||
index_comment: false,
|
||||
index_concurrent: false,
|
||||
add_primary_key: false,
|
||||
alter_primary_key: false,
|
||||
foreign_key: false,
|
||||
},
|
||||
Some(DatabaseType::Redshift | DatabaseType::Vertica) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Postgres,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
comment: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::Sqlite | DatabaseType::Rqlite) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Sqlite,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_filter: true,
|
||||
..base
|
||||
},
|
||||
#[cfg(feature = "duckdb-sidecar")]
|
||||
Some(DatabaseType::DuckDb) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::DuckDb,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::SqlServer) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::SqlServer,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
comment: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_type: true,
|
||||
index_include: true,
|
||||
index_filter: true,
|
||||
index_comment: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::Dameng) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Dameng,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
comment: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_type: true,
|
||||
add_primary_key: true,
|
||||
alter_primary_key: true,
|
||||
..base
|
||||
},
|
||||
// 神通 Oscar v7:Oracle 兼容,实测支持 ALTER TABLE ADD/MODIFY/DROP/RENAME COLUMN、
|
||||
// DROP/ADD PRIMARY KEY、COMMENT ON、CREATE/DROP INDEX(见 issue #5505 探测)。
|
||||
Some(DatabaseType::Oscar) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Oscar,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
comment: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_type: true,
|
||||
add_primary_key: true,
|
||||
alter_primary_key: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::Iris) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Oracle,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
// IRIS supports %DESCRIPTION while defining tables/columns, but cannot alter existing descriptions.
|
||||
comment: false,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_type: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::Oracle) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Oracle,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
comment: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_type: true,
|
||||
add_primary_key: true,
|
||||
foreign_key: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::OceanbaseOracle | DatabaseType::Yashandb | DatabaseType::Xugu) => {
|
||||
TableStructureCapabilities {
|
||||
dialect: StructureDialect::Oracle,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
comment: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
index_type: true,
|
||||
..base
|
||||
}
|
||||
}
|
||||
Some(DatabaseType::H2) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::H2,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
comment: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::ClickHouse) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::ClickHouse,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
reorder_column: true,
|
||||
comment: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::ManticoreSearch) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::ManticoreSearch,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
..base
|
||||
},
|
||||
Some(DatabaseType::Informix) => TableStructureCapabilities {
|
||||
dialect: StructureDialect::Informix,
|
||||
add_column: true,
|
||||
drop_column: true,
|
||||
rename_column: true,
|
||||
alter_existing_column: true,
|
||||
create_index: true,
|
||||
drop_index: true,
|
||||
rebuild_index: true,
|
||||
..base
|
||||
},
|
||||
_ => base,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_oracle_like(dialect: StructureDialect) -> bool {
|
||||
matches!(dialect, StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Oscar)
|
||||
}
|
||||
|
||||
pub(super) fn database_label(database_type: Option<DatabaseType>) -> String {
|
||||
database_type
|
||||
.map(|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())
|
||||
})
|
||||
.unwrap_or_else(|| "this database".to_string())
|
||||
}
|
||||
|
||||
pub(super) fn dialect_label(dialect: StructureDialect) -> String {
|
||||
match dialect {
|
||||
StructureDialect::Mysql => "mysql",
|
||||
StructureDialect::Doris => "doris",
|
||||
StructureDialect::Postgres => "postgres",
|
||||
StructureDialect::Sqlite => "sqlite",
|
||||
#[cfg(feature = "duckdb-sidecar")]
|
||||
StructureDialect::DuckDb => "duckdb",
|
||||
StructureDialect::SqlServer => "sqlserver",
|
||||
StructureDialect::Oracle => "oracle",
|
||||
StructureDialect::Dameng => "dameng",
|
||||
StructureDialect::Oscar => "oscar",
|
||||
StructureDialect::H2 => "h2",
|
||||
StructureDialect::ClickHouse => "clickhouse",
|
||||
StructureDialect::ManticoreSearch => "manticoresearch",
|
||||
StructureDialect::Informix => "informix",
|
||||
StructureDialect::GaussdbM => "gaussdb-m",
|
||||
StructureDialect::Questdb => "questdb",
|
||||
StructureDialect::Unsupported => "this database",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(super) fn database_type_for_dialect(dialect: StructureDialect) -> Option<DatabaseType> {
|
||||
match dialect {
|
||||
StructureDialect::Mysql => Some(DatabaseType::Mysql),
|
||||
StructureDialect::Doris => Some(DatabaseType::Doris),
|
||||
StructureDialect::Postgres => Some(DatabaseType::Postgres),
|
||||
StructureDialect::Sqlite => Some(DatabaseType::Sqlite),
|
||||
#[cfg(feature = "duckdb-sidecar")]
|
||||
StructureDialect::DuckDb => Some(DatabaseType::DuckDb),
|
||||
StructureDialect::SqlServer => Some(DatabaseType::SqlServer),
|
||||
StructureDialect::Oracle => Some(DatabaseType::Oracle),
|
||||
StructureDialect::Dameng => Some(DatabaseType::Dameng),
|
||||
StructureDialect::Oscar => Some(DatabaseType::Oscar),
|
||||
StructureDialect::H2 => Some(DatabaseType::H2),
|
||||
StructureDialect::ClickHouse => Some(DatabaseType::ClickHouse),
|
||||
StructureDialect::ManticoreSearch => Some(DatabaseType::ManticoreSearch),
|
||||
StructureDialect::Informix => Some(DatabaseType::Informix),
|
||||
StructureDialect::Questdb => Some(DatabaseType::Questdb),
|
||||
StructureDialect::GaussdbM => Some(DatabaseType::Mysql),
|
||||
StructureDialect::Unsupported => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
use super::dialect::{database_label, StructureDialect};
|
||||
use super::types::{EditableStructureForeignKey, ForeignKeyInfo, TableStructureSqlOptions};
|
||||
use super::util::{clean, qualified_new_table, qualified_table, quote_ident, quote_new_ident};
|
||||
|
||||
pub(super) fn build_foreign_key_sql(options: &TableStructureSqlOptions, warnings: &mut Vec<String>) -> Vec<String> {
|
||||
build_foreign_key_sql_with_mode(options, warnings, false)
|
||||
}
|
||||
|
||||
pub(super) fn build_foreign_key_sql_for_new_table(
|
||||
options: &TableStructureSqlOptions,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Vec<String> {
|
||||
build_foreign_key_sql_with_mode(options, warnings, true)
|
||||
}
|
||||
|
||||
fn build_foreign_key_sql_with_mode(
|
||||
options: &TableStructureSqlOptions,
|
||||
warnings: &mut Vec<String>,
|
||||
for_new_table: bool,
|
||||
) -> Vec<String> {
|
||||
if options.foreign_keys.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let database_label = database_label(options.database_type);
|
||||
let capabilities = super::dialect::capabilities_for(options.database_type, options.driver_profile.as_deref());
|
||||
let dialect = capabilities.dialect;
|
||||
if !capabilities.foreign_key {
|
||||
if has_foreign_key_edits(&options.foreign_keys) {
|
||||
warnings.push(format!("Editing foreign keys is not supported for {database_label} from this editor."));
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let table = if for_new_table {
|
||||
qualified_new_table(options.database_type, dialect, options.schema.as_deref(), &options.table_name)
|
||||
} else {
|
||||
qualified_table(dialect, options.schema.as_deref(), &options.table_name)
|
||||
};
|
||||
let mut statements = Vec::new();
|
||||
|
||||
for foreign_key in &options.foreign_keys {
|
||||
if foreign_key.marked_for_drop {
|
||||
if let Some(original) = &foreign_key.original {
|
||||
statements.push(drop_foreign_key_sql(dialect, &table, &original.name));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(original) = &foreign_key.original {
|
||||
if !has_foreign_key_change(foreign_key, original) {
|
||||
continue;
|
||||
}
|
||||
statements.push(drop_foreign_key_sql(dialect, &table, &original.name));
|
||||
}
|
||||
|
||||
if let Some(sql) =
|
||||
create_foreign_key_sql(options.database_type, dialect, &table, foreign_key, warnings, for_new_table)
|
||||
{
|
||||
statements.push(sql);
|
||||
}
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
fn has_foreign_key_edits(foreign_keys: &[EditableStructureForeignKey]) -> bool {
|
||||
foreign_keys.iter().any(|foreign_key| {
|
||||
if foreign_key.marked_for_drop {
|
||||
return foreign_key.original.is_some();
|
||||
}
|
||||
|
||||
match &foreign_key.original {
|
||||
Some(original) => has_foreign_key_change(foreign_key, original),
|
||||
None => has_foreign_key_definition(foreign_key),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn has_foreign_key_definition(foreign_key: &EditableStructureForeignKey) -> bool {
|
||||
!clean(&foreign_key.name).is_empty()
|
||||
|| !clean(&foreign_key.column).is_empty()
|
||||
|| !clean(&foreign_key.ref_schema).is_empty()
|
||||
|| !clean(&foreign_key.ref_table).is_empty()
|
||||
|| !clean(&foreign_key.ref_column).is_empty()
|
||||
|| !clean(&foreign_key.on_update).is_empty()
|
||||
|| !clean(&foreign_key.on_delete).is_empty()
|
||||
}
|
||||
|
||||
fn has_foreign_key_change(foreign_key: &EditableStructureForeignKey, original: &ForeignKeyInfo) -> bool {
|
||||
clean(&foreign_key.name) != clean(&original.name)
|
||||
|| clean(&foreign_key.column) != clean(&original.column)
|
||||
|| clean(&foreign_key.ref_schema) != clean(original.ref_schema.as_deref().unwrap_or(""))
|
||||
|| clean(&foreign_key.ref_table) != clean(&original.ref_table)
|
||||
|| clean(&foreign_key.ref_column) != clean(&original.ref_column)
|
||||
|| normalize_action(&foreign_key.on_update) != normalize_action(original.on_update.as_deref().unwrap_or(""))
|
||||
|| normalize_action(&foreign_key.on_delete) != normalize_action(original.on_delete.as_deref().unwrap_or(""))
|
||||
}
|
||||
|
||||
fn drop_foreign_key_sql(dialect: StructureDialect, table: &str, name: &str) -> String {
|
||||
match dialect {
|
||||
StructureDialect::Mysql => format!("ALTER TABLE {table} DROP FOREIGN KEY {};", quote_ident(dialect, name)),
|
||||
StructureDialect::Postgres | StructureDialect::Oracle => {
|
||||
format!("ALTER TABLE {table} DROP CONSTRAINT {};", quote_ident(dialect, name))
|
||||
}
|
||||
_ => unreachable!("foreign key SQL requested for unsupported dialect"),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_foreign_key_sql(
|
||||
database_type: Option<crate::models::connection::DatabaseType>,
|
||||
dialect: StructureDialect,
|
||||
table: &str,
|
||||
foreign_key: &EditableStructureForeignKey,
|
||||
warnings: &mut Vec<String>,
|
||||
for_new_table: bool,
|
||||
) -> Option<String> {
|
||||
let name = clean(&foreign_key.name);
|
||||
let columns = identifier_list(&foreign_key.column);
|
||||
let ref_table = clean(&foreign_key.ref_table);
|
||||
let ref_columns = identifier_list(&foreign_key.ref_column);
|
||||
if name.is_empty() || columns.is_empty() || ref_table.is_empty() || ref_columns.is_empty() {
|
||||
warnings.push("Foreign key name, column, referenced table, and referenced column are required.".to_string());
|
||||
return None;
|
||||
}
|
||||
if columns.len() != ref_columns.len() {
|
||||
warnings.push(format!("Foreign key \"{name}\" must have the same number of local and referenced columns."));
|
||||
return None;
|
||||
}
|
||||
|
||||
let ref_target = if clean(&foreign_key.ref_schema).is_empty() {
|
||||
quote_ident(dialect, &ref_table)
|
||||
} else {
|
||||
format!("{}.{}", quote_ident(dialect, &foreign_key.ref_schema), quote_ident(dialect, &ref_table))
|
||||
};
|
||||
|
||||
let constraint_name =
|
||||
if for_new_table { quote_new_ident(database_type, dialect, &name) } else { quote_ident(dialect, &name) };
|
||||
let local_columns =
|
||||
columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
if for_new_table {
|
||||
quote_new_ident(database_type, dialect, column)
|
||||
} else {
|
||||
quote_ident(dialect, column)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let referenced_columns =
|
||||
ref_columns.iter().map(|column| quote_ident(dialect, column)).collect::<Vec<_>>().join(", ");
|
||||
let mut sql = format!(
|
||||
"ALTER TABLE {table} ADD CONSTRAINT {constraint_name} FOREIGN KEY ({local_columns}) REFERENCES {ref_target} ({referenced_columns})"
|
||||
);
|
||||
|
||||
if let Some(action) = action_clause(dialect, "ON DELETE", &foreign_key.on_delete, warnings) {
|
||||
sql.push(' ');
|
||||
sql.push_str(&action);
|
||||
}
|
||||
if let Some(action) = action_clause(dialect, "ON UPDATE", &foreign_key.on_update, warnings) {
|
||||
sql.push(' ');
|
||||
sql.push_str(&action);
|
||||
}
|
||||
sql.push(';');
|
||||
Some(sql)
|
||||
}
|
||||
|
||||
fn action_clause(dialect: StructureDialect, prefix: &str, value: &str, warnings: &mut Vec<String>) -> Option<String> {
|
||||
let action = normalize_action(value);
|
||||
if action.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if dialect == StructureDialect::Oracle {
|
||||
if prefix == "ON UPDATE" {
|
||||
if action != "NO ACTION" {
|
||||
warnings.push(format!("Oracle does not support {prefix} {action}."));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
return match action.as_str() {
|
||||
"CASCADE" | "SET NULL" => Some(format!("{prefix} {action}")),
|
||||
"NO ACTION" => None,
|
||||
_ => {
|
||||
warnings.push(format!("Unsupported Oracle foreign key action \"{}\".", clean(value)));
|
||||
None
|
||||
}
|
||||
};
|
||||
}
|
||||
match action.as_str() {
|
||||
"CASCADE" | "SET NULL" | "RESTRICT" | "NO ACTION" => Some(format!("{prefix} {action}")),
|
||||
_ => {
|
||||
warnings.push(format!("Unsupported foreign key action \"{}\".", clean(value)));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_action(value: &str) -> String {
|
||||
clean(value).split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_uppercase()
|
||||
}
|
||||
|
||||
fn identifier_list(value: &str) -> Vec<String> {
|
||||
value.split(',').map(clean).filter(|value| !value.is_empty()).collect()
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
use super::comments::build_sqlserver_index_comment_sql_for_profile;
|
||||
use super::dialect::{capabilities_for, database_label, database_type_for_dialect, dialect_label, StructureDialect};
|
||||
use super::types::{EditableStructureIndex, IndexInfo, TableStructureSqlOptions};
|
||||
use super::util::{clean, qualified_table, quote_ident, quote_new_ident, quote_string};
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
pub(super) fn build_index_sql(options: &TableStructureSqlOptions, warnings: &mut Vec<String>) -> Vec<String> {
|
||||
let capabilities;
|
||||
let dialect;
|
||||
if options.is_gaussdb_m_mode {
|
||||
let caps = super::dialect::gaussdb_m_capabilities();
|
||||
capabilities = caps;
|
||||
dialect = StructureDialect::GaussdbM;
|
||||
} else {
|
||||
let caps = capabilities_for(options.database_type, options.driver_profile.as_deref());
|
||||
capabilities = caps;
|
||||
dialect = caps.dialect;
|
||||
}
|
||||
let table = qualified_table(dialect, options.schema.as_deref(), &options.table_name);
|
||||
let database_label = database_label(options.database_type);
|
||||
let mut statements = Vec::new();
|
||||
|
||||
for index in &options.indexes {
|
||||
if index.marked_for_drop {
|
||||
let Some(original) = &index.original else {
|
||||
continue;
|
||||
};
|
||||
if !capabilities.drop_index {
|
||||
warnings.push(format!("Dropping indexes is not supported for {database_label} from this editor."));
|
||||
continue;
|
||||
}
|
||||
if original.is_primary {
|
||||
warnings.push(format!("Primary index \"{}\" cannot be dropped from this editor.", original.name));
|
||||
continue;
|
||||
}
|
||||
if is_dameng_constraint_backed(options.database_type, original) {
|
||||
statements.push(build_dameng_drop_constraint_sql(dialect, &table, &original.name));
|
||||
continue;
|
||||
}
|
||||
statements.push(build_drop_index_sql(
|
||||
options.database_type,
|
||||
dialect,
|
||||
&table,
|
||||
options.schema.as_deref(),
|
||||
&original.name,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(original) = &index.original {
|
||||
if !has_existing_index_change(index) {
|
||||
continue;
|
||||
}
|
||||
if !capabilities.rebuild_index || !capabilities.drop_index || !capabilities.create_index {
|
||||
warnings
|
||||
.push(format!("Editing existing indexes is not supported for {database_label} from this editor."));
|
||||
continue;
|
||||
}
|
||||
if original.is_primary {
|
||||
warnings.push(format!("Primary index \"{}\" cannot be edited from this editor.", original.name));
|
||||
continue;
|
||||
}
|
||||
if is_dameng_constraint_backed(options.database_type, original) {
|
||||
statements.extend(build_dameng_constraint_index_edit(
|
||||
options,
|
||||
dialect,
|
||||
&table,
|
||||
index,
|
||||
original,
|
||||
capabilities.index_concurrent,
|
||||
warnings,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let or_replace =
|
||||
options.database_type == Some(DatabaseType::Dameng) && clean(&index.name) == clean(&original.name);
|
||||
if !or_replace {
|
||||
statements.push(build_drop_index_sql(
|
||||
options.database_type,
|
||||
dialect,
|
||||
&table,
|
||||
options.schema.as_deref(),
|
||||
&original.name,
|
||||
));
|
||||
}
|
||||
statements.extend(build_create_index_statements(
|
||||
options.database_type,
|
||||
dialect,
|
||||
&table,
|
||||
index,
|
||||
warnings,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
or_replace,
|
||||
capabilities.index_concurrent,
|
||||
false,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
if !capabilities.create_index {
|
||||
warnings.push(format!("Creating indexes is not supported for {database_label} from this editor."));
|
||||
continue;
|
||||
}
|
||||
statements.extend(build_create_index_statements(
|
||||
options.database_type,
|
||||
dialect,
|
||||
&table,
|
||||
index,
|
||||
warnings,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
false,
|
||||
capabilities.index_concurrent,
|
||||
false,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
/// Dameng builds the index behind a PRIMARY KEY / UNIQUE constraint as a "virtual" index
|
||||
/// owned by that constraint. Index-level DDL against such an index — `DROP INDEX` as much as
|
||||
/// `CREATE OR REPLACE INDEX` — is rejected with a misleading "no permission to drop index"
|
||||
/// error even for the owning user (#7959); it can only be changed through
|
||||
/// `ALTER TABLE ... ADD/DROP CONSTRAINT`, the same way Dameng primary keys are already
|
||||
/// handled in `columns.rs`.
|
||||
///
|
||||
/// A "real" unique index (`CREATE UNIQUE INDEX`, which is also what this editor emits for a
|
||||
/// newly created unique index) has no constraint behind it, is not reported as
|
||||
/// constraint-backed by introspection, and keeps the index-level path — constraint DDL would
|
||||
/// fail on it with "constraint does not exist".
|
||||
fn is_dameng_constraint_backed(database_type: Option<DatabaseType>, original: &IndexInfo) -> bool {
|
||||
database_type == Some(DatabaseType::Dameng) && original.constraint_backed
|
||||
}
|
||||
|
||||
fn build_dameng_drop_constraint_sql(dialect: StructureDialect, table: &str, constraint_name: &str) -> String {
|
||||
format!("ALTER TABLE {table} DROP CONSTRAINT {};", quote_ident(dialect, constraint_name))
|
||||
}
|
||||
|
||||
/// Rewrites an edit of a constraint-backed Dameng index as constraint DDL: drop the
|
||||
/// constraint, then re-add it as `UNIQUE` (still unique) or replace it with an ordinary
|
||||
/// index (downgraded to a plain index, where index-level DDL is fine again because the
|
||||
/// constraint is gone).
|
||||
fn build_dameng_constraint_index_edit(
|
||||
options: &TableStructureSqlOptions,
|
||||
dialect: StructureDialect,
|
||||
table: &str,
|
||||
index: &EditableStructureIndex,
|
||||
original: &IndexInfo,
|
||||
concurrently_supported: bool,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Vec<String> {
|
||||
let name = clean(&index.name);
|
||||
let columns: Vec<String> =
|
||||
index.columns.iter().map(|column| clean(column)).filter(|column| !column.is_empty()).collect();
|
||||
// Same guard as `build_create_index_statements`: an empty name or an empty column list
|
||||
// cannot produce a valid replacement (`validate_draft` already reports it as a warning).
|
||||
// Emitting only the DROP would silently delete the constraint, so skip the edit entirely.
|
||||
if name.is_empty() || columns.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut statements = vec![build_dameng_drop_constraint_sql(dialect, table, &original.name)];
|
||||
if !index.is_unique {
|
||||
statements.extend(build_create_index_statements(
|
||||
options.database_type,
|
||||
dialect,
|
||||
table,
|
||||
index,
|
||||
warnings,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
false,
|
||||
concurrently_supported,
|
||||
false,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
return statements;
|
||||
}
|
||||
|
||||
// `build_create_index_statements` would honor BITMAP for Dameng, but a unique constraint
|
||||
// always builds a normal index behind itself, so the type cannot be carried over.
|
||||
if normalized_index_type(index) == "BITMAP" {
|
||||
warnings.push(format!(
|
||||
"Index type BITMAP is ignored for unique index \"{name}\": Dameng enforces it with a unique constraint, whose index cannot be a bitmap index."
|
||||
));
|
||||
}
|
||||
let cols = columns.iter().map(|column| quote_ident(dialect, column)).collect::<Vec<_>>().join(", ");
|
||||
statements.push(format!("ALTER TABLE {table} ADD CONSTRAINT {} UNIQUE ({cols});", quote_ident(dialect, &name)));
|
||||
statements
|
||||
}
|
||||
|
||||
pub fn has_existing_index_change(index: &EditableStructureIndex) -> bool {
|
||||
let Some(original) = &index.original else {
|
||||
return false;
|
||||
};
|
||||
clean(&index.name) != clean(&original.name)
|
||||
|| index_list_changed(&index.columns, Some(&original.columns))
|
||||
|| index.is_unique != original.is_unique
|
||||
|| normalized_index_type(index) != clean(original.index_type.as_deref().unwrap_or("")).to_ascii_uppercase()
|
||||
|| index_list_changed(&index.included_columns, original.included_columns.as_ref())
|
||||
|| clean(&index.filter) != clean(original.filter.as_deref().unwrap_or(""))
|
||||
|| clean(&index.comment) != clean(original.comment.as_deref().unwrap_or(""))
|
||||
|| index_opclasses_changed(
|
||||
&index.column_opclasses,
|
||||
&original.column_opclasses,
|
||||
&index.columns,
|
||||
&original.columns,
|
||||
)
|
||||
}
|
||||
|
||||
/// Compare opclass arrays positionally, skipping expression columns.
|
||||
/// When the column list has changed we already detect that above; here we only
|
||||
/// compare opclasses for columns that exist in both the edited and original lists.
|
||||
fn index_opclasses_changed(
|
||||
next: &[Option<String>],
|
||||
previous: &[Option<String>],
|
||||
next_cols: &[String],
|
||||
prev_cols: &[String],
|
||||
) -> bool {
|
||||
// If the edited side has explicit opclasses, compare positionally.
|
||||
if !next.is_empty() {
|
||||
// Length mismatch (shouldn't happen when columns are in lockstep, but
|
||||
// defend against it): any difference in length is a change.
|
||||
if next.len() != previous.len() {
|
||||
return true;
|
||||
}
|
||||
return next.iter().zip(previous.iter()).any(|(n, p)| {
|
||||
let n_flat = n.as_deref().map(clean).filter(|v| !v.is_empty());
|
||||
let p_flat = p.as_deref().map(clean).filter(|v| !v.is_empty());
|
||||
n_flat != p_flat
|
||||
});
|
||||
}
|
||||
// If the edited side has no opclasses, check whether the original side
|
||||
// had any non-default opclasses for columns still present.
|
||||
if previous.iter().any(|o| o.as_deref().map(|v| !clean(v).is_empty()).unwrap_or(false)) {
|
||||
let prev_set: std::collections::HashSet<_> = prev_cols.iter().map(|c| clean(c)).collect();
|
||||
return next_cols.iter().zip(previous.iter()).any(|(nc, po)| {
|
||||
po.as_deref().map(|v| !clean(v).is_empty()).unwrap_or(false) && prev_set.contains(&clean(nc))
|
||||
});
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn index_list_changed(next: &[String], previous: Option<&Vec<String>>) -> bool {
|
||||
let next_clean: Vec<_> = next.iter().map(|value| clean(value)).filter(|value| !value.is_empty()).collect();
|
||||
let previous_clean: Vec<_> =
|
||||
previous.unwrap_or(&Vec::new()).iter().map(|value| clean(value)).filter(|value| !value.is_empty()).collect();
|
||||
next_clean.len() != previous_clean.len()
|
||||
|| next_clean.iter().enumerate().any(|(index, value)| previous_clean.get(index) != Some(value))
|
||||
}
|
||||
|
||||
pub(super) fn normalized_index_type(index: &EditableStructureIndex) -> String {
|
||||
clean(&index.index_type).to_ascii_uppercase()
|
||||
}
|
||||
|
||||
pub(super) fn mysql_index_parts(index_type: &str) -> (String, String) {
|
||||
match index_type.to_ascii_uppercase().as_str() {
|
||||
"FULLTEXT" | "SPATIAL" => (format!("{} ", index_type.to_ascii_uppercase()), String::new()),
|
||||
"RTREE" => ("SPATIAL ".to_string(), String::new()),
|
||||
"BTREE" | "HASH" => (String::new(), format!(" USING {}", index_type.to_ascii_uppercase())),
|
||||
_ => (String::new(), String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn gaussdbm_index_parts(index_type: &str) -> (String, String) {
|
||||
match index_type.to_ascii_uppercase().as_str() {
|
||||
"BTREE" | "UBTREE" => (String::new(), " USING UBTREE".to_string()),
|
||||
"HASH" => (String::new(), " USING HASH".to_string()),
|
||||
_ => (String::new(), String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn gaussdbm_index_column_sql(column: &str, is_expression: bool) -> String {
|
||||
let trimmed = column.trim();
|
||||
if is_expression {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
if let Some((name, suffix)) = trimmed.rsplit_once('(') {
|
||||
if let Some(length) = suffix.strip_suffix(')') {
|
||||
if !name.trim().is_empty() && !length.is_empty() && length.chars().all(|ch| ch.is_ascii_digit()) {
|
||||
return format!("{}({length})", quote_ident(StructureDialect::GaussdbM, name.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
quote_ident(StructureDialect::GaussdbM, trimmed)
|
||||
}
|
||||
|
||||
fn mysql_index_column_sql(column: &str) -> String {
|
||||
let trimmed = column.trim();
|
||||
// Keep the wrapped expression from MySQL metadata instead of quoting it as a column identifier.
|
||||
if trimmed.starts_with("((") && trimmed.ends_with("))") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
quote_ident(StructureDialect::Mysql, column)
|
||||
}
|
||||
}
|
||||
|
||||
fn postgres_index_column_sql(column: &str, is_expression: bool, opclass: Option<&str>) -> String {
|
||||
// The base key text: a real column is quoted as an identifier; an expression/functional
|
||||
// key part arrives as raw expression text (from the per-column `pg_get_indexdef`, which
|
||||
// omits the opclass — see `list_indexes_with_sql`), so quoting the whole thing as an
|
||||
// identifier would turn it into a nonexistent column reference (#6295).
|
||||
let base =
|
||||
if is_expression { column.trim().to_string() } else { quote_ident(StructureDialect::Postgres, column.trim()) };
|
||||
// The opclass is read separately from `pg_index.indclass` for every key position
|
||||
// (including expression keys) and appended uniformly — it never lives inside the
|
||||
// expression text, so there is no duplication risk.
|
||||
match opclass.filter(|o| !o.is_empty()) {
|
||||
Some(opc) => format!("{} {}", base, opc),
|
||||
None => base,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-key expression provenance for `columns` (the edited/current key list), computed once for
|
||||
/// the whole index. The editor only lets users toggle real table columns onto an index (see
|
||||
/// TableStructureEditor.vue's column checkbox list) — there is no free-text expression input —
|
||||
/// so an edited key part is only ever an expression when it corresponds to one from the original
|
||||
/// introspected snapshot.
|
||||
///
|
||||
/// Matches each edited key part against the first *not yet claimed* original key part with the
|
||||
/// same text, rather than the first textual match overall: a real column whose name happens to
|
||||
/// equal another key's expression text (or two key parts with identical text) can otherwise steal
|
||||
/// the wrong original's provenance. Consuming each original slot at most once keeps provenance
|
||||
/// tied to its true ordinal position instead of being re-derived by scanning for a text match
|
||||
/// (#6312 review). A key without explicit provenance is treated as a real column: the editor can
|
||||
/// only add table columns, and guessing from the identifier text breaks valid quoted names.
|
||||
fn key_expression_flags(index: &EditableStructureIndex, columns: &[String]) -> Vec<bool> {
|
||||
let original = match &index.original {
|
||||
Some(original) if !original.key_is_expression.is_empty() => original,
|
||||
_ => return vec![false; columns.len()],
|
||||
};
|
||||
let mut consumed = vec![false; original.columns.len()];
|
||||
columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let claimed = original
|
||||
.columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(i, original_column)| !consumed[*i] && *original_column == column);
|
||||
match claimed {
|
||||
Some((i, _)) => {
|
||||
consumed[i] = true;
|
||||
original.key_is_expression.get(i).copied().unwrap_or(false)
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Per-key operator class provenance for `columns` (the edited/current key list).
|
||||
///
|
||||
/// Honors explicit per-key opclasses (`index.column_opclasses`) positionally when
|
||||
/// they are aligned to `columns` (non-empty, same length) and represent a genuine
|
||||
/// edit: either there is no `original` (a newly created index, where the UI
|
||||
/// authors `column_opclasses` in lockstep with `columns`), or the edited
|
||||
/// opclasses differ from the round-tripped `original.column_opclasses`. A stale
|
||||
/// copy left behind by a column toggle/reorder — still equal to the original —
|
||||
/// falls through to the order-independent text-matching below, so opclasses are
|
||||
/// never misassigned positionally before the editor maintains `column_opclasses`
|
||||
/// in lockstep with `columns`. Once that lockstep is in place the guard always
|
||||
/// passes and the honor branch is authoritative.
|
||||
fn key_opclasses(index: &EditableStructureIndex, columns: &[String]) -> Vec<Option<String>> {
|
||||
if !index.column_opclasses.is_empty()
|
||||
&& index.column_opclasses.len() == columns.len()
|
||||
&& index.original.as_ref().is_none_or(|original| original.column_opclasses != index.column_opclasses)
|
||||
{
|
||||
return index.column_opclasses.to_vec();
|
||||
}
|
||||
let original = match &index.original {
|
||||
Some(original) if !original.column_opclasses.is_empty() => original,
|
||||
_ => return vec![None; columns.len()],
|
||||
};
|
||||
let mut consumed = vec![false; original.columns.len()];
|
||||
columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let claimed = original
|
||||
.columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(i, original_column)| !consumed[*i] && *original_column == column);
|
||||
match claimed {
|
||||
Some((i, _)) => {
|
||||
consumed[i] = true;
|
||||
original.column_opclasses.get(i).cloned().flatten()
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn build_drop_index_sql(
|
||||
database_type: Option<DatabaseType>,
|
||||
dialect: StructureDialect,
|
||||
table: &str,
|
||||
schema: Option<&str>,
|
||||
index_name: &str,
|
||||
) -> String {
|
||||
if database_type == Some(DatabaseType::Iris) {
|
||||
return format!("DROP INDEX {} ON TABLE {table};", quote_ident(dialect, index_name));
|
||||
}
|
||||
if matches!(dialect, StructureDialect::Mysql | StructureDialect::SqlServer) {
|
||||
return format!("DROP INDEX {} ON {table};", quote_ident(dialect, index_name));
|
||||
}
|
||||
if matches!(
|
||||
dialect,
|
||||
StructureDialect::Postgres
|
||||
| StructureDialect::Oracle
|
||||
| StructureDialect::Dameng
|
||||
| StructureDialect::Oscar
|
||||
| StructureDialect::Informix
|
||||
| StructureDialect::Sqlite
|
||||
) && schema.is_some_and(|schema| !schema.trim().is_empty())
|
||||
{
|
||||
return format!("DROP INDEX {}.{};", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, index_name));
|
||||
}
|
||||
format!("DROP INDEX {};", quote_ident(dialect, index_name))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn build_create_index_statements(
|
||||
database_type: Option<DatabaseType>,
|
||||
dialect: StructureDialect,
|
||||
table: &str,
|
||||
index: &EditableStructureIndex,
|
||||
warnings: &mut Vec<String>,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
or_replace: bool,
|
||||
concurrently_supported: bool,
|
||||
for_new_table: bool,
|
||||
driver_profile: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let capabilities = capabilities_for(database_type_for_dialect(dialect), None);
|
||||
let name = clean(&index.name);
|
||||
let columns: Vec<String> =
|
||||
index.columns.iter().map(|column| clean(column)).filter(|column| !column.is_empty()).collect();
|
||||
if name.is_empty() || columns.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// `CREATE INDEX CONCURRENTLY` is PostgreSQL-only: the flag is honored only when
|
||||
// the caller's real database type advertises the capability, so a stale or
|
||||
// forged `concurrently: true` from another engine is ignored and the original
|
||||
// SQL is generated unchanged. Unsupported concurrent requests (existing index,
|
||||
// partitioned parent) are rejected up front by `validate_concurrent_index_scope`
|
||||
// before any statement is built — this function never downgrades them.
|
||||
let concurrently = index.concurrently && concurrently_supported && dialect == StructureDialect::Postgres;
|
||||
|
||||
let unique = if index.is_unique { "UNIQUE " } else { "" };
|
||||
let replace = if or_replace { "OR REPLACE " } else { "" };
|
||||
let key_is_expression = key_expression_flags(index, &columns);
|
||||
let key_opclasses = key_opclasses(index, &columns);
|
||||
let cols = columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, column)| {
|
||||
if dialect == StructureDialect::Mysql {
|
||||
mysql_index_column_sql(column)
|
||||
} else if dialect == StructureDialect::GaussdbM {
|
||||
gaussdbm_index_column_sql(column, key_is_expression[i])
|
||||
} else if dialect == StructureDialect::Postgres {
|
||||
postgres_index_column_sql(column, key_is_expression[i], key_opclasses[i].as_deref())
|
||||
} else if for_new_table {
|
||||
quote_new_ident(database_type, dialect, column)
|
||||
} else {
|
||||
quote_ident(dialect, column)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let idx_type = normalized_index_type(index);
|
||||
let mut type_prefix = String::new();
|
||||
let mut using_clause = String::new();
|
||||
|
||||
if !idx_type.is_empty() && capabilities.index_type {
|
||||
match dialect {
|
||||
StructureDialect::Postgres => using_clause = format!(" USING {idx_type}"),
|
||||
StructureDialect::SqlServer => type_prefix = format!("{idx_type} "),
|
||||
StructureDialect::Mysql => {
|
||||
let (prefix, using) = mysql_index_parts(&idx_type);
|
||||
type_prefix = prefix;
|
||||
using_clause = using;
|
||||
}
|
||||
StructureDialect::Oracle | StructureDialect::Dameng if idx_type == "BITMAP" => {
|
||||
type_prefix = "BITMAP ".to_string()
|
||||
}
|
||||
StructureDialect::GaussdbM => {
|
||||
let (prefix, using) = gaussdbm_index_parts(&idx_type);
|
||||
type_prefix = prefix;
|
||||
using_clause = using;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let included_columns: Vec<String> =
|
||||
index.included_columns.iter().map(|column| clean(column)).filter(|column| !column.is_empty()).collect();
|
||||
let include_clause = if !included_columns.is_empty()
|
||||
&& capabilities.index_include
|
||||
&& matches!(dialect, StructureDialect::Postgres | StructureDialect::SqlServer)
|
||||
{
|
||||
format!(
|
||||
" INCLUDE ({})",
|
||||
included_columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
if for_new_table {
|
||||
quote_new_ident(database_type, dialect, column)
|
||||
} else {
|
||||
quote_ident(dialect, column)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let comment = clean(&index.comment);
|
||||
let comment_clause = if !comment.is_empty()
|
||||
&& capabilities.index_comment
|
||||
&& matches!(dialect, StructureDialect::Mysql | StructureDialect::GaussdbM)
|
||||
{
|
||||
format!(" COMMENT {}", quote_string(&comment))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let filter = clean(&index.filter);
|
||||
let supports_where = capabilities.index_filter
|
||||
&& matches!(dialect, StructureDialect::Postgres | StructureDialect::SqlServer | StructureDialect::Sqlite);
|
||||
let where_clause = if !filter.is_empty() && supports_where { format!(" WHERE {filter}") } else { String::new() };
|
||||
let quoted_index_name =
|
||||
if for_new_table { quote_new_ident(database_type, dialect, &name) } else { quote_ident(dialect, &name) };
|
||||
let index_name = if dialect == StructureDialect::Sqlite && schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
||||
format!("{}.{}", quote_ident(dialect, schema.unwrap()), quoted_index_name)
|
||||
} else {
|
||||
quoted_index_name
|
||||
};
|
||||
// SQLite assigns an index to the database named by the qualified index.
|
||||
// Its CREATE INDEX grammar does not allow a qualified table name.
|
||||
let create_table =
|
||||
if dialect == StructureDialect::Sqlite { quote_ident(dialect, table_name) } else { table.to_string() };
|
||||
let create_sql = if dialect == StructureDialect::Postgres {
|
||||
let concurrent_clause = if concurrently { "CONCURRENTLY " } else { "" };
|
||||
format!(
|
||||
"CREATE {replace}{unique}INDEX {concurrent_clause}{index_name} ON {create_table}{using_clause} ({cols}){include_clause}{where_clause};"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"CREATE {replace}{unique}{type_prefix}INDEX {index_name}{using_clause} ON {create_table} ({cols}){include_clause}{where_clause}{comment_clause};"
|
||||
)
|
||||
};
|
||||
let mut statements = vec![create_sql];
|
||||
|
||||
if !comment.is_empty() && capabilities.index_comment && dialect == StructureDialect::Postgres {
|
||||
statements.push(format!("COMMENT ON INDEX {} IS {};", quote_ident(dialect, &name), quote_string(&comment)));
|
||||
} else if !comment.is_empty() && capabilities.index_comment && dialect == StructureDialect::SqlServer {
|
||||
statements.extend(build_sqlserver_index_comment_sql_for_profile(
|
||||
table,
|
||||
schema,
|
||||
table_name,
|
||||
&name,
|
||||
&comment,
|
||||
driver_profile,
|
||||
));
|
||||
} else if !comment.is_empty()
|
||||
&& capabilities.index_comment
|
||||
&& matches!(dialect, StructureDialect::Mysql | StructureDialect::GaussdbM)
|
||||
{
|
||||
// Comment is embedded inline in the CREATE INDEX statement above
|
||||
} else if !comment.is_empty() && capabilities.index_comment {
|
||||
warnings.push(format!("Index comments are not supported for {} from this editor.", dialect_label(dialect)));
|
||||
}
|
||||
statements
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use super::dialect::StructureDialect;
|
||||
use super::types::TableStructureSqlOptions;
|
||||
use super::util::qualified_table;
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
pub(super) fn validate_mysql_engine(options: &TableStructureSqlOptions) -> Vec<String> {
|
||||
let Some(engine) = options.mysql_engine.as_deref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let engine = engine.trim();
|
||||
if options.database_type != Some(DatabaseType::Mysql) || options.is_gaussdb_m_mode {
|
||||
return vec!["Changing the table engine is supported only for native MySQL connections.".to_string()];
|
||||
}
|
||||
if engine.is_empty() {
|
||||
return vec!["MySQL table engine is required.".to_string()];
|
||||
}
|
||||
if engine.len() > 64 || !engine.chars().all(|character| character.is_ascii_alphanumeric() || character == '_') {
|
||||
return vec!["MySQL table engine contains invalid characters.".to_string()];
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub(super) fn build_mysql_engine_change_sql(options: &TableStructureSqlOptions) -> Vec<String> {
|
||||
let Some(engine) = options.mysql_engine.as_deref().map(str::trim).filter(|engine| !engine.is_empty()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let table = qualified_table(StructureDialect::Mysql, options.schema.as_deref(), &options.table_name);
|
||||
vec![format!("ALTER TABLE {table} ENGINE = {engine};")]
|
||||
}
|
||||
|
||||
pub(super) fn append_mysql_table_option(statement: &mut String, option: &str) {
|
||||
if statement.ends_with(';') {
|
||||
statement.pop();
|
||||
}
|
||||
statement.push(' ');
|
||||
statement.push_str(option);
|
||||
statement.push(';');
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use super::dialect::StructureDialect;
|
||||
use super::types::{TableOwnerChangeSqlOptions, TableStructureSqlResult};
|
||||
use super::util::{qualified_table, quote_ident};
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
pub fn build_table_owner_change_sql(options: TableOwnerChangeSqlOptions) -> TableStructureSqlResult {
|
||||
let owner = &options.owner;
|
||||
if owner == &options.original_owner {
|
||||
return TableStructureSqlResult { statements: Vec::new(), warnings: Vec::new() };
|
||||
}
|
||||
if owner.is_empty() {
|
||||
return TableStructureSqlResult {
|
||||
statements: Vec::new(),
|
||||
warnings: vec!["Table owner cannot be empty.".to_string()],
|
||||
};
|
||||
}
|
||||
if options.database_type != Some(DatabaseType::Postgres) {
|
||||
return TableStructureSqlResult {
|
||||
statements: Vec::new(),
|
||||
warnings: vec!["Changing the table owner is currently supported only for PostgreSQL.".to_string()],
|
||||
};
|
||||
}
|
||||
|
||||
let table = qualified_table(StructureDialect::Postgres, options.schema.as_deref(), &options.table_name);
|
||||
let owner = quote_ident(StructureDialect::Postgres, owner);
|
||||
TableStructureSqlResult { statements: vec![format!("ALTER TABLE {table} OWNER TO {owner};")], warnings: Vec::new() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn options(owner: &str, original_owner: &str) -> TableOwnerChangeSqlOptions {
|
||||
TableOwnerChangeSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: Some("app schema".to_string()),
|
||||
table_name: "order\"items".to_string(),
|
||||
owner: owner.to_string(),
|
||||
original_owner: original_owner.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_quoted_postgres_owner_change() {
|
||||
let result = build_table_owner_change_sql(options("reporting\"role", "app_user"));
|
||||
assert!(result.warnings.is_empty());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec!["ALTER TABLE \"app schema\".\"order\"\"items\" OWNER TO \"reporting\"\"role\";".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_whitespace_in_postgres_role_names() {
|
||||
let changed = build_table_owner_change_sql(options(" app_user ", "app_user"));
|
||||
assert!(changed.warnings.is_empty());
|
||||
assert_eq!(
|
||||
changed.statements,
|
||||
vec!["ALTER TABLE \"app schema\".\"order\"\"items\" OWNER TO \" app_user \";".to_string()]
|
||||
);
|
||||
|
||||
let unchanged = build_table_owner_change_sql(options(" app_user ", " app_user "));
|
||||
assert!(unchanged.statements.is_empty());
|
||||
assert!(unchanged.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_or_unsupported_owner_changes() {
|
||||
let empty = build_table_owner_change_sql(options("", "app_user"));
|
||||
assert!(empty.statements.is_empty());
|
||||
assert_eq!(empty.warnings, vec!["Table owner cannot be empty."]);
|
||||
|
||||
let mut unsupported = options("reporting", "app_user");
|
||||
unsupported.database_type = Some(DatabaseType::Mysql);
|
||||
let unsupported = build_table_owner_change_sql(unsupported);
|
||||
assert!(unsupported.statements.is_empty());
|
||||
assert_eq!(unsupported.warnings, vec!["Changing the table owner is currently supported only for PostgreSQL."]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,363 @@
|
||||
use super::dialect::{database_label, StructureDialect};
|
||||
use super::types::{EditableStructureTrigger, TableStructureSqlOptions, TriggerInfo};
|
||||
use super::util::{clean, qualified_new_table, qualified_table, quote_ident, quote_new_ident};
|
||||
|
||||
pub(super) fn build_trigger_sql(options: &TableStructureSqlOptions, warnings: &mut Vec<String>) -> Vec<String> {
|
||||
build_trigger_sql_with_mode(options, warnings, false)
|
||||
}
|
||||
|
||||
pub(super) fn build_trigger_sql_for_new_table(
|
||||
options: &TableStructureSqlOptions,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Vec<String> {
|
||||
build_trigger_sql_with_mode(options, warnings, true)
|
||||
}
|
||||
|
||||
fn build_trigger_sql_with_mode(
|
||||
options: &TableStructureSqlOptions,
|
||||
warnings: &mut Vec<String>,
|
||||
for_new_table: bool,
|
||||
) -> Vec<String> {
|
||||
if options.triggers.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let dialect = super::dialect::capabilities_for(options.database_type, options.driver_profile.as_deref()).dialect;
|
||||
let database_label = database_label(options.database_type);
|
||||
if !matches!(dialect, StructureDialect::Mysql | StructureDialect::Oracle | StructureDialect::SqlServer) {
|
||||
if options.triggers.iter().any(has_trigger_edit) {
|
||||
warnings.push(format!("Editing triggers is not supported for {database_label} from this editor."));
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let table = if for_new_table {
|
||||
qualified_new_table(options.database_type, dialect, options.schema.as_deref(), &options.table_name)
|
||||
} else {
|
||||
qualified_table(dialect, options.schema.as_deref(), &options.table_name)
|
||||
};
|
||||
let mut statements = Vec::new();
|
||||
|
||||
for trigger in &options.triggers {
|
||||
if trigger.marked_for_drop {
|
||||
if let Some(original) = &trigger.original {
|
||||
statements.push(drop_trigger_sql(dialect, options.schema.as_deref(), &original.name));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(original) = &trigger.original {
|
||||
if dialect == StructureDialect::Oracle {
|
||||
if has_trigger_change(trigger, original) {
|
||||
warnings.push(format!(
|
||||
"Editing existing Oracle trigger \"{}\" requires its complete source definition.",
|
||||
original.name
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !has_trigger_change(trigger, original) {
|
||||
continue;
|
||||
}
|
||||
// Oracle can replace a trigger in place; renames still require dropping the old object.
|
||||
if dialect != StructureDialect::Oracle || clean(&trigger.name) != clean(&original.name) {
|
||||
statements.push(drop_trigger_sql(dialect, options.schema.as_deref(), &original.name));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sql) = create_trigger_sql(
|
||||
options.database_type,
|
||||
dialect,
|
||||
options.schema.as_deref(),
|
||||
&table,
|
||||
trigger,
|
||||
warnings,
|
||||
for_new_table,
|
||||
) {
|
||||
statements.push(sql);
|
||||
// SQL Server rebuilds via DROP + CREATE, which resets is_disabled to
|
||||
// enabled; restore the catalog-reported disabled state explicitly.
|
||||
// `table` is already qualified the same way the CREATE's ON clause is.
|
||||
if dialect == StructureDialect::SqlServer
|
||||
&& trigger.original.as_ref().is_some_and(|original| original.enabled == Some(false))
|
||||
{
|
||||
let schema = options.schema.as_deref();
|
||||
let trigger_name = qualified_trigger_object_name(dialect, schema, &trigger.name);
|
||||
statements.push(format!("DISABLE TRIGGER {trigger_name} ON {table};"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
fn qualified_trigger_object_name(dialect: StructureDialect, schema: Option<&str>, name: &str) -> String {
|
||||
if schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
||||
format!("{}.{}", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, name))
|
||||
} else {
|
||||
quote_ident(dialect, name)
|
||||
}
|
||||
}
|
||||
|
||||
fn has_trigger_edit(trigger: &EditableStructureTrigger) -> bool {
|
||||
trigger.marked_for_drop || trigger.original.as_ref().is_none_or(|original| has_trigger_change(trigger, original))
|
||||
}
|
||||
|
||||
fn has_trigger_change(trigger: &EditableStructureTrigger, original: &TriggerInfo) -> bool {
|
||||
clean(&trigger.name) != clean(&original.name)
|
||||
|| normalize_keyword(&trigger.timing) != normalize_keyword(&original.timing)
|
||||
|| normalize_keyword(&trigger.event) != normalize_keyword(&original.event)
|
||||
|| normalize_statement(&trigger.statement) != normalize_statement(original.statement.as_deref().unwrap_or(""))
|
||||
}
|
||||
|
||||
fn drop_trigger_sql(dialect: StructureDialect, schema: Option<&str>, name: &str) -> String {
|
||||
let qualified_name = if schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
||||
format!("{}.{}", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, name))
|
||||
} else {
|
||||
quote_ident(dialect, name)
|
||||
};
|
||||
format!("DROP TRIGGER {qualified_name};")
|
||||
}
|
||||
|
||||
fn create_trigger_sql(
|
||||
database_type: Option<crate::models::connection::DatabaseType>,
|
||||
dialect: StructureDialect,
|
||||
schema: Option<&str>,
|
||||
table: &str,
|
||||
trigger: &EditableStructureTrigger,
|
||||
warnings: &mut Vec<String>,
|
||||
for_new_table: bool,
|
||||
) -> Option<String> {
|
||||
let name = clean(&trigger.name);
|
||||
let timing = normalize_keyword(&trigger.timing);
|
||||
let event = clean(&trigger.event);
|
||||
let statement = clean(&trigger.statement);
|
||||
|
||||
if name.is_empty() || timing.is_empty() || event.is_empty() || statement.is_empty() {
|
||||
warnings.push("Trigger name, timing, event, and statement are required.".to_string());
|
||||
return None;
|
||||
}
|
||||
match dialect {
|
||||
StructureDialect::Mysql => create_mysql_trigger_sql(table, &name, &timing, &event, &statement, warnings),
|
||||
StructureDialect::SqlServer => {
|
||||
create_sqlserver_trigger_sql(schema, table, &name, &timing, &event, &statement, warnings)
|
||||
}
|
||||
StructureDialect::Oracle => create_oracle_trigger_sql(
|
||||
database_type,
|
||||
schema,
|
||||
table,
|
||||
&name,
|
||||
&timing,
|
||||
&event,
|
||||
&statement,
|
||||
warnings,
|
||||
for_new_table,
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_sqlserver_trigger_sql(
|
||||
schema: Option<&str>,
|
||||
table: &str,
|
||||
name: &str,
|
||||
timing: &str,
|
||||
event: &str,
|
||||
statement: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Option<String> {
|
||||
if !matches!(timing, "AFTER" | "INSTEAD OF") {
|
||||
warnings.push(format!("Unsupported SQL Server trigger timing \"{timing}\"."));
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut events = Vec::new();
|
||||
for item in event.split([',', '\n']) {
|
||||
let item = item.trim();
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let normalized_item = item.to_ascii_uppercase();
|
||||
for event in normalized_item.split(" OR ") {
|
||||
let event = event.trim().to_string();
|
||||
if !matches!(event.as_str(), "INSERT" | "UPDATE" | "DELETE") {
|
||||
warnings.push(format!("Unsupported SQL Server trigger event \"{}\".", item));
|
||||
return None;
|
||||
}
|
||||
if !events.contains(&event) {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
if events.is_empty() {
|
||||
warnings.push("SQL Server trigger event is required.".to_string());
|
||||
return None;
|
||||
}
|
||||
|
||||
let trigger_name = if schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
||||
format!(
|
||||
"{}.{}",
|
||||
quote_ident(StructureDialect::SqlServer, schema.unwrap()),
|
||||
quote_ident(StructureDialect::SqlServer, name)
|
||||
)
|
||||
} else {
|
||||
quote_ident(StructureDialect::SqlServer, name)
|
||||
};
|
||||
let body = sqlserver_trigger_body(statement);
|
||||
if body.is_empty() {
|
||||
warnings.push("SQL Server trigger statement is required.".to_string());
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"CREATE TRIGGER {trigger_name} ON {table} {timing} {} AS\n{};",
|
||||
events.join(", "),
|
||||
body.trim_end_matches(';').trim_end()
|
||||
))
|
||||
}
|
||||
|
||||
fn sqlserver_trigger_body(statement: &str) -> &str {
|
||||
let statement = statement.trim();
|
||||
if statement.get(..2).is_some_and(|prefix| prefix.eq_ignore_ascii_case("AS")) {
|
||||
return statement[2..].trim_start();
|
||||
}
|
||||
if !statement.to_ascii_uppercase().starts_with("CREATE TRIGGER") {
|
||||
return statement;
|
||||
}
|
||||
|
||||
// The body separator is the standalone AS that follows the event list
|
||||
// (`... AFTER INSERT, UPDATE AS <body>`); earlier AS tokens can appear in
|
||||
// trigger options such as `WITH EXECUTE AS OWNER`, so the first standalone
|
||||
// AS alone is not a reliable split point.
|
||||
let mut seen_event = false;
|
||||
let mut token = String::new();
|
||||
let mut char_indices = statement.char_indices().peekable();
|
||||
while let Some((index, ch)) = char_indices.next() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' {
|
||||
token.push(ch);
|
||||
if !char_indices.peek().is_some_and(|(_, next)| next.is_ascii_alphanumeric() || *next == '_') {
|
||||
if token.eq_ignore_ascii_case("AS") && seen_event {
|
||||
return statement[index + 1..].trim_start();
|
||||
}
|
||||
if matches!(token.to_ascii_uppercase().as_str(), "INSERT" | "UPDATE" | "DELETE" | "LOGON") {
|
||||
seen_event = true;
|
||||
}
|
||||
token.clear();
|
||||
}
|
||||
} else {
|
||||
token.clear();
|
||||
}
|
||||
}
|
||||
statement
|
||||
}
|
||||
|
||||
fn create_mysql_trigger_sql(
|
||||
table: &str,
|
||||
name: &str,
|
||||
timing: &str,
|
||||
event: &str,
|
||||
statement: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Option<String> {
|
||||
let event = normalize_keyword(event);
|
||||
if !matches!(timing, "BEFORE" | "AFTER") {
|
||||
warnings.push(format!("Unsupported trigger timing \"{timing}\"."));
|
||||
return None;
|
||||
}
|
||||
if !matches!(event.as_str(), "INSERT" | "UPDATE" | "DELETE") {
|
||||
warnings.push(format!("Unsupported trigger event \"{}\".", clean(&event)));
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"CREATE TRIGGER {} {timing} {event} ON {table} FOR EACH ROW\n{};",
|
||||
quote_ident(StructureDialect::Mysql, name),
|
||||
statement.trim_end_matches(';').trim_end()
|
||||
))
|
||||
}
|
||||
|
||||
fn create_oracle_trigger_sql(
|
||||
database_type: Option<crate::models::connection::DatabaseType>,
|
||||
schema: Option<&str>,
|
||||
table: &str,
|
||||
name: &str,
|
||||
timing: &str,
|
||||
event: &str,
|
||||
statement: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
for_new_table: bool,
|
||||
) -> Option<String> {
|
||||
let Some((timing_clause, row_level)) = oracle_trigger_timing(timing) else {
|
||||
warnings.push(format!("Unsupported Oracle trigger timing \"{timing}\"."));
|
||||
return None;
|
||||
};
|
||||
if !is_supported_oracle_trigger_event(event) {
|
||||
warnings.push(format!("Unsupported Oracle trigger event \"{}\".", clean(event)));
|
||||
return None;
|
||||
}
|
||||
|
||||
let trigger_identifier = if for_new_table {
|
||||
quote_new_ident(database_type, StructureDialect::Oracle, name)
|
||||
} else {
|
||||
quote_ident(StructureDialect::Oracle, name)
|
||||
};
|
||||
let trigger_name = if schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
||||
format!("{}.{}", quote_ident(StructureDialect::Oracle, schema.unwrap()), trigger_identifier)
|
||||
} else {
|
||||
trigger_identifier
|
||||
};
|
||||
let row_clause = if row_level { "\nFOR EACH ROW" } else { "" };
|
||||
let statement = statement.trim_end().trim_end_matches('/').trim_end().trim_end_matches(';').trim_end();
|
||||
|
||||
Some(format!(
|
||||
"CREATE OR REPLACE TRIGGER {trigger_name} {timing_clause} {event} ON {table}{row_clause}\n{statement};"
|
||||
))
|
||||
}
|
||||
|
||||
fn oracle_trigger_timing(timing: &str) -> Option<(&'static str, bool)> {
|
||||
match timing {
|
||||
"BEFORE" | "BEFORE EACH ROW" => Some(("BEFORE", true)),
|
||||
"AFTER" | "AFTER EACH ROW" => Some(("AFTER", true)),
|
||||
"INSTEAD OF" | "INSTEAD OF EACH ROW" => Some(("INSTEAD OF", true)),
|
||||
"BEFORE STATEMENT" => Some(("BEFORE", false)),
|
||||
"AFTER STATEMENT" => Some(("AFTER", false)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_supported_oracle_trigger_event(event: &str) -> bool {
|
||||
if event.contains([';', '\n', '\r']) || event.contains("--") || event.contains("/*") {
|
||||
return false;
|
||||
}
|
||||
let upper = event.to_ascii_uppercase();
|
||||
let clauses: Vec<&str> = upper.split(" OR ").map(str::trim).collect();
|
||||
!clauses.is_empty()
|
||||
&& clauses.iter().all(|clause| {
|
||||
matches!(*clause, "INSERT" | "DELETE")
|
||||
|| *clause == "UPDATE"
|
||||
|| clause.strip_prefix("UPDATE OF ").is_some_and(|columns| !columns.trim().is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_keyword(value: &str) -> String {
|
||||
clean(value).to_ascii_uppercase()
|
||||
}
|
||||
|
||||
fn normalize_statement(value: &str) -> String {
|
||||
clean(value).trim_end_matches(';').trim().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sqlserver_trigger_body_splits_after_event_list_not_execute_as() {
|
||||
// The AS inside `WITH EXECUTE AS OWNER` must not be treated as the body separator.
|
||||
let source = "CREATE TRIGGER dbo.t ON dbo.tbl WITH EXECUTE AS OWNER AFTER INSERT AS PRINT 'x'";
|
||||
assert_eq!(sqlserver_trigger_body(source), "PRINT 'x'");
|
||||
let no_options = "CREATE TRIGGER dbo.t ON dbo.tbl AFTER UPDATE AS SELECT 1 AS a";
|
||||
assert_eq!(sqlserver_trigger_body(no_options), "SELECT 1 AS a");
|
||||
let leading_as = "AS SELECT 2";
|
||||
assert_eq!(sqlserver_trigger_body(leading_as), "SELECT 2");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditableStructureColumn {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub data_type: String,
|
||||
pub is_nullable: bool,
|
||||
#[serde(default)]
|
||||
pub default_value: String,
|
||||
#[serde(default)]
|
||||
pub comment: String,
|
||||
#[serde(default)]
|
||||
pub is_primary_key: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub extra: Option<ColumnExtra>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub original: Option<ColumnInfo>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub original_position: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub marked_for_drop: bool,
|
||||
#[serde(default)]
|
||||
pub character_set: String,
|
||||
#[serde(default)]
|
||||
pub collation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ColumnExtra {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auto_increment: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_update_current_timestamp: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub identity: Option<ColumnIdentity>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manticore_indexed: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manticore_stored: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manticore_attribute: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manticore_secondary_index: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ColumnIdentity {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub generation: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub seed: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub increment: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct ColumnInfo {
|
||||
pub name: String,
|
||||
pub data_type: String,
|
||||
pub is_nullable: bool,
|
||||
pub column_default: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_primary_key: bool,
|
||||
#[serde(default)]
|
||||
pub extra: Option<String>,
|
||||
#[serde(default)]
|
||||
pub comment: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub character_set: 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 EditableStructureIndex {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub columns: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub is_unique: bool,
|
||||
#[serde(default)]
|
||||
pub is_primary: bool,
|
||||
#[serde(default)]
|
||||
pub filter: String,
|
||||
#[serde(default)]
|
||||
pub index_type: String,
|
||||
#[serde(default)]
|
||||
pub included_columns: Vec<String>,
|
||||
/// Parallel to `columns`: operator class for each key column (PostgreSQL).
|
||||
/// `None` means default operator class. The UI keeps this array in lockstep
|
||||
/// with `columns`; when empty, opclasses fall back to `original` matching.
|
||||
#[serde(default)]
|
||||
pub column_opclasses: Vec<Option<String>>,
|
||||
#[serde(default)]
|
||||
pub comment: String,
|
||||
#[serde(default)]
|
||||
pub concurrently: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub original: Option<IndexInfo>,
|
||||
#[serde(default)]
|
||||
pub marked_for_drop: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct IndexInfo {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub columns: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub is_unique: bool,
|
||||
#[serde(default)]
|
||||
pub is_primary: bool,
|
||||
#[serde(default)]
|
||||
pub filter: Option<String>,
|
||||
#[serde(default)]
|
||||
pub index_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub included_columns: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub comment: Option<String>,
|
||||
/// Parallel to `columns`: `true` at index `i` means `columns[i]` is a raw expression
|
||||
/// (e.g. sourced from `pg_get_indexdef`), not a plain column name.
|
||||
#[serde(default)]
|
||||
pub key_is_expression: Vec<bool>,
|
||||
/// Parallel to `columns`: operator class name for each key column, if non-default.
|
||||
#[serde(default)]
|
||||
pub column_opclasses: Vec<Option<String>>,
|
||||
/// Round-tripped from `crate::types::IndexInfo`: `true` when the introspected index is
|
||||
/// the object behind a PRIMARY KEY / UNIQUE constraint. Dameng only accepts constraint
|
||||
/// level DDL for those (#7959); a standalone unique index keeps the index-level path.
|
||||
/// Defaults to `false`, so a payload from an older client (or a source that does not
|
||||
/// report it) behaves exactly as before.
|
||||
#[serde(default)]
|
||||
pub constraint_backed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditableStructureForeignKey {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub column: String,
|
||||
#[serde(default)]
|
||||
pub ref_schema: String,
|
||||
#[serde(default)]
|
||||
pub ref_table: String,
|
||||
#[serde(default)]
|
||||
pub ref_column: String,
|
||||
#[serde(default)]
|
||||
pub on_update: String,
|
||||
#[serde(default)]
|
||||
pub on_delete: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub original: Option<ForeignKeyInfo>,
|
||||
#[serde(default)]
|
||||
pub marked_for_drop: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ForeignKeyInfo {
|
||||
pub name: String,
|
||||
pub column: String,
|
||||
#[serde(default)]
|
||||
pub ref_schema: Option<String>,
|
||||
pub ref_table: String,
|
||||
pub ref_column: String,
|
||||
#[serde(default)]
|
||||
pub on_update: Option<String>,
|
||||
#[serde(default)]
|
||||
pub on_delete: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditableStructureTrigger {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub timing: String,
|
||||
#[serde(default)]
|
||||
pub event: String,
|
||||
#[serde(default)]
|
||||
pub statement: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub original: Option<TriggerInfo>,
|
||||
#[serde(default)]
|
||||
pub marked_for_drop: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TriggerInfo {
|
||||
pub name: String,
|
||||
pub event: String,
|
||||
pub timing: String,
|
||||
#[serde(default)]
|
||||
pub statement: Option<String>,
|
||||
/// Carries the catalog-reported enabled state so SQL Server edits can
|
||||
/// restore it after the DROP + CREATE rebuild (`DISABLE TRIGGER`).
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableStructureSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
/// Driver profile reported by the connection (e.g. `"gbase8s"`). GBase 8s
|
||||
/// is Informix-compatible rather than MySQL-compatible like the rest of
|
||||
/// the `Gbase` family, so this disambiguates which dialect to generate.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub driver_profile: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub table_name: String,
|
||||
#[serde(default)]
|
||||
pub columns: Vec<EditableStructureColumn>,
|
||||
#[serde(default)]
|
||||
pub indexes: Vec<EditableStructureIndex>,
|
||||
#[serde(default)]
|
||||
pub foreign_keys: Vec<EditableStructureForeignKey>,
|
||||
#[serde(default)]
|
||||
pub triggers: Vec<EditableStructureTrigger>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub table_comment: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub original_table_comment: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mysql_engine: Option<String>,
|
||||
/// MySQL only: the table's current default collation
|
||||
/// (`information_schema.TABLES.TABLE_COLLATION`). A column whose collation
|
||||
/// merely matches it inherits the table default, so its `CHARACTER SET` /
|
||||
/// `COLLATE` clauses are redundant and are dropped from the generated DDL.
|
||||
/// Introspection keeps reporting the column's real values, which is what
|
||||
/// the structure editor renders in its charset/collation pickers.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub table_collation: Option<String>,
|
||||
/// Whether the target table is a partitioned parent table (PostgreSQL
|
||||
/// `relkind = 'p'`). PostgreSQL rejects `CREATE INDEX CONCURRENTLY` on
|
||||
/// partitioned parents, so the builder refuses such a request up front
|
||||
/// (`validate_concurrent_index_scope`) instead of emitting SQL the server
|
||||
/// will reject or downgrading to a blocking `CREATE INDEX`.
|
||||
#[serde(default)]
|
||||
pub partitioned: bool,
|
||||
/// When true, the connection is GaussDB M-mode which uses MySQL-compatible
|
||||
/// SQL dialect with backtick quoting. The structure editor maps this to
|
||||
/// `StructureDialect::Mysql` so that DDL is generated with MySQL syntax.
|
||||
#[serde(default)]
|
||||
pub is_gaussdb_m_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableStructureSqlResult {
|
||||
pub statements: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableOwnerChangeSqlOptions {
|
||||
#[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 owner: String,
|
||||
#[serde(default)]
|
||||
pub original_owner: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SqliteTableStructurePreview {
|
||||
pub statements: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
pub schema_revision: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SingleColumnAlterSqlOptions {
|
||||
#[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>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub table_name: String,
|
||||
pub column: EditableStructureColumn,
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
use super::column_format::mysql_temporal_precision;
|
||||
use super::dialect::StructureDialect;
|
||||
use super::types::EditableStructureColumn;
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
/// Dialects whose qualified names use `schema.table` when a non-empty schema is present.
|
||||
fn is_schema_qualifying_dialect(dialect: StructureDialect) -> bool {
|
||||
matches!(
|
||||
dialect,
|
||||
StructureDialect::Postgres
|
||||
| StructureDialect::Oracle
|
||||
| StructureDialect::Dameng
|
||||
| StructureDialect::Oscar
|
||||
| StructureDialect::SqlServer
|
||||
| StructureDialect::H2
|
||||
| StructureDialect::Informix
|
||||
| StructureDialect::Sqlite
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn qualified_table(dialect: StructureDialect, schema: Option<&str>, table_name: &str) -> String {
|
||||
if is_schema_qualifying_dialect(dialect) && schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
||||
return format!("{}.{}", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, table_name));
|
||||
}
|
||||
quote_ident(dialect, table_name)
|
||||
}
|
||||
|
||||
/// Qualify a table being created while preserving the schema as an existing object.
|
||||
///
|
||||
/// Oracle's ordinary identifiers are case-insensitive and are folded to uppercase. The
|
||||
/// regular `qualified_table`/`quote_ident` pair must remain exact because it is also used
|
||||
/// for tables already loaded from metadata, including quoted mixed-case names.
|
||||
pub(super) fn qualified_new_table(
|
||||
database_type: Option<DatabaseType>,
|
||||
dialect: StructureDialect,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
) -> String {
|
||||
if is_schema_qualifying_dialect(dialect) && schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
||||
return format!(
|
||||
"{}.{}",
|
||||
quote_ident(dialect, schema.unwrap()),
|
||||
quote_new_ident(database_type, dialect, table_name)
|
||||
);
|
||||
}
|
||||
quote_new_ident(database_type, dialect, table_name)
|
||||
}
|
||||
|
||||
pub(super) fn quote_ident(dialect: StructureDialect, name: &str) -> String {
|
||||
match dialect {
|
||||
StructureDialect::Mysql
|
||||
| StructureDialect::Doris
|
||||
| StructureDialect::GaussdbM
|
||||
| StructureDialect::ManticoreSearch
|
||||
| StructureDialect::Questdb => {
|
||||
format!("`{}`", name.replace('`', "``"))
|
||||
}
|
||||
StructureDialect::SqlServer => format!("[{}]", name.replace(']', "]]")),
|
||||
StructureDialect::Informix if is_simple_informix_identifier(name) => name.to_string(),
|
||||
_ => format!("\"{}\"", name.replace('"', "\"\"")),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_simple_informix_identifier(name: &str) -> bool {
|
||||
let mut chars = name.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
(first.is_ascii_alphabetic() || first == '_')
|
||||
&& chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '$')
|
||||
}
|
||||
|
||||
/// Format an identifier that will be created by a CREATE/ADD statement.
|
||||
///
|
||||
/// This is deliberately separate from `quote_ident`: metadata-backed DDL must preserve the
|
||||
/// exact spelling of an existing quoted Oracle object, while a newly entered ordinary name
|
||||
/// should use Oracle's normal case-insensitive identifier semantics.
|
||||
pub(super) fn quote_new_ident(database_type: Option<DatabaseType>, dialect: StructureDialect, name: &str) -> String {
|
||||
if database_type == Some(DatabaseType::Oracle) && dialect == StructureDialect::Oracle {
|
||||
oracle_new_object_reference(name)
|
||||
} else {
|
||||
quote_ident(dialect, name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference spelling that resolves to an object created through [`quote_new_ident`]'s Oracle path.
|
||||
///
|
||||
/// Plain Oracle identifiers are created unquoted and are therefore stored uppercase-folded; a
|
||||
/// later reference with the same unquoted spelling folds to the same object. Names that had to
|
||||
/// stay quoted at creation (special characters, reserved words) keep exact quoting here too, so
|
||||
/// generated references always line up with the created object.
|
||||
pub fn oracle_new_object_reference(name: &str) -> String {
|
||||
if is_simple_oracle_identifier(name) && !is_oracle_reserved_identifier(name) {
|
||||
name.to_string()
|
||||
} else {
|
||||
quote_ident(StructureDialect::Oracle, name)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_simple_oracle_identifier(name: &str) -> bool {
|
||||
let mut chars = name.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
first.is_ascii_alphabetic() && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '$' || ch == '#')
|
||||
}
|
||||
|
||||
// sqlparser maintains the shared SQL keyword vocabulary used by this crate. It intentionally
|
||||
// includes more than Oracle's reserved words, so the conservative check is supplemented only
|
||||
// with Oracle-specific legacy words missing from that maintained list instead of copying a
|
||||
// separate several-hundred-entry keyword table here.
|
||||
const ORACLE_RESERVED_WORDS_MISSING_FROM_SQLPARSER: &[&str] =
|
||||
&["MAXEXTENTS", "NOAUDIT", "ROWID", "ROWNUM", "SUCCESSFUL", "UID"];
|
||||
|
||||
fn is_oracle_reserved_identifier(name: &str) -> bool {
|
||||
sqlparser::keywords::ALL_KEYWORDS.iter().any(|keyword| keyword.eq_ignore_ascii_case(name))
|
||||
|| ORACLE_RESERVED_WORDS_MISSING_FROM_SQLPARSER.iter().any(|keyword| keyword.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
pub(super) fn quote_string(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn is_sql_string_literal(value: &str) -> bool {
|
||||
let trimmed = value.trim();
|
||||
let Some(inner) = trimmed.strip_prefix('\'').and_then(|value| value.strip_suffix('\'')) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut chars = inner.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '\'' && chars.next_if_eq(&'\'').is_none() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn sqlserver_unicode_string_literal(value: &str) -> Option<String> {
|
||||
let mut candidate = value.trim();
|
||||
let mut parenthesis_depth = 0usize;
|
||||
|
||||
loop {
|
||||
if let Some(literal) = candidate.strip_prefix('N').or_else(|| candidate.strip_prefix('n')) {
|
||||
if is_sql_string_literal(literal) {
|
||||
return Some(format!("{}N{}{}", "(".repeat(parenthesis_depth), literal, ")".repeat(parenthesis_depth)));
|
||||
}
|
||||
}
|
||||
if is_sql_string_literal(candidate) {
|
||||
return Some(format!("{}N{}{}", "(".repeat(parenthesis_depth), candidate, ")".repeat(parenthesis_depth)));
|
||||
}
|
||||
|
||||
let inner = candidate.strip_prefix('(')?.strip_suffix(')')?;
|
||||
parenthesis_depth += 1;
|
||||
candidate = inner.trim();
|
||||
}
|
||||
}
|
||||
|
||||
fn postgres_string_default_literal(value: &str) -> Option<&str> {
|
||||
let trimmed = value.trim();
|
||||
let inner = trimmed.strip_prefix('\'')?;
|
||||
let mut literal_end = 1;
|
||||
let mut chars = inner.char_indices().peekable();
|
||||
while let Some((index, ch)) = chars.next() {
|
||||
if ch == '\'' {
|
||||
if chars.next_if(|(_, next)| *next == '\'').is_some() {
|
||||
continue;
|
||||
}
|
||||
literal_end += index + ch.len_utf8();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if literal_end == 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let literal = &trimmed[..literal_end];
|
||||
if !is_sql_string_literal(literal) {
|
||||
return None;
|
||||
}
|
||||
let cast_type = trimmed[literal_end..].trim().strip_prefix("::")?.trim();
|
||||
if is_postgres_textual_cast_type(cast_type) {
|
||||
Some(literal)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_postgres_textual_cast_type(value: &str) -> bool {
|
||||
let normalized =
|
||||
value.trim().trim_matches('"').split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
let base_type = normalized.split('(').next().unwrap_or(&normalized).trim();
|
||||
matches!(
|
||||
base_type,
|
||||
"char"
|
||||
| "character"
|
||||
| "varchar"
|
||||
| "character varying"
|
||||
| "text"
|
||||
| "bpchar"
|
||||
| "name"
|
||||
| "json"
|
||||
| "jsonb"
|
||||
| "xml"
|
||||
| "bytea"
|
||||
| "uuid"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn clean(value: &str) -> String {
|
||||
value.trim().to_string()
|
||||
}
|
||||
|
||||
pub(super) fn is_protected_manticore_id_column(dialect: StructureDialect, column_name: &str) -> bool {
|
||||
dialect == StructureDialect::ManticoreSearch && column_name.trim().eq_ignore_ascii_case("id")
|
||||
}
|
||||
|
||||
pub(super) fn is_temporal_type_for_default(dialect: StructureDialect, base_type: &str) -> bool {
|
||||
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
match dialect {
|
||||
StructureDialect::Mysql | StructureDialect::Doris => {
|
||||
matches!(normalized.as_str(), "date" | "datetime" | "timestamp" | "time" | "year")
|
||||
}
|
||||
StructureDialect::Postgres => {
|
||||
matches!(
|
||||
normalized.as_str(),
|
||||
"date"
|
||||
| "time"
|
||||
| "time without time zone"
|
||||
| "time with time zone"
|
||||
| "timestamp"
|
||||
| "timestamp without time zone"
|
||||
| "timestamp with time zone"
|
||||
| "interval"
|
||||
| "timetz"
|
||||
| "timestamptz"
|
||||
) || normalized.starts_with("interval ")
|
||||
}
|
||||
StructureDialect::SqlServer => matches!(
|
||||
normalized.as_str(),
|
||||
"date" | "time" | "datetime" | "datetime2" | "smalldatetime" | "datetimeoffset"
|
||||
),
|
||||
StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Oscar => matches!(
|
||||
normalized.as_str(),
|
||||
"date"
|
||||
| "timestamp"
|
||||
| "timestamp with time zone"
|
||||
| "timestamp with local time zone"
|
||||
| "interval year to month"
|
||||
| "interval day to second"
|
||||
),
|
||||
StructureDialect::H2 => {
|
||||
matches!(
|
||||
normalized.as_str(),
|
||||
"date"
|
||||
| "time"
|
||||
| "time without time zone"
|
||||
| "time with time zone"
|
||||
| "timestamp"
|
||||
| "timestamp without time zone"
|
||||
| "timestamp with time zone"
|
||||
) || normalized.starts_with("interval ")
|
||||
}
|
||||
StructureDialect::ClickHouse => {
|
||||
matches!(normalized.as_str(), "date" | "date32" | "datetime" | "datetime64")
|
||||
}
|
||||
StructureDialect::Sqlite => {
|
||||
matches!(normalized.as_str(), "date" | "datetime" | "timestamp" | "time")
|
||||
}
|
||||
StructureDialect::Informix => matches!(normalized.as_str(), "date" | "datetime"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_temporal_expression(value: &str) -> bool {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if trimmed.contains('(') || trimmed.contains(')') {
|
||||
return true;
|
||||
}
|
||||
trimmed.chars().all(|c| c.is_ascii_alphabetic() || c == '_')
|
||||
}
|
||||
|
||||
pub(super) fn is_string_type_for_default(dialect: StructureDialect, base_type: &str) -> bool {
|
||||
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
match dialect {
|
||||
StructureDialect::Mysql | StructureDialect::Doris => matches!(
|
||||
normalized.as_str(),
|
||||
"char"
|
||||
| "varchar"
|
||||
| "tinytext"
|
||||
| "text"
|
||||
| "mediumtext"
|
||||
| "longtext"
|
||||
| "binary"
|
||||
| "varbinary"
|
||||
| "tinyblob"
|
||||
| "blob"
|
||||
| "mediumblob"
|
||||
| "longblob"
|
||||
| "enum"
|
||||
| "set"
|
||||
| "json"
|
||||
| "nvarchar"
|
||||
| "nchar"
|
||||
| "long"
|
||||
),
|
||||
StructureDialect::Postgres => matches!(
|
||||
normalized.as_str(),
|
||||
"char"
|
||||
| "character"
|
||||
| "varchar"
|
||||
| "character varying"
|
||||
| "text"
|
||||
| "bpchar"
|
||||
| "name"
|
||||
| "json"
|
||||
| "jsonb"
|
||||
| "xml"
|
||||
| "bytea"
|
||||
| "uuid"
|
||||
),
|
||||
StructureDialect::SqlServer => matches!(
|
||||
normalized.as_str(),
|
||||
"char" | "varchar" | "nchar" | "nvarchar" | "text" | "ntext" | "xml" | "uniqueidentifier" | "sysname"
|
||||
),
|
||||
StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Oscar => matches!(
|
||||
normalized.as_str(),
|
||||
"char" | "nchar" | "varchar2" | "nvarchar2" | "clob" | "nclob" | "long" | "raw" | "long raw" | "bfile"
|
||||
),
|
||||
StructureDialect::H2 => matches!(
|
||||
normalized.as_str(),
|
||||
"char"
|
||||
| "character"
|
||||
| "varchar"
|
||||
| "character varying"
|
||||
| "text"
|
||||
| "clob"
|
||||
| "binary"
|
||||
| "varbinary"
|
||||
| "blob"
|
||||
| "uuid"
|
||||
| "json"
|
||||
),
|
||||
StructureDialect::ClickHouse => matches!(normalized.as_str(), "string" | "fixedstring" | "uuid" | "json"),
|
||||
StructureDialect::Sqlite => {
|
||||
matches!(normalized.as_str(), "text" | "varchar" | "char" | "character" | "clob" | "nvarchar" | "nchar")
|
||||
}
|
||||
StructureDialect::Informix => matches!(
|
||||
normalized.as_str(),
|
||||
"char"
|
||||
| "character"
|
||||
| "varchar"
|
||||
| "character varying"
|
||||
| "nvarchar"
|
||||
| "nchar"
|
||||
| "text"
|
||||
| "clob"
|
||||
| "lvarchar"
|
||||
| "byte"
|
||||
| "blob"
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn format_default_for_sql(dialect: StructureDialect, data_type: &str, default_value: &str) -> String {
|
||||
if default_value.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let base_type = data_type.split('(').next().unwrap_or(data_type).trim();
|
||||
if is_temporal_type_for_default(dialect, base_type) {
|
||||
if is_temporal_expression(default_value) {
|
||||
return mysql_temporal_default_with_column_precision(dialect, data_type, default_value);
|
||||
}
|
||||
return quote_string(default_value);
|
||||
}
|
||||
if is_string_type_for_default(dialect, base_type) {
|
||||
if dialect == StructureDialect::SqlServer
|
||||
&& matches!(base_type.to_ascii_lowercase().as_str(), "nchar" | "nvarchar" | "ntext" | "sysname")
|
||||
{
|
||||
if let Some(literal) = sqlserver_unicode_string_literal(default_value) {
|
||||
return literal;
|
||||
}
|
||||
if default_value.contains('(') || default_value.contains(')') {
|
||||
return default_value.to_string();
|
||||
}
|
||||
return format!("N{}", quote_string(default_value));
|
||||
}
|
||||
// Only skip quoting for function-call expressions like `gen_random_uuid()`.
|
||||
// Simple identifiers like `CURRENT_TIMESTAMP` are not valid defaults for string columns.
|
||||
if is_sql_string_literal(default_value)
|
||||
|| (dialect == StructureDialect::Postgres && postgres_string_default_literal(default_value).is_some())
|
||||
|| default_value.contains('(')
|
||||
|| default_value.contains(')')
|
||||
{
|
||||
return default_value.to_string();
|
||||
}
|
||||
return quote_string(default_value);
|
||||
}
|
||||
default_value.to_string()
|
||||
}
|
||||
|
||||
/// MySQL requires a `CURRENT_TIMESTAMP` default on a fractional-precision
|
||||
/// temporal column to spell out the column's precision (`datetime(3)` needs
|
||||
/// `DEFAULT CURRENT_TIMESTAMP(3)`), otherwise the statement fails with
|
||||
/// `ERROR 1067 (42000): Invalid default value`. Complete a bare
|
||||
/// `CURRENT_TIMESTAMP` with the precision parsed from the data type; a value
|
||||
/// that already carries parentheses is passed through unchanged.
|
||||
fn mysql_temporal_default_with_column_precision(
|
||||
dialect: StructureDialect,
|
||||
data_type: &str,
|
||||
default_value: &str,
|
||||
) -> String {
|
||||
let trimmed = default_value.trim();
|
||||
if dialect != StructureDialect::Mysql || !trimmed.eq_ignore_ascii_case("current_timestamp") {
|
||||
return default_value.to_string();
|
||||
}
|
||||
match mysql_temporal_precision(data_type) {
|
||||
Some(fsp) => format!("{trimmed}({fsp})"),
|
||||
None => default_value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_default(value: Option<&String>) -> String {
|
||||
let trimmed = value.map(|value| value.trim()).unwrap_or("");
|
||||
if trimmed.eq_ignore_ascii_case("null") {
|
||||
String::new()
|
||||
} else if let Some(literal) = postgres_string_default_literal(trimmed) {
|
||||
literal.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn original_default(column: &EditableStructureColumn) -> String {
|
||||
normalize_default(column.original.as_ref().and_then(|original| original.column_default.as_ref()))
|
||||
}
|
||||
|
||||
pub fn original_comment(column: &EditableStructureColumn) -> String {
|
||||
clean(column.original.as_ref().and_then(|original| original.comment.as_deref()).unwrap_or(""))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sqlserver_unicode_defaults_use_unicode_literals_without_double_quoting() {
|
||||
assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "nvarchar(50)", "中文"), "N'中文'");
|
||||
assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "nchar(5)", "'中文'"), "N'中文'");
|
||||
assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "sysname", "n'guest'"), "N'guest'");
|
||||
assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "nvarchar(50)", "('中文')"), "(N'中文')");
|
||||
assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "nvarchar(50)", "((N'中文'))"), "((N'中文'))");
|
||||
assert_eq!(
|
||||
format_default_for_sql(StructureDialect::SqlServer, "nvarchar(max)", "CONCAT(N'a', N'b')"),
|
||||
"CONCAT(N'a', N'b')"
|
||||
);
|
||||
assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "varchar(50)", "中文"), "'中文'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use super::column_format::has_dameng_identity;
|
||||
use super::dialect::{capabilities_for, StructureDialect};
|
||||
use super::indexes::has_existing_index_change;
|
||||
use super::types::{EditableStructureColumn, TableStructureSqlOptions};
|
||||
use super::util::clean;
|
||||
|
||||
/// Hard errors for `CREATE INDEX CONCURRENTLY` requests the editor cannot
|
||||
/// honor safely. Unlike the advisory warnings in [`validate_draft`], these
|
||||
/// reject the whole save plan: the caller asked for a non-blocking build and
|
||||
/// the only way to honor that is the requested concurrent DDL, so falling back
|
||||
/// to blocking index DDL must never happen silently.
|
||||
///
|
||||
/// Only real PostgreSQL advertises `index_concurrent`, so stale/forged
|
||||
/// `concurrently` flags on other engines are ignored here (they cannot express
|
||||
/// a concurrent request in the first place).
|
||||
pub(super) fn validate_concurrent_index_scope(options: &TableStructureSqlOptions) -> Vec<String> {
|
||||
let mut errors = Vec::new();
|
||||
let capabilities = capabilities_for(options.database_type, options.driver_profile.as_deref());
|
||||
if !capabilities.index_concurrent || capabilities.dialect != StructureDialect::Postgres {
|
||||
return errors;
|
||||
}
|
||||
for index in options.indexes.iter().filter(|index| !index.marked_for_drop) {
|
||||
if !index.concurrently {
|
||||
continue;
|
||||
}
|
||||
if index.original.is_some() {
|
||||
errors.push(format!(
|
||||
"CREATE INDEX CONCURRENTLY is only supported for newly created indexes. Editing an existing index \"{}\" with Concurrent enabled is not supported.",
|
||||
index.name
|
||||
));
|
||||
} else if options.partitioned {
|
||||
errors.push(
|
||||
"CREATE INDEX CONCURRENTLY is not supported on PostgreSQL partitioned parent tables. Create indexes concurrently on individual partitions and attach them separately."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
pub fn validate_draft(options: &TableStructureSqlOptions) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
let active_columns: Vec<_> = options.columns.iter().filter(|column| !column.marked_for_drop).collect();
|
||||
validate_columns(&active_columns, &mut warnings);
|
||||
validate_dameng_identity(options, &active_columns, &mut warnings);
|
||||
validate_gin_opclass_warnings(options, &mut warnings);
|
||||
for index in options
|
||||
.indexes
|
||||
.iter()
|
||||
.filter(|index| !index.marked_for_drop && (index.original.is_none() || has_existing_index_change(index)))
|
||||
{
|
||||
if clean(&index.name).is_empty() {
|
||||
warnings.push("Index name cannot be empty.".to_string());
|
||||
}
|
||||
if index.columns.iter().map(|column| clean(column)).filter(|column| !column.is_empty()).count() == 0 {
|
||||
warnings.push(format!(
|
||||
"Index \"{}\" needs at least one column.",
|
||||
if index.name.is_empty() { "(new)" } else { &index.name }
|
||||
));
|
||||
}
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
pub(super) fn validate_dameng_identity(
|
||||
options: &TableStructureSqlOptions,
|
||||
columns: &[&EditableStructureColumn],
|
||||
warnings: &mut Vec<String>,
|
||||
) {
|
||||
if capabilities_for(options.database_type, options.driver_profile.as_deref()).dialect != StructureDialect::Dameng {
|
||||
return;
|
||||
}
|
||||
|
||||
let identity_columns: Vec<_> = columns.iter().filter(|column| has_dameng_identity(column)).collect();
|
||||
if identity_columns.len() > 1 {
|
||||
warnings.push("Dameng tables can have only one identity column.".to_string());
|
||||
}
|
||||
for column in identity_columns {
|
||||
if column.extra.as_ref().and_then(|extra| extra.identity.as_ref()).and_then(|identity| identity.increment)
|
||||
== Some(0)
|
||||
{
|
||||
warnings.push(format!("Dameng identity column \"{}\" increment cannot be 0.", column.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_columns(columns: &[&EditableStructureColumn], warnings: &mut Vec<String>) {
|
||||
let mut names = std::collections::HashSet::new();
|
||||
for column in columns {
|
||||
if clean(&column.name).is_empty() {
|
||||
warnings.push("Column name cannot be empty.".to_string());
|
||||
}
|
||||
if clean(&column.data_type).is_empty() {
|
||||
warnings.push(format!(
|
||||
"Column \"{}\" type cannot be empty.",
|
||||
if column.name.is_empty() { "(new)" } else { &column.name }
|
||||
));
|
||||
}
|
||||
let key = clean(&column.name).to_lowercase();
|
||||
if !key.is_empty() && !names.insert(key) {
|
||||
warnings.push(format!("Column \"{}\" is duplicated.", column.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PostgreSQL GIN indexes on varchar/text/character columns without an explicit
|
||||
/// operator class are a common trap: the default `array_ops` class is rarely what
|
||||
/// the user wants, and `gin_trgm_ops` (from `pg_trgm` extension) or similar
|
||||
/// classes are almost always needed. This is a warning, not an error, because
|
||||
/// the default class may be deliberate in some cases.
|
||||
fn validate_gin_opclass_warnings(options: &TableStructureSqlOptions, warnings: &mut Vec<String>) {
|
||||
let capabilities = capabilities_for(options.database_type, options.driver_profile.as_deref());
|
||||
if capabilities.dialect != StructureDialect::Postgres {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a lookup: column name (lowercased) → data type for active columns.
|
||||
let col_types: std::collections::HashMap<String, &str> = options
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|col| !col.marked_for_drop)
|
||||
.map(|col| (clean(&col.name).to_lowercase(), col.data_type.as_str()))
|
||||
.collect();
|
||||
|
||||
let text_like = |dt: &str| -> bool {
|
||||
let lower = dt.to_lowercase();
|
||||
lower == "text"
|
||||
|| lower.starts_with("character varying")
|
||||
|| lower.starts_with("varchar")
|
||||
|| lower.starts_with("character")
|
||||
|| lower == "char"
|
||||
};
|
||||
|
||||
for index in options.indexes.iter().filter(|idx| {
|
||||
!idx.marked_for_drop
|
||||
&& clean(&idx.index_type).eq_ignore_ascii_case("GIN")
|
||||
&& (idx.original.is_none() || has_existing_index_change(idx))
|
||||
}) {
|
||||
for (i, col) in index.columns.iter().enumerate() {
|
||||
let col_name = clean(col);
|
||||
if col_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Expression columns are not matched against table column types.
|
||||
let is_expr =
|
||||
index.original.as_ref().and_then(|orig| orig.key_is_expression.get(i).copied()).unwrap_or(false);
|
||||
if is_expr {
|
||||
continue;
|
||||
}
|
||||
// Check if this column has a text-like type.
|
||||
let Some(dt) = col_types.get(&col_name.to_lowercase()) else {
|
||||
continue;
|
||||
};
|
||||
if !text_like(dt) {
|
||||
continue;
|
||||
}
|
||||
// Check if opclass is explicitly provided.
|
||||
let has_opclass =
|
||||
index.column_opclasses.get(i).and_then(|o| o.as_deref()).map(|v| !clean(v).is_empty()).unwrap_or(false);
|
||||
if !has_opclass {
|
||||
warnings.push(format!(
|
||||
"GIN index \"{}\" on column \"{}\" (type {}) has no operator class specified. Consider adding an operator class, e.g. `gin_trgm_ops` (requires pg_trgm extension) for text search.",
|
||||
index.name, col_name, dt
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use crate::models::connection::{ConnectionConfig, DatabaseType};
|
||||
|
||||
pub const DRIVER_PROFILE: &str = "tdsql";
|
||||
|
||||
pub fn is_config(config: &ConnectionConfig) -> bool {
|
||||
is_profile(&config.db_type, config.driver_profile.as_deref())
|
||||
}
|
||||
|
||||
pub fn is_profile(db_type: &DatabaseType, driver_profile: Option<&str>) -> bool {
|
||||
*db_type == DatabaseType::Mysql
|
||||
&& driver_profile.is_some_and(|profile| profile.eq_ignore_ascii_case(DRIVER_PROFILE))
|
||||
}
|
||||
|
||||
pub fn preserves_leading_directives_for_database_type(db_type: DatabaseType) -> bool {
|
||||
db_type == DatabaseType::Mysql
|
||||
}
|
||||
|
||||
pub fn leading_directive_start(statement: &str, executable_start: usize) -> Option<usize> {
|
||||
let prefix = statement.get(..executable_start)?;
|
||||
let line_start = prefix.rfind(['\r', '\n']).map_or(0, |index| index + 1);
|
||||
let line_prefix = &prefix[line_start..];
|
||||
let trimmed_line_start = line_prefix.trim_start();
|
||||
if trimmed_line_start.starts_with("/*") && trimmed_line_start.trim_end().ends_with("*/") {
|
||||
return Some(line_start + line_prefix.len() - trimmed_line_start.len());
|
||||
}
|
||||
|
||||
let trimmed_prefix = prefix.trim_end();
|
||||
trimmed_prefix.strip_suffix("/*proxy*/").map(|before| before.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn profile_is_isolated_from_standard_mysql() {
|
||||
assert!(is_profile(&DatabaseType::Mysql, Some("TDSQL")));
|
||||
assert!(!is_profile(&DatabaseType::Mysql, None));
|
||||
assert!(!is_profile(&DatabaseType::Postgres, Some("tdsql")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_directives_are_available_only_to_mysql_parsing() {
|
||||
assert!(preserves_leading_directives_for_database_type(DatabaseType::Mysql));
|
||||
assert!(!preserves_leading_directives_for_database_type(DatabaseType::Postgres));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_directives_preserve_same_line_and_exact_proxy_behavior() {
|
||||
let exact = "/*proxy*/ \n\tSHOW PROXY STATUS";
|
||||
let executable_start = exact.find("SHOW").unwrap();
|
||||
assert_eq!(leading_directive_start(exact, executable_start), Some(0));
|
||||
|
||||
let same_line = " /*sets:allsets */ SELECT 1";
|
||||
let executable_start = same_line.find("SELECT").unwrap();
|
||||
assert_eq!(leading_directive_start(same_line, executable_start), Some(2));
|
||||
|
||||
let separate_line = "/*sets:allsets */\nSELECT 1";
|
||||
let executable_start = separate_line.find("SELECT").unwrap();
|
||||
assert_eq!(leading_directive_start(separate_line, executable_start), None);
|
||||
|
||||
let separated = "/*proxy*/\n/* audit */\nSHOW PROXY STATUS";
|
||||
let executable_start = separated.find("SHOW").unwrap();
|
||||
assert_eq!(leading_directive_start(separated, executable_start), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
pub fn format_pg_array_sql_literal(arr: &[serde_json::Value]) -> String {
|
||||
if arr.is_empty() {
|
||||
return "'{}'".to_string();
|
||||
}
|
||||
let elements: Vec<String> = arr.iter().map(format_pg_array_element).collect();
|
||||
let inner = format!("{{{}}}", elements.join(","));
|
||||
format!("'{}'", inner.replace('\\', "\\\\").replace('\'', "''"))
|
||||
}
|
||||
|
||||
pub fn format_pg_array_element(val: &serde_json::Value) -> String {
|
||||
match val {
|
||||
serde_json::Value::Null => "NULL".to_string(),
|
||||
serde_json::Value::Array(arr) => {
|
||||
if arr.is_empty() {
|
||||
return "{}".to_string();
|
||||
}
|
||||
let elements: Vec<String> = arr.iter().map(format_pg_array_element).collect();
|
||||
format!("{{{}}}", elements.join(","))
|
||||
}
|
||||
serde_json::Value::String(s) => {
|
||||
let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
format!("\"{}\"", escaped)
|
||||
}
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::Bool(b) => {
|
||||
if *b {
|
||||
"true".to_string()
|
||||
} else {
|
||||
"false".to_string()
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(o) => {
|
||||
let json = serde_json::to_string(o).unwrap_or_default();
|
||||
let escaped = json.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
format!("\"{}\"", escaped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_ch_array_sql_literal(arr: &[serde_json::Value]) -> String {
|
||||
if arr.is_empty() {
|
||||
return "[]".to_string();
|
||||
}
|
||||
let elements: Vec<String> = arr.iter().map(format_ch_array_element).collect();
|
||||
format!("[{}]", elements.join(","))
|
||||
}
|
||||
|
||||
pub fn format_ch_array_element(val: &serde_json::Value) -> String {
|
||||
match val {
|
||||
serde_json::Value::Null => "NULL".to_string(),
|
||||
serde_json::Value::Array(arr) => {
|
||||
if arr.is_empty() {
|
||||
return "[]".to_string();
|
||||
}
|
||||
let elements: Vec<String> = arr.iter().map(format_ch_array_element).collect();
|
||||
format!("[{}]", elements.join(","))
|
||||
}
|
||||
serde_json::Value::String(s) => {
|
||||
let escaped = s.replace('\\', "\\\\").replace('\'', "''");
|
||||
format!("'{}'", escaped)
|
||||
}
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::Bool(b) => {
|
||||
if *b {
|
||||
"true".to_string()
|
||||
} else {
|
||||
"false".to_string()
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(o) => {
|
||||
let json = serde_json::to_string(o).unwrap_or_default();
|
||||
format!("'{}'", json.replace('\\', "\\\\").replace('\'', "''"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn quote_postgres_string_literal(value: &str) -> String {
|
||||
if !value.contains('\\') && !value.chars().any(|character| character.is_ascii_control()) {
|
||||
return quote_string_literal(value);
|
||||
}
|
||||
|
||||
let mut escaped = String::with_capacity(value.len());
|
||||
for character in value.chars() {
|
||||
match character {
|
||||
'\\' => escaped.push_str("\\\\"),
|
||||
'\n' => escaped.push_str("\\n"),
|
||||
'\r' => escaped.push_str("\\r"),
|
||||
'\t' => escaped.push_str("\\t"),
|
||||
'\x08' => escaped.push_str("\\b"),
|
||||
'\x0c' => escaped.push_str("\\f"),
|
||||
'\'' => escaped.push_str("''"),
|
||||
character if character.is_ascii_control() => {
|
||||
const HEX: &[u8; 16] = b"0123456789ABCDEF";
|
||||
let byte = character as u8;
|
||||
escaped.push_str("\\x");
|
||||
escaped.push(HEX[(byte >> 4) as usize] as char);
|
||||
escaped.push(HEX[(byte & 0x0F) as usize] as char);
|
||||
}
|
||||
character => escaped.push(character),
|
||||
}
|
||||
}
|
||||
|
||||
// Escape string constants keep control characters out of the physical
|
||||
// script and remain correct regardless of standard_conforming_strings.
|
||||
format!("E'{escaped}'")
|
||||
}
|
||||
|
||||
pub fn is_postgres_vector_type(column_type: Option<&str>) -> bool {
|
||||
column_type
|
||||
.map(|column_type| {
|
||||
let normalized = column_type.trim().trim_matches('"').to_ascii_lowercase();
|
||||
if normalized.trim_end().ends_with("[]") {
|
||||
return false;
|
||||
}
|
||||
let base = normalized.split(['(', ' ', '\t', '\n']).next().unwrap_or("").trim_matches('"');
|
||||
matches!(base, "vector" | "halfvec") || base.ends_with(".vector") || base.ends_with(".halfvec")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn format_postgres_vector_sql_literal(value: &serde_json::Value) -> String {
|
||||
if value.is_null() {
|
||||
return "NULL".to_string();
|
||||
}
|
||||
let text = match value {
|
||||
// pgvector vector/halfvec are scalar extension types whose importable
|
||||
// literal grammar uses square brackets, unlike PostgreSQL arrays.
|
||||
serde_json::Value::Array(arr) => {
|
||||
let elements = arr.iter().map(format_postgres_vector_element).collect::<Vec<_>>();
|
||||
format!("[{}]", elements.join(","))
|
||||
}
|
||||
serde_json::Value::String(text) => text.to_string(),
|
||||
_ => value.to_string(),
|
||||
};
|
||||
quote_postgres_string_literal(&text)
|
||||
}
|
||||
|
||||
pub fn format_postgres_vector_element(value: &serde_json::Value) -> String {
|
||||
match value {
|
||||
serde_json::Value::String(text) => text.trim().to_string(),
|
||||
serde_json::Value::Number(number) => number.to_string(),
|
||||
serde_json::Value::Bool(value) => value.to_string(),
|
||||
serde_json::Value::Null => "NULL".to_string(),
|
||||
_ => value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn quote_string_literal(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
Reference in new issue
Block a user