diff --git a/apps/desktop/src/components/import/TableImportDialog.vue b/apps/desktop/src/components/import/TableImportDialog.vue index 70b3507aa7..73cef8bcd8 100644 --- a/apps/desktop/src/components/import/TableImportDialog.vue +++ b/apps/desktop/src/components/import/TableImportDialog.vue @@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { AlertTriangle, ArrowLeft, ArrowRight, Check, CheckCircle2, FileJson, FileSpreadsheet, FileText, FileUp, Loader2, RefreshCw, Square, Upload, X } from "@lucide/vue"; +import { AlertTriangle, ArrowLeft, ArrowRight, Check, CheckCircle2, FileCode, FileJson, FileSpreadsheet, FileText, FileUp, Loader2, RefreshCw, Square, Upload, X } from "@lucide/vue"; import { useConnectionStore } from "@/stores/connectionStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useToast } from "@/composables/useToast"; @@ -109,6 +109,7 @@ const formatOptions: Array<{ value: api.TableImportSourceFormat; icon: any; labe { value: "delimited", icon: FileText, labelKey: "tableImport.formatDelimited", descriptionKey: "tableImport.formatDelimitedDescription" }, { value: "json", icon: FileJson, labelKey: "tableImport.formatJson", descriptionKey: "tableImport.formatJsonDescription" }, { value: "excel", icon: FileSpreadsheet, labelKey: "tableImport.formatExcel", descriptionKey: "tableImport.formatExcelDescription" }, + { value: "sql", icon: FileCode, labelKey: "tableImport.formatSql", descriptionKey: "tableImport.formatSqlDescription" }, ]; const encodingOptions: Array<{ value: api.TableImportTextEncoding; labelKey: string }> = [ @@ -275,6 +276,7 @@ function detectFormat(name: string): api.TableImportSourceFormat { if (lower.endsWith(".txt")) return "delimited"; if (lower.endsWith(".json")) return "json"; if (lower.endsWith(".xls") || lower.endsWith(".xlsx") || lower.endsWith(".xlsm")) return "excel"; + if (lower.endsWith(".sql")) return "sql"; return "csv"; } @@ -317,12 +319,14 @@ function taskParseOptions(format: api.TableImportSourceFormat, sheetName = ""): emptyStringAsNull: emptyStringAsNull.value, sheetName, jsonShape: jsonShape.value, + databaseType: structureDatabaseType.value, }); } function importParseOptions(format: api.TableImportSourceFormat, currentPreview: api.TableImportPreview, sheetName = ""): api.TableImportParseOptions { const options = taskParseOptions(format, sheetName); - if (isDelimitedFormat(format) && currentPreview.totalRowsExact !== false && options.encoding === "auto" && currentPreview.effectiveEncoding) { + // SQL 脚本与分隔文本一样依赖文本编码检测,导入时沿用预览阶段确定的编码避免二次检测结果不一致 + if ((isDelimitedFormat(format) || format === "sql") && currentPreview.totalRowsExact !== false && options.encoding === "auto" && currentPreview.effectiveEncoding) { options.encoding = currentPreview.effectiveEncoding; } return options; @@ -615,10 +619,11 @@ async function selectFile() { const selected = await open({ multiple: targetMode.value === "create", filters: [ - { name: "Data files", extensions: ["csv", "tsv", "txt", "json", "xlsx", "xlsm", "xls"] }, + { name: "Data files", extensions: ["csv", "tsv", "txt", "json", "xlsx", "xlsm", "xls", "sql"] }, { name: "Text", extensions: ["csv", "tsv", "txt"] }, { name: "JSON", extensions: ["json"] }, { name: "Excel", extensions: ["xlsx", "xlsm", "xls"] }, + { name: "SQL", extensions: ["sql"] }, ], }); if (!selected) return; @@ -939,7 +944,8 @@ async function reloadBatchPreviewsForEncoding() { errorMessage.value = ""; try { for (const task of batchTasks.value) { - if (!isDelimitedFormat(task.format)) continue; + // SQL 脚本同样是文本源,编码变化时需要重新预览 + if (!isDelimitedFormat(task.format) && task.format !== "sql") continue; const reusableSource = task.preview.sourceRef ? task.preview.filePath : task.source; const nextPreview = await api.previewTableImportFile(reusableSource, { sourceRef: task.preview.sourceRef || null, @@ -1018,7 +1024,7 @@ watch(rawProgressPercent, (percent) => {
- +
{{ t("tableImport.target") }} @@ -1092,7 +1098,7 @@ watch(rawProgressPercent, (percent) => { {{ t("tableImport.selectFile") }}
-
+
+
+
+ + +
+ {{ t("tableImport.encodingDetected", { encoding: encodingLabel(preview.effectiveEncoding) }) }} +
+
+
+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 3cfb5126bb..c1ea74eab6 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -5338,6 +5338,8 @@ export default { formatJsonDescription: "Object or array rows", formatExcel: "Excel", formatExcelDescription: "XLS, XLSX, XLSM", + formatSql: "SQL", + formatSqlDescription: "INSERT statements in SQL scripts", previewRows: "Preview rows", delimiter: "Delimiter", encoding: "Encoding", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index a21c827fb4..caff192cd2 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -5096,6 +5096,8 @@ export default withEnglishFallback({ formatJsonDescription: "Object or array rows", formatExcel: "Excel", formatExcelDescription: "XLS, XLSX, XLSM", + formatSql: "SQL", + formatSqlDescription: "Sentencias INSERT en scripts SQL", previewRows: "Preview rows", delimiter: "Delimiter", encoding: "Codificación", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 4b32a7c42b..8e94549ee9 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -5094,6 +5094,8 @@ export default withEnglishFallback({ formatJsonDescription: "Object or array rows", formatExcel: "Excel", formatExcelDescription: "XLS, XLSX, XLSM", + formatSql: "SQL", + formatSqlDescription: "Istruzioni INSERT negli script SQL", previewRows: "Preview rows", delimiter: "Delimiter", encoding: "Codifica", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 3fb71f6d1e..961cf0d872 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -5123,6 +5123,8 @@ export default withEnglishFallback({ formatJsonDescription: "Object or array rows", formatExcel: "Excel", formatExcelDescription: "XLS, XLSX, XLSM", + formatSql: "SQL", + formatSqlDescription: "SQL スクリプトの INSERT ステートメント", previewRows: "Preview rows", delimiter: "Delimiter", encoding: "文字コード", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index d57a0f80c0..cbd73ab3a5 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -4763,6 +4763,8 @@ export default withEnglishFallback({ formatJsonDescription: "객체 또는 배열 행", formatExcel: "Excel", formatExcelDescription: "XLS, XLSX, XLSM", + formatSql: "SQL", + formatSqlDescription: "SQL 스크립트의 INSERT 문", previewRows: "미리보기 행", delimiter: "구분자", encoding: "인코딩", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index f7b98ec453..ac0e81be82 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -5096,6 +5096,8 @@ export default withEnglishFallback({ formatJsonDescription: "Object or array rows", formatExcel: "Excel", formatExcelDescription: "XLS, XLSX, XLSM", + formatSql: "SQL", + formatSqlDescription: "Instruções INSERT em scripts SQL", previewRows: "Preview rows", delimiter: "Delimiter", encoding: "Codificação", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 5eb957bcef..be6f8089ae 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -5323,6 +5323,8 @@ export default withEnglishFallback({ formatJsonDescription: "对象行或数组行", formatExcel: "Excel", formatExcelDescription: "XLS、XLSX、XLSM", + formatSql: "SQL", + formatSqlDescription: "SQL 脚本中的 INSERT 语句", previewRows: "预览行数", delimiter: "分隔符", encoding: "文件编码", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 671a1751f7..87a05b8146 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -4420,6 +4420,8 @@ export default withEnglishFallback({ formatJsonDescription: "物件列或陣列列", formatExcel: "Excel", formatExcelDescription: "XLS、XLSX、XLSM", + formatSql: "SQL", + formatSqlDescription: "SQL 指令檔中的 INSERT 語句", previewRows: "預覽列數", delimiter: "分隔符", encoding: "檔案編碼", diff --git a/apps/desktop/src/lib/__tests__/table/tableImport.spec.ts b/apps/desktop/src/lib/__tests__/table/tableImport.spec.ts index 0ed0ee0052..a4ca241a08 100644 --- a/apps/desktop/src/lib/__tests__/table/tableImport.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableImport.spec.ts @@ -115,6 +115,26 @@ describe("tableImport", () => { expect(buildTableImportParseOptions({ ...baseSettings, format: "csv", sheetName: "Second" }).sheetName).toBeNull(); }); + it("passes the text encoding for SQL script sources like delimited text", () => { + const settings = { + delimiter: ",", + textEncoding: "gbk" as const, + titleRow: 1, + dataStartRow: 2, + lastDataRow: 0, + trimValues: false, + emptyStringAsNull: true, + jsonShape: "auto" as const, + databaseType: "mysql" as const, + }; + + expect(buildTableImportParseOptions({ ...settings, format: "sql" }).encoding).toBe("gbk"); + expect(buildTableImportParseOptions({ ...settings, format: "sql" }).sqlDialect).toBe("mysql"); + expect(buildTableImportParseOptions({ ...settings, format: "delimited" }).encoding).toBe("gbk"); + expect(buildTableImportParseOptions({ ...settings, format: "delimited" }).sqlDialect).toBeNull(); + expect(buildTableImportParseOptions({ ...settings, format: "excel" }).encoding).toBeNull(); + }); + it("suggests create-table data types from preview rows", () => { expect( suggestImportTargetDataTypes( diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 8c61076999..d5b50a269d 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -4477,7 +4477,7 @@ export async function sortTablesByFkDependency(options: SortTablesByFkOptions): export type TableImportMode = "append" | "truncate"; export type TableImportStatus = "running" | "done" | "error" | "cancelled"; export type TableImportPhase = "preparing" | "detectingEncoding" | "reading" | "writing" | "finalizing" | "done"; -export type TableImportSourceFormat = "csv" | "tsv" | "delimited" | "json" | "excel"; +export type TableImportSourceFormat = "csv" | "tsv" | "delimited" | "json" | "excel" | "sql"; export type TableImportJsonShape = "auto" | "objects" | "arrays"; export type TableImportTextEncoding = "auto" | "utf8" | "gbk" | "utf16Le" | "utf16Be"; @@ -4499,6 +4499,7 @@ export interface TableImportParseOptions { sheetName?: string | null; sheetIndex?: number | null; jsonShape?: TableImportJsonShape | null; + sqlDialect?: DatabaseType | null; } export interface TableImportPreviewRequest { diff --git a/apps/desktop/src/lib/table/tableImport.ts b/apps/desktop/src/lib/table/tableImport.ts index 42870cb709..33f1e3bd6f 100644 --- a/apps/desktop/src/lib/table/tableImport.ts +++ b/apps/desktop/src/lib/table/tableImport.ts @@ -86,6 +86,7 @@ export interface TableImportParseSettings { emptyStringAsNull: boolean; sheetName?: string; jsonShape: TableImportJsonShape; + databaseType?: DatabaseType | null; } export function defaultTableImportEmptyStringAsNull(format: TableImportSourceFormat): boolean { @@ -94,9 +95,11 @@ export function defaultTableImportEmptyStringAsNull(format: TableImportSourceFor export function buildTableImportParseOptions(settings: TableImportParseSettings): TableImportParseOptions { const isDelimited = settings.format === "csv" || settings.format === "tsv" || settings.format === "delimited"; + // SQL 脚本与分隔文本一样是纯文本源,需要传递编码供后端解码(支持 GBK 等常见转储编码) + const isTextSource = isDelimited || settings.format === "sql"; return { delimiter: settings.format === "tsv" ? "\\t" : settings.format === "csv" ? "," : settings.delimiter, - encoding: isDelimited ? settings.textEncoding : null, + encoding: isTextSource ? settings.textEncoding : null, titleRow: settings.titleRow, dataStartRow: settings.dataStartRow, lastDataRow: settings.lastDataRow, @@ -104,6 +107,8 @@ export function buildTableImportParseOptions(settings: TableImportParseSettings) emptyStringAsNull: settings.emptyStringAsNull, sheetName: settings.format === "excel" ? settings.sheetName || null : null, jsonShape: settings.format === "json" ? settings.jsonShape : null, + // SQL 脚本需要按源方言解析字符串转义与标识符大小写(取目标连接的数据库类型) + sqlDialect: settings.format === "sql" ? (settings.databaseType ?? null) : null, }; } diff --git a/crates/dbx-core/src/table_import.rs b/crates/dbx-core/src/table_import.rs index 9e5fe887ad..6a5b3f9934 100644 --- a/crates/dbx-core/src/table_import.rs +++ b/crates/dbx-core/src/table_import.rs @@ -16,6 +16,12 @@ use quick_xml::events::{BytesStart, Event}; use quick_xml::Reader as XmlReader; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use sqlparser::ast::{ + DataType, Expr, FunctionArg, FunctionArgExpr, FunctionArguments, Ident, Insert, ObjectName, ObjectNamePart, + SetExpr, Statement, TableObject, UnaryOperator, Value as SqlValue, +}; +use sqlparser::dialect::{GenericDialect, MsSqlDialect, MySqlDialect, OracleDialect, PostgreSqlDialect, SQLiteDialect}; +use sqlparser::parser::Parser; use crate::connection::{task_client_session_id, AppState, PoolKind}; use crate::models::connection::DatabaseType; @@ -112,6 +118,7 @@ pub enum TableImportSourceFormat { Delimited, Json, Excel, + Sql, } impl TableImportSourceFormat { @@ -122,6 +129,7 @@ impl TableImportSourceFormat { TableImportSourceFormat::Delimited => "txt", TableImportSourceFormat::Json => "json", TableImportSourceFormat::Excel => "excel", + TableImportSourceFormat::Sql => "sql", } } @@ -184,6 +192,9 @@ pub struct TableImportParseOptions { pub sheet_name: Option, pub sheet_index: Option, pub json_shape: Option, + /// SQL 脚本的源方言(目标连接类型)。决定字符串转义、标识符大小写与语句拆分规则。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sql_dialect: Option, } impl Default for TableImportParseOptions { @@ -200,6 +211,7 @@ impl Default for TableImportParseOptions { sheet_name: None, sheet_index: None, json_shape: Some(TableImportJsonShape::Auto), + sql_dialect: None, } } } @@ -334,6 +346,7 @@ pub enum ImportFileKind { Txt, Json, Xlsx, + Sql, } impl ImportFileKind { @@ -344,6 +357,7 @@ impl ImportFileKind { ImportFileKind::Txt => "txt", ImportFileKind::Json => "json", ImportFileKind::Xlsx => "xlsx", + ImportFileKind::Sql => "sql", } } } @@ -360,6 +374,8 @@ pub fn import_file_kind(path: &str) -> Result { Ok(ImportFileKind::Json) } else if lower.ends_with(".xlsx") || lower.ends_with(".xlsm") || lower.ends_with(".xls") { Ok(ImportFileKind::Xlsx) + } else if lower.ends_with(".sql") { + Ok(ImportFileKind::Sql) } else { Err("Unsupported import file type".to_string()) } @@ -372,6 +388,7 @@ pub fn source_format_for_path(path: &str) -> Result TableImportSourceFormat::Delimited, ImportFileKind::Json => TableImportSourceFormat::Json, ImportFileKind::Xlsx => TableImportSourceFormat::Excel, + ImportFileKind::Sql => TableImportSourceFormat::Sql, }) } @@ -1085,6 +1102,479 @@ pub fn parse_json_bytes(bytes: &[u8], preview_limit: usize) -> Result, + columns_generated: bool, +} + +/// 按选择的文本编码解码 SQL 脚本字节(未指定时自动检测),返回脚本文本与实际编码。 +fn decode_sql_script_bytes( + bytes: &[u8], + requested: Option, +) -> Result<(String, TableImportTextEncoding), String> { + let (encoding, bom_len) = resolve_text_encoding_from_bytes(bytes, requested)?; + let charset = encoding.encoding().ok_or_else(|| "SQL import requires a resolved text encoding".to_string())?; + let (decoded, _, _) = charset.decode(&bytes[bom_len.min(bytes.len())..]); + Ok((decoded.into_owned(), encoding)) +} + +/// 源 SQL 方言家族:决定未加引号标识符的大小写折叠规则。 +/// 字符串/表达式/标识符的词法解析已交给 sqlparser(按方言正确解释反斜杠、E'...'、 +/// X'...' 等),这里只按方言决定标识符的显示与比较规则,避免把 PostgreSQL 的 +/// "Foo" 与 foo 错误合并。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SqlImportDialectFamily { + Postgres, + MySql, + Sqlite, + SqlServer, + Oracle, + Generic, +} + +fn sql_import_dialect_family(db_type: DatabaseType) -> SqlImportDialectFamily { + if matches!( + db_type, + DatabaseType::Postgres + | DatabaseType::Redshift + | DatabaseType::Kingbase + | DatabaseType::Highgo + | DatabaseType::Uxdb + | DatabaseType::Vastbase + | DatabaseType::OpenGauss + | DatabaseType::Gaussdb + | DatabaseType::Kwdb + | DatabaseType::Iris + ) { + SqlImportDialectFamily::Postgres + } else if matches!( + db_type, + DatabaseType::Mysql + | DatabaseType::Doris + | DatabaseType::StarRocks + | DatabaseType::ManticoreSearch + | DatabaseType::Goldendb + ) { + SqlImportDialectFamily::MySql + } else if matches!( + db_type, + DatabaseType::Sqlite | DatabaseType::Rqlite | DatabaseType::Turso | DatabaseType::CloudflareD1 + ) { + SqlImportDialectFamily::Sqlite + } else if matches!(db_type, DatabaseType::SqlServer) { + SqlImportDialectFamily::SqlServer + } else if matches!( + db_type, + DatabaseType::Oracle + | DatabaseType::Dameng + | DatabaseType::OceanbaseOracle + | DatabaseType::Yashandb + | DatabaseType::Oscar + | DatabaseType::Xugu + ) { + SqlImportDialectFamily::Oracle + } else { + SqlImportDialectFamily::Generic + } +} + +fn sql_import_parser_dialect(family: SqlImportDialectFamily) -> Box { + match family { + SqlImportDialectFamily::Postgres => Box::new(PostgreSqlDialect {}), + SqlImportDialectFamily::MySql => Box::new(MySqlDialect {}), + SqlImportDialectFamily::Sqlite => Box::new(SQLiteDialect {}), + SqlImportDialectFamily::SqlServer => Box::new(MsSqlDialect {}), + SqlImportDialectFamily::Oracle => Box::new(OracleDialect {}), + SqlImportDialectFamily::Generic => Box::new(GenericDialect {}), + } +} + +/// 标识符的展示名:PostgreSQL 未加引号标识符折叠为小写,加引号保留原样; +/// 其它方言保留原文大小写。 +fn sql_import_ident_display(ident: &Ident, family: SqlImportDialectFamily) -> String { + if family == SqlImportDialectFamily::Postgres && ident.quote_style.is_none() { + ident.value.to_lowercase() + } else { + ident.value.clone() + } +} + +/// 判断两组列名是否指向同一列清单。 +/// - PostgreSQL:未加引号已折叠为小写、加引号保留原样,精确比较即可区分 "Foo" 与 foo。 +/// - 其它方言(MySQL/SQLite/…):列名大小写不敏感。 +fn sql_import_names_match(a: &[String], b: &[String], family: SqlImportDialectFamily) -> bool { + a.len() == b.len() + && a.iter().zip(b).all(|(left, right)| { + if family == SqlImportDialectFamily::Postgres { + left == right + } else { + left.eq_ignore_ascii_case(right) + } + }) +} + +fn sql_import_table_matches(a: &str, b: &str, family: SqlImportDialectFamily) -> bool { + if family == SqlImportDialectFamily::Postgres { + a == b + } else { + a.eq_ignore_ascii_case(b) + } +} + +/// 从 `ObjectName`(可能是 `schema.column`)取出最后一个标识符部分。 +fn sql_import_object_name_ident<'a>(name: &'a ObjectName, what: &str) -> Result<&'a Ident, String> { + let Some(last) = name.0.last() else { + return Err(format!("SQL import: empty {what} name")); + }; + match last { + ObjectNamePart::Identifier(ident) => Ok(ident), + ObjectNamePart::Function(_) => Err(format!("SQL import: function-based {what} names are not supported")), + } +} + +fn sql_import_number_value(raw: &str, context: &str) -> Result { + // 优先保留整数精度;小数与科学计数法回退到浮点。 + if let Ok(integer) = raw.parse::() { + return Ok(serde_json::Value::Number(integer.into())); + } + if let Ok(float) = raw.parse::() { + if float.is_finite() { + if float.fract() == 0.0 && float >= i64::MIN as f64 && float < -(i64::MIN as f64) { + let integer = float as i64; + if integer as f64 == float { + return Ok(serde_json::Value::Number(integer.into())); + } + } + if let Some(number) = serde_json::Number::from_f64(float) { + return Ok(serde_json::Value::Number(number)); + } + } + } + Err(format!("{context}: unsupported numeric literal '{raw}'")) +} + +fn sql_import_expr_value(expr: &Expr, context: &str) -> Result { + match expr { + Expr::Value(value) => match &value.value { + SqlValue::Number(raw, _) => sql_import_number_value(raw.as_str(), context), + SqlValue::Boolean(flag) => Ok(serde_json::Value::Bool(*flag)), + SqlValue::Null => Ok(serde_json::Value::Null), + // 二进制/十六进制字面量无法无损地作为文本导入,明确拒绝而非静默改写。 + SqlValue::HexStringLiteral(_) + | SqlValue::SingleQuotedByteStringLiteral(_) + | SqlValue::DoubleQuotedByteStringLiteral(_) => { + Err(format!("{context}: binary/hex literals (X'..', B'..') are not supported for SQL import")) + } + SqlValue::Placeholder(_) => Err(format!("{context}: bind placeholders are not supported for SQL import")), + // 普通字符串字面量:转义已由 sqlparser 按源方言正确解码。 + other => match other.clone().into_string() { + Some(text) => Ok(serde_json::Value::String(text)), + None => Err(format!("{context}: unsupported SQL literal")), + }, + }, + // 一元正负号作用于数值字面量:`-1.5`、`+3` 等是合法值,不能当表达式拒绝。 + Expr::UnaryOp { op, expr } => match op { + UnaryOperator::Minus => { + if let Expr::Value(value) = expr.as_ref() { + if let SqlValue::Number(raw, _) = &value.value { + return sql_import_number_value(&format!("-{raw}"), context); + } + } + Err(sql_import_unsupported_expression(context)) + } + UnaryOperator::Plus => { + if let Expr::Value(value) = expr.as_ref() { + if let SqlValue::Number(raw, _) = &value.value { + return sql_import_number_value(raw.as_str(), context); + } + } + Err(sql_import_unsupported_expression(context)) + } + _ => Err(sql_import_unsupported_expression(context)), + }, + // `DATE '...'`、`TIMESTAMP '...'`、`TIME '...'` 等标准字面量:值就是引号里的字符串,无损导入。 + Expr::TypedString(typed) => sql_import_typed_string(typed, context), + // 白名单字面量日期函数(TO_DATE/TO_TIMESTAMP/...):参数全为字符串字面量时按第一个参数导入。 + Expr::Function(function) => sql_import_literal_temporal_function(function, context), + _ => Err(sql_import_unsupported_expression(context)), + } +} + +/// `DATE '...'`、`TIMESTAMP '...'`、`TIME '...'` 这类标准字面量:值就是引号里的字符串, +/// 无损地作为字符串导入。其它 TypedString(如 INTERVAL)语义更复杂,不展开。 +fn sql_import_typed_string(typed: &sqlparser::ast::TypedString, context: &str) -> Result { + match &typed.data_type { + DataType::Date | DataType::Time(..) | DataType::Timestamp(..) => match typed.value.clone().into_string() { + Some(text) => Ok(serde_json::Value::String(text)), + None => Err(sql_import_unsupported_expression(context)), + }, + _ => Err(sql_import_unsupported_expression(context)), + } +} + +/// 白名单“字面量日期函数”:`TO_DATE('2021-09-08 09:06:25', 'YYYY-MM-DD HH24:MI:SS')` 这类调用, +/// 当参数全部是字符串字面量时,其“值”就是第一个字符串实参表示的日期时间,按该字符串无损导入。 +/// 任何非字面量参数(列引用、表达式、命名参数、ORDER BY 子句等)都不展开,回退到“表达式不支持”, +/// 避免静默改变语句语义。 +fn sql_import_literal_temporal_function( + function: &sqlparser::ast::Function, + context: &str, +) -> Result { + let name = function.name.to_string().to_ascii_lowercase(); + if !matches!(name.as_str(), "to_date" | "to_timestamp" | "to_timestamp_tz") { + return Err(sql_import_unsupported_expression(context)); + } + let FunctionArguments::List(list) = &function.args else { + return Err(sql_import_unsupported_expression(context)); + }; + if list.args.is_empty() || !list.clauses.is_empty() || list.duplicate_treatment.is_some() { + return Err(sql_import_unsupported_expression(context)); + } + + let mut first_value: Option = None; + for arg in &list.args { + let FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::Value(value))) = arg else { + return Err(sql_import_unsupported_expression(context)); + }; + let text = value.clone().into_string().ok_or_else(|| sql_import_unsupported_expression(context))?; + if first_value.is_none() { + first_value = Some(text); + } + } + + let value = first_value.expect("args non-empty checked above"); + Ok(serde_json::Value::String(value)) +} + +fn sql_import_unsupported_expression(context: &str) -> String { + format!( + "{context}: expressions (functions, operators, casts, ...) are not supported; \ + SQL import only accepts literal values so statement semantics are preserved" + ) +} + +/// 解析单条 INSERT 语句,把值行登记到 `target`/`rows`。 +/// REPLACE、INSERT IGNORE、INSERT ... SELECT、INSERT ... SET、ON CONFLICT 等无法用 +/// 普通 INSERT 无损表达的构造一律拒绝。 +fn parse_sql_insert_statement( + insert: &Insert, + family: SqlImportDialectFamily, + target: &mut Option, + rows: &mut Vec>, + preview_limit: usize, + total_rows: &mut usize, +) -> Result<(), String> { + if insert.replace_into { + return Err( + "SQL import does not support REPLACE; its delete-then-insert conflict semantics cannot be preserved" + .to_string(), + ); + } + if insert.ignore { + return Err("SQL import does not support INSERT IGNORE".to_string()); + } + if insert.or.is_some() { + return Err("SQL import does not support INSERT OR ... conflict clauses".to_string()); + } + if insert.on.is_some() { + return Err("SQL import does not support ON DUPLICATE KEY / ON CONFLICT clauses".to_string()); + } + if !insert.assignments.is_empty() { + return Err("SQL import does not support INSERT ... SET".to_string()); + } + if insert.returning.is_some() || insert.output.is_some() { + return Err("SQL import does not support INSERT ... RETURNING / OUTPUT".to_string()); + } + + let table_ref = match &insert.table { + TableObject::TableName(name) => name, + TableObject::TableFunction(_) | TableObject::TableQuery(_) => { + return Err("SQL import only supports INSERT into a named table".to_string()); + } + }; + let table_ident = sql_import_object_name_ident(table_ref, "table")?; + let table_name = sql_import_ident_display(table_ident, family); + + if let Some(existing) = target.as_ref() { + if !sql_import_table_matches(&existing.table, &table_name, family) { + return Err(format!( + "SQL import supports one table per file; found '{}' and '{}'", + existing.table, table_name + )); + } + } + + let explicit_columns = insert + .columns + .iter() + .map(|column| { + sql_import_object_name_ident(column, "column").map(|ident| sql_import_ident_display(ident, family)) + }) + .collect::, String>>()?; + + let Some(source) = insert.source.as_deref() else { + return Err("SQL import only supports INSERT ... VALUES".to_string()); + }; + let SetExpr::Values(values) = source.body.as_ref() else { + return Err("SQL import only supports INSERT ... VALUES; INSERT ... SELECT is not supported".to_string()); + }; + + // 登记/校验列清单:显式列优先,无列清单时延后到首行按值数量推断(保留旧行为), + // 列名比较改为按方言规则(区分引号状态)。 + if !explicit_columns.is_empty() { + match target.as_mut() { + None => { + *target = Some(SqlInsertTarget { + table: table_name.clone(), + columns: explicit_columns, + columns_generated: false, + }); + } + Some(existing) => { + if !sql_import_names_match(&existing.columns, &explicit_columns, family) { + if existing.columns_generated && existing.columns.len() == explicit_columns.len() { + existing.columns = explicit_columns; + existing.columns_generated = false; + } else { + return Err(format!( + "INSERT statements use different column lists for table '{}'", + existing.table + )); + } + } + } + } + } + + for row in &values.rows { + let values = &row.content; + if target.is_none() { + if values.is_empty() { + return Err("INSERT statement has an empty value tuple".to_string()); + } + let columns = (0..values.len()).map(|index| format!("column_{}", index + 1)).collect::>(); + *target = Some(SqlInsertTarget { table: table_name.clone(), columns, columns_generated: true }); + } + let columns = &target.as_ref().expect("insert target registered above").columns; + if values.len() != columns.len() { + return Err(format!( + "INSERT row has {} values but table '{}' expects {} columns", + values.len(), + table_name, + columns.len() + )); + } + let mut converted = Vec::with_capacity(values.len()); + for (index, value) in values.iter().enumerate() { + let context = format!("SQL import: table '{table_name}', column {}", columns[index]); + converted.push(sql_import_expr_value(value, &context)?); + } + *total_rows += 1; + if rows.len() < preview_limit { + rows.push(converted); + } + } + Ok(()) +} + +/// 跳过空白与 SQL 注释,返回第一个关键字(用于判断语句是否以 INSERT/REPLACE 开头)。 +fn sql_import_leading_keyword(statement: &str) -> Option { + let bytes = statement.as_bytes(); + let mut index = 0usize; + loop { + while index < bytes.len() && bytes[index].is_ascii_whitespace() { + index += 1; + } + if index + 1 < bytes.len() && bytes[index] == b'-' && bytes[index + 1] == b'-' { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + continue; + } + if index < bytes.len() && bytes[index] == b'#' { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + continue; + } + if index + 1 < bytes.len() && bytes[index] == b'/' && bytes[index + 1] == b'*' { + index += 2; + while index + 1 < bytes.len() && !(bytes[index] == b'*' && bytes[index + 1] == b'/') { + index += 1; + } + index = (index + 2).min(bytes.len()); + continue; + } + break; + } + let start = index; + while index < bytes.len() && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') { + index += 1; + } + if index == start { + return None; + } + Some(statement[start..index].to_string()) +} + +pub fn parse_sql_bytes_with_options( + bytes: &[u8], + options: &TableImportParseOptions, + preview_limit: usize, +) -> Result { + let (text, encoding) = decode_sql_script_bytes(bytes, options.encoding)?; + let family = options.sql_dialect.map(sql_import_dialect_family).unwrap_or(SqlImportDialectFamily::Generic); + let dialect = sql_import_parser_dialect(family); + + // 复用 sql.rs 的方言感知拆分器:正确处理 MySQL DELIMITER、PostgreSQL 美元引用、 + // SQL Server GO、Oracle / 等,替代原来按分号裸切的实现。 + let statements = match options.sql_dialect { + Some(db_type) => crate::sql::split_sql_statements_for_database(&text, db_type), + None => crate::sql::split_sql_statements(&text), + }; + + let mut target: Option = None; + let mut rows: Vec> = Vec::new(); + let mut total_rows = 0usize; + + for statement_sql in statements { + let parsed = match Parser::parse_sql(dialect.as_ref(), &statement_sql) { + Ok(statements) => statements, + Err(error) => { + // 非 INSERT 语句(DDL、SET、……)不属于数据导入范畴,跳过; + // 以 INSERT/REPLACE 开头却解析失败的语句必须报错,不能静默丢弃。 + let keyword = sql_import_leading_keyword(&statement_sql); + let is_insert = keyword + .as_deref() + .is_some_and(|word| word.eq_ignore_ascii_case("INSERT") || word.eq_ignore_ascii_case("REPLACE")); + if is_insert { + return Err(format!("SQL import could not parse INSERT statement: {error}")); + } + continue; + } + }; + for statement in parsed { + let Statement::Insert(insert) = statement else { + continue; + }; + parse_sql_insert_statement(&insert, family, &mut target, &mut rows, preview_limit, &mut total_rows)?; + } + } + + let target = target.ok_or_else(|| "No INSERT statements found in SQL file".to_string())?; + Ok(ParsedImportFile { columns: target.columns, rows, total_rows, effective_encoding: Some(encoding) }) +} + +pub fn parse_sql_bytes(bytes: &[u8], preview_limit: usize) -> Result { + parse_sql_bytes_with_options(bytes, &TableImportParseOptions::default(), preview_limit) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum XlsxTemporalKind { Date, @@ -2964,6 +3454,16 @@ async fn parse_import_file_with_options_and_text_columns( let bytes = tokio::fs::read(path).await.map_err(|e| e.to_string())?; parse_json_bytes_with_options(&bytes, options, preview_limit) } + TableImportSourceFormat::Sql => { + let path = path.to_string(); + let options = options.clone(); + tokio::task::spawn_blocking(move || { + let bytes = std::fs::read(&path).map_err(|e| e.to_string())?; + parse_sql_bytes_with_options(&bytes, &options, preview_limit) + }) + .await + .map_err(|e| e.to_string())? + } TableImportSourceFormat::Excel => { let path = path.to_string(); let options = options.clone(); @@ -7685,6 +8185,226 @@ mod tests { assert!(error.contains("configured for object rows")); } + fn sql_import_options(dialect: DatabaseType) -> TableImportParseOptions { + TableImportParseOptions { sql_dialect: Some(dialect), ..TableImportParseOptions::default() } + } + + #[test] + fn parses_sql_insert_with_column_list_and_comments() { + let script = b"-- dump header comment\n\ + /*!40101 SET NAMES utf8mb4 */;\n\ + CREATE TABLE users (id INT, name TEXT);\n\ + /* block comment; with semicolon */\n\ + INSERT INTO `users` (`id`, `name`) VALUES (1, 'Ada'), (2, 'Bob');\n\ + INSERT INTO users (id, name) VALUES (3, 'Cathy');"; + let options = sql_import_options(DatabaseType::Mysql); + let parsed = parse_sql_bytes_with_options(script, &options, 10).unwrap(); + + assert_eq!(parsed.columns, vec!["id", "name"]); + assert_eq!(parsed.total_rows, 3); + assert_eq!(parsed.rows[0], vec![serde_json::json!(1), serde_json::json!("Ada")]); + assert_eq!(parsed.rows[2], vec![serde_json::json!(3), serde_json::json!("Cathy")]); + } + + #[test] + fn parses_sql_insert_without_column_list_using_generated_columns() { + let script = b"INSERT INTO users VALUES (1, 'it''s'), (2, 'back\\'slash');"; + let options = sql_import_options(DatabaseType::Mysql); + let parsed = parse_sql_bytes_with_options(script, &options, 10).unwrap(); + + assert_eq!(parsed.columns, vec!["column_1", "column_2"]); + assert_eq!(parsed.total_rows, 2); + assert_eq!(parsed.rows[0], vec![serde_json::json!(1), serde_json::json!("it's")]); + assert_eq!(parsed.rows[1], vec![serde_json::json!(2), serde_json::json!("back'slash")]); + } + + #[test] + fn parses_sql_insert_value_kinds() { + let script = b"INSERT INTO t (a, b, c, d, e, f) VALUES \ + (NULL, TRUE, FALSE, -1.5, 3, '2026-01-01 10:00:00');"; + let parsed = parse_sql_bytes(script, 10).unwrap(); + + assert_eq!(parsed.total_rows, 1); + assert_eq!( + parsed.rows[0], + vec![ + serde_json::Value::Null, + serde_json::json!(true), + serde_json::json!(false), + serde_json::json!(-1.5), + serde_json::json!(3), + serde_json::json!("2026-01-01 10:00:00"), + ] + ); + } + + #[test] + fn sql_import_rejects_replace() { + let error = parse_sql_bytes(b"REPLACE INTO users (id) VALUES (1);", 10).unwrap_err(); + assert!(error.contains("REPLACE")); + } + + #[test] + fn sql_import_rejects_expressions() { + let error = parse_sql_bytes(b"INSERT INTO t (a) VALUES (NOW());", 10).unwrap_err(); + assert!(error.contains("not supported")); + } + + #[test] + fn sql_import_expands_literal_temporal_functions() { + let options = sql_import_options(DatabaseType::Oracle); + let script = b"INSERT INTO t (a, b, c) VALUES \ + (TO_DATE('2021-09-08 09:06:25', 'YYYY-MM-DD HH24:MI:SS'), \ + TO_TIMESTAMP('2021-09-08 09:06:25', 'YYYY-MM-DD HH24:MI:SS'), \ + TIMESTAMP '2021-09-08 09:06:25');"; + let parsed = parse_sql_bytes_with_options(script, &options, 10).unwrap(); + + assert_eq!(parsed.rows[0][0], serde_json::json!("2021-09-08 09:06:25")); + assert_eq!(parsed.rows[0][1], serde_json::json!("2021-09-08 09:06:25")); + assert_eq!(parsed.rows[0][2], serde_json::json!("2021-09-08 09:06:25")); + } + + #[test] + fn sql_import_expands_typed_date_literal() { + let script = b"INSERT INTO t (a) VALUES (DATE '2021-09-08');"; + let parsed = parse_sql_bytes(script, 10).unwrap(); + assert_eq!(parsed.rows[0][0], serde_json::json!("2021-09-08")); + } + + #[test] + fn sql_import_expands_temporal_function_generic_dialect() { + // 未指定目标方言(Generic)时也按函数名展开。 + let script = b"INSERT INTO t (a) VALUES (TO_DATE('2021-09-08 09:06:25', 'YYYY-MM-DD HH24:MI:SS'));"; + let parsed = parse_sql_bytes(script, 10).unwrap(); + assert_eq!(parsed.rows[0][0], serde_json::json!("2021-09-08 09:06:25")); + } + + #[test] + fn sql_import_rejects_temporal_function_with_non_literal_args() { + let options = sql_import_options(DatabaseType::Oracle); + // 列引用作为参数:无法无损展开,应拒绝而非静默改写。 + let error = + parse_sql_bytes_with_options(b"INSERT INTO t (a) VALUES (TO_DATE(col, 'YYYY-MM-DD'));", &options, 10) + .unwrap_err(); + assert!(error.contains("not supported")); + } + + #[test] + fn sql_import_rejects_unlisted_function_still() { + // 非白名单函数(如 NOW())仍按表达式拒绝。 + let options = sql_import_options(DatabaseType::Mysql); + let error = parse_sql_bytes_with_options(b"INSERT INTO t (a) VALUES (NOW());", &options, 10).unwrap_err(); + assert!(error.contains("not supported")); + } + + #[test] + fn sql_import_rejects_binary_literals() { + let error = parse_sql_bytes(b"INSERT INTO t (a) VALUES (X'1A2B');", 10).unwrap_err(); + assert!(error.contains("binary/hex")); + } + + #[test] + fn sql_import_rejects_insert_select() { + let error = parse_sql_bytes(b"INSERT INTO t (a) SELECT a FROM other;", 10).unwrap_err(); + assert!(error.contains("VALUES")); + } + + #[test] + fn sql_import_postgres_treats_backslash_as_literal() { + // PostgreSQL 普通字符串中的反斜杠是字面量,不解释为转义。 + let script = b"INSERT INTO t (a) VALUES ('a\\nb');"; + let options = sql_import_options(DatabaseType::Postgres); + let parsed = parse_sql_bytes_with_options(script, &options, 10).unwrap(); + assert_eq!(parsed.rows[0], vec![serde_json::json!("a\\nb")]); + } + + #[test] + fn sql_import_mysql_decodes_backslash_escapes() { + // MySQL 普通字符串中的反斜杠转义(\n → 换行)。 + let script = b"INSERT INTO t (a) VALUES ('a\\nb');"; + let options = sql_import_options(DatabaseType::Mysql); + let parsed = parse_sql_bytes_with_options(script, &options, 10).unwrap(); + assert_eq!(parsed.rows[0], vec![serde_json::json!("a\nb")]); + } + + #[test] + fn sql_import_postgres_distinguishes_quoted_identifiers() { + // 加引号的 "Foo" 与未加引号的 foo 在 PostgreSQL 中是不同标识符。 + let script = b"INSERT INTO t (\"Foo\", foo) VALUES (1, 2);"; + let options = sql_import_options(DatabaseType::Postgres); + let parsed = parse_sql_bytes_with_options(script, &options, 10).unwrap(); + assert_eq!(parsed.columns, vec!["Foo", "foo"]); + } + + #[test] + fn sql_import_postgres_rejects_mismatched_quoted_column_lists() { + // "Foo" 与 foo 不同,不能合并为同一张表的列清单。 + let script = b"INSERT INTO t (\"Foo\") VALUES (1); INSERT INTO t (foo) VALUES (2);"; + let options = sql_import_options(DatabaseType::Postgres); + let error = parse_sql_bytes_with_options(script, &options, 10).unwrap_err(); + assert!(error.contains("different column lists")); + } + + #[test] + fn sql_import_rejects_multiple_tables() { + let script = b"INSERT INTO a VALUES (1); INSERT INTO b VALUES (2);"; + let error = parse_sql_bytes(script, 10).unwrap_err(); + + assert!(error.contains("one table per file")); + } + + #[test] + fn sql_import_rejects_row_arity_mismatch() { + let script = b"INSERT INTO t (a, b) VALUES (1, 2), (3);"; + let error = parse_sql_bytes(script, 10).unwrap_err(); + + assert!(error.contains("expects 2 columns")); + } + + #[test] + fn sql_import_rejects_file_without_insert_statements() { + let error = parse_sql_bytes(b"CREATE TABLE t (id INT); SET NAMES utf8;", 10).unwrap_err(); + + assert!(error.contains("No INSERT statements found")); + } + + #[test] + fn sql_import_caps_preview_rows_but_counts_total() { + let script = b"INSERT INTO t (id) VALUES (1), (2), (3), (4), (5);"; + let parsed = parse_sql_bytes(script, 2).unwrap(); + + assert_eq!(parsed.total_rows, 5); + assert_eq!(parsed.rows.len(), 2); + } + + #[test] + fn sql_import_decodes_gbk_script() { + // INSERT INTO t (name) VALUES ('中文'); encoded as GBK + let mut script = b"INSERT INTO t (name) VALUES ('".to_vec(); + script.extend_from_slice(&[0xD6, 0xD0, 0xCE, 0xC4]); + script.extend_from_slice(b"');"); + let options = TableImportParseOptions { + encoding: Some(TableImportTextEncoding::Gbk), + ..TableImportParseOptions::default() + }; + let parsed = parse_sql_bytes_with_options(&script, &options, 10).unwrap(); + + assert_eq!(parsed.rows[0], vec![serde_json::json!("中文")]); + assert_eq!(parsed.effective_encoding, Some(TableImportTextEncoding::Gbk)); + } + + #[test] + fn parses_sql_insert_with_semicolon_and_quote_inside_comments() { + // 注释里的分号与引号不能作为语句边界;字符串里的分号同理 + let script = b"-- note: don't split; here\n\ + INSERT INTO t (a) VALUES ('a;b'), ('it -- not a comment');"; + let parsed = parse_sql_bytes(script, 10).unwrap(); + + assert_eq!(parsed.total_rows, 2); + assert_eq!(parsed.rows[0], vec![serde_json::json!("a;b")]); + assert_eq!(parsed.rows[1], vec![serde_json::json!("it -- not a comment")]); + } + #[test] fn parses_selected_excel_sheet() { let path = std::env::temp_dir().join(format!("dbx-table-import-{}.xlsx", uuid::Uuid::new_v4())); diff --git a/docs/content/docs/table-import.cn.mdx b/docs/content/docs/table-import.cn.mdx index 22a081e6c8..96bea2119a 100644 --- a/docs/content/docs/table-import.cn.mdx +++ b/docs/content/docs/table-import.cn.mdx @@ -1,6 +1,6 @@ --- title: 表数据导入 -description: 通过向导将 CSV、TSV、文本、JSON 或 Excel 数据导入现有表,或按文件批量创建新表。 +description: 通过向导将 CSV、TSV、文本、JSON、Excel 或 SQL 数据导入现有表,或按文件批量创建新表。 --- 表数据导入用于把文件数据写入数据库表。DBX 会先解析和预览文件,再让你确认解析选项、目标表、字段映射和执行方式,而不是选中文件后立即写入。 @@ -25,6 +25,11 @@ description: 通过向导将 CSV、TSV、文本、JSON 或 Excel 数据导入现 | 分隔文本 | `.txt` 等文本文件,可自定义分隔符 | | JSON | 支持单个对象、对象数组或数组行;可以指定对象或数组结构 | | Excel | 支持 `.xlsx`、`.xlsm` 和 `.xls`,可选择工作表 | +| SQL | 包含字面量 `INSERT INTO ... VALUES` 语句的 `.sql` 脚本,每个文件对应一张表 | + +SQL 导入只接受字面量 `INSERT INTO ... VALUES` 行;表达式、`REPLACE`、`INSERT IGNORE`、`INSERT ... SELECT` 等无法无损改写的内容会被拒绝,以保留数据和语句语义。 + +SQL 导入通过非流式路径读取整个脚本,单个 `.sql` 文件不能超过 100 MB,超出上限会显式报错。`TO_DATE`、`TO_TIMESTAMP` 等日期函数的值会按第一个参数的文本导入,目标字段必须能解析该格式。 Desktop 直接读取所选本地文件;Docker/Web 会先把浏览器选择的文件上传到服务器临时目录,预览和导入结束后释放临时源文件。 @@ -67,7 +72,7 @@ Desktop 直接读取所选本地文件;Docker/Web 会先把浏览器选择的 ### 文本编码 -CSV、TSV 和分隔文本支持自动检测、UTF-8、GBK、UTF-16 LE 和 UTF-16 BE。自动检测不正确时,请手动选择编码后重新检查预览。 +CSV、TSV、分隔文本和 SQL 脚本支持自动检测、UTF-8、GBK、UTF-16 LE 和 UTF-16 BE。自动检测不正确时,请手动选择编码后重新检查预览。 ### 行范围 diff --git a/docs/content/docs/table-import.mdx b/docs/content/docs/table-import.mdx index 0a1e5eb2c7..924431be29 100644 --- a/docs/content/docs/table-import.mdx +++ b/docs/content/docs/table-import.mdx @@ -1,6 +1,6 @@ --- title: Table Import -description: Use a guided workflow to import CSV, TSV, text, JSON, or Excel data into an existing table or create new tables in batches. +description: Use a guided workflow to import CSV, TSV, text, JSON, Excel, or SQL data into an existing table or create new tables in batches. --- Table Import writes file data into database tables. DBX parses and previews the source first, then asks you to review parsing options, the target, column mappings, and execution settings before any rows are written. @@ -25,6 +25,11 @@ Create-table mode accepts multiple files at once. Excel workbooks are expanded i | Delimited text | Files such as `.txt` with a custom delimiter | | JSON | A single object, object rows, or array rows; the shape can be selected explicitly | | Excel | `.xlsx`, `.xlsm`, and `.xls`, with worksheet selection | +| SQL | `.sql` scripts containing literal `INSERT INTO ... VALUES` statements for one table | + +SQL import accepts only literal `INSERT INTO ... VALUES` rows. Expressions, `REPLACE`, `INSERT IGNORE`, `INSERT ... SELECT`, and other constructs that cannot be rewritten losslessly are rejected so data and statement semantics are preserved. + +SQL import reads the whole script through the non-streaming path, so each `.sql` file is limited to 100 MB; larger files are rejected with an explicit error. Values written with date functions such as `TO_DATE` and `TO_TIMESTAMP` are imported as the text of their first argument, and the target column must be able to parse that format. Desktop reads selected local files directly. Docker/Web uploads browser-selected files to temporary server storage, then releases the prepared source after preview and import finish. @@ -67,7 +72,7 @@ Desktop reads selected local files directly. Docker/Web uploads browser-selected ### Text Encoding -CSV, TSV, and delimited text support auto detection, UTF-8, GBK, UTF-16 LE, and UTF-16 BE. If detection is wrong, select an encoding manually and recheck the preview. +CSV, TSV, delimited text, and SQL scripts support auto detection, UTF-8, GBK, UTF-16 LE, and UTF-16 BE. If detection is wrong, select an encoding manually and recheck the preview. ### Row Range