diff --git a/apps/desktop/src/components/grid/DataGridExtractorDialog.vue b/apps/desktop/src/components/grid/DataGridExtractorDialog.vue index e9d1dd17d3..6186eab31b 100644 --- a/apps/desktop/src/components/grid/DataGridExtractorDialog.vue +++ b/apps/desktop/src/components/grid/DataGridExtractorDialog.vue @@ -69,6 +69,10 @@ function updateSql(key: K, valu draftOptions.value = { ...draftOptions.value, sql: { ...draftOptions.value.sql, [key]: value } }; } +function updateJson(key: K, value: DataGridExtractorOptions["json"][K]) { + draftOptions.value = { ...draftOptions.value, json: { ...draftOptions.value.json, [key]: value } }; +} + async function refreshPreview() { const sequence = ++previewSequence; previewLoading.value = true; @@ -217,7 +221,10 @@ function save() { - +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 435d0357ae..66f96b0499 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1521,6 +1521,7 @@ export default { copyExtractorInsertMerged: "Merge rows", copyExtractorInsertRowByRow: "One statement per row", copyExtractorPrettyJson: "Pretty-print JSON", + copyExtractorCamelCaseJsonFields: "Convert snake_case fields to camelCase", copyExtractorPreview: "Preview (current selection)", copyExtractorEmptySelection: "Select data to preview the extractor output.", copyExtractorUnsupportedSelection: "This extractor requires the same columns to be selected in every row.", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 220875faf0..b87853d2cd 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1867,6 +1867,7 @@ export default withEnglishFallback({ copyExtractorInsertMerged: "Combinar varias filas", copyExtractorInsertRowByRow: "Una sentencia por fila", copyExtractorPrettyJson: "JSON formateado", + copyExtractorCamelCaseJsonFields: "Convertir campos snake_case a camelCase", copyExtractorPreview: "Vista previa (selección actual)", copyExtractorEmptySelection: "Seleccione datos para previsualizar el resultado de la extracción.", copyExtractorUnsupportedSelection: "El extractor actual requiere seleccionar las mismas columnas en cada fila.", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index add831615c..b6b24fd196 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1865,6 +1865,7 @@ export default withEnglishFallback({ copyExtractorInsertMerged: "Unisci più righe", copyExtractorInsertRowByRow: "Una istruzione per riga", copyExtractorPrettyJson: "JSON formattato", + copyExtractorCamelCaseJsonFields: "Converti i campi snake_case in camelCase", copyExtractorPreview: "Anteprima (selezione corrente)", copyExtractorEmptySelection: "Seleziona i dati per visualizzare l'anteprima dell'estrazione.", copyExtractorUnsupportedSelection: "L'estrattore corrente richiede la stessa selezione di colonne per ogni riga.", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index cdb62828bf..040843b8d7 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1887,6 +1887,7 @@ export default withEnglishFallback({ copyExtractorInsertMerged: "複数行をマージ", copyExtractorInsertRowByRow: "1行ごとに1文", copyExtractorPrettyJson: "JSONを整形", + copyExtractorCamelCaseJsonFields: "snake_caseのフィールド名をcamelCaseに変換", copyExtractorPreview: "プレビュー(現在の選択)", copyExtractorEmptySelection: "データを選択してから抽出結果をプレビューしてください。", copyExtractorUnsupportedSelection: "現在の抽出器では各行で同じ列を選択する必要があります。", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 57071d0a5c..a58b526c76 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1405,6 +1405,7 @@ export default withEnglishFallback({ copyExtractorInsertMerged: "행 병합", copyExtractorInsertRowByRow: "행별 한 구문", copyExtractorPrettyJson: "JSON 보기 좋게", + copyExtractorCamelCaseJsonFields: "snake_case 필드 이름을 camelCase로 변환", copyExtractorPreview: "미리보기 (현재 선택)", copyExtractorEmptySelection: "추출기 출력을 미리보려면 데이터를 선택하세요.", copyExtractorUnsupportedSelection: "이 추출기는 모든 행에서 동일한 컬럼이 선택되어야 합니다.", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index a489f71d86..09249d5047 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1867,6 +1867,7 @@ export default withEnglishFallback({ copyExtractorInsertMerged: "Mesclar várias linhas", copyExtractorInsertRowByRow: "Uma instrução por linha", copyExtractorPrettyJson: "JSON formatado", + copyExtractorCamelCaseJsonFields: "Converter campos snake_case para camelCase", copyExtractorPreview: "Visualizar (seleção atual)", copyExtractorEmptySelection: "Selecione dados para visualizar os resultados da extração.", copyExtractorUnsupportedSelection: "O extrator atual exige que as mesmas colunas sejam selecionadas para cada linha.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 14f71016b1..67d3e8bae0 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1445,6 +1445,7 @@ export default withEnglishFallback({ copyExtractorInsertMerged: "合并多行", copyExtractorInsertRowByRow: "每行一条语句", copyExtractorPrettyJson: "格式化 JSON", + copyExtractorCamelCaseJsonFields: "下划线字段名转为小驼峰", copyExtractorPreview: "预览(当前选择)", copyExtractorEmptySelection: "请选择数据后预览提取结果。", copyExtractorUnsupportedSelection: "当前提取器要求每一行选择相同的列。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 829fcdb81c..8db2b2a536 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1871,6 +1871,7 @@ export default withEnglishFallback({ copyExtractorInsertMerged: "合併多行", copyExtractorInsertRowByRow: "每行一條語句", copyExtractorPrettyJson: "格式化 JSON", + copyExtractorCamelCaseJsonFields: "將底線欄位名稱轉為小駝峰", copyExtractorPreview: "預覽(目前選擇)", copyExtractorEmptySelection: "請選擇資料後預覽提取結果。", copyExtractorUnsupportedSelection: "目前提取器要求每一行選擇相同的列。", diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridCopyExtractor.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridCopyExtractor.spec.ts index 14a4ce2a3b..d897eb5aa5 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridCopyExtractor.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridCopyExtractor.spec.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, normalizeDataGridCopyPreference, normalizeDataGridExtractorOptions, resolveDataGridCopyPreference, validateDataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor"; @@ -18,6 +19,20 @@ describe("data-grid extractor options", () => { expect(normalized.dsv).not.toBe(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.dsv); }); + it("keeps camel-case JSON field names explicit and disabled by default", () => { + expect(normalizeDataGridExtractorOptions({ json: {} }).json.camelCaseFieldNames).toBe(false); + expect(normalizeDataGridExtractorOptions({ json: { camelCaseFieldNames: true } }).json.camelCaseFieldNames).toBe(true); + expect(normalizeDataGridExtractorOptions({ json: { camelCaseFieldNames: "true" } }).json.camelCaseFieldNames).toBe(false); + }); + + it("exposes the camel-case field-name switch only with JSON extractor options", () => { + const dialog = readFileSync(new URL("../../../components/grid/DataGridExtractorDialog.vue", import.meta.url), "utf8"); + + expect(dialog).toContain('v-if="isJson"'); + expect(dialog).toContain("draftOptions.json.camelCaseFieldNames"); + expect(dialog).toContain('t("grid.copyExtractorCamelCaseJsonFields")'); + }); + it("rejects overlapping effective row and column separators", () => { const options = normalizeDataGridExtractorOptions(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS); options.dsv.rowSeparator = ","; diff --git a/apps/desktop/src/lib/dataGrid/dataGridCopyExtractor.ts b/apps/desktop/src/lib/dataGrid/dataGridCopyExtractor.ts index 584a56793f..de51950f0b 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridCopyExtractor.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridCopyExtractor.ts @@ -88,7 +88,10 @@ export interface DataGridExtractorOptions { insertMode: DataGridCopyInsertMode; excludePrimaryKeysFromInsert: boolean; }; - json: { pretty: boolean }; + json: { + pretty: boolean; + camelCaseFieldNames: boolean; + }; } export interface DataGridExtractColumn { @@ -145,7 +148,7 @@ export const DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS: DataGridExtractorOptions = { insertMode: "merged", excludePrimaryKeysFromInsert: false, }, - json: { pretty: true }, + json: { pretty: true, camelCaseFieldNames: false }, }; function unicodeCodePointLength(value: string): number { @@ -175,7 +178,10 @@ export function normalizeDataGridExtractorOptions(value: unknown): DataGridExtra insertMode: sql.insertMode === "row-by-row" ? "row-by-row" : "merged", excludePrimaryKeysFromInsert: sql.excludePrimaryKeysFromInsert === true, }, - json: { pretty: json.pretty !== false }, + json: { + pretty: json.pretty !== false, + camelCaseFieldNames: json.camelCaseFieldNames === true, + }, }; } diff --git a/crates/dbx-core/src/data_grid_extractors/contract.rs b/crates/dbx-core/src/data_grid_extractors/contract.rs index 589cde028a..c784a5f9a4 100644 --- a/crates/dbx-core/src/data_grid_extractors/contract.rs +++ b/crates/dbx-core/src/data_grid_extractors/contract.rs @@ -112,11 +112,12 @@ impl Default for DataGridSqlExtractorOptions { #[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 } + Self { pretty: DEFAULT_JSON_PRETTY, camel_case_field_names: false } } } diff --git a/crates/dbx-core/src/data_grid_extractors/json.rs b/crates/dbx-core/src/data_grid_extractors/json.rs index 9596a528fe..4609603172 100644 --- a/crates/dbx-core/src/data_grid_extractors/json.rs +++ b/crates/dbx-core/src/data_grid_extractors/json.rs @@ -10,7 +10,8 @@ pub(super) fn write_json( context: &ExtractContext<'_>, output: &mut dyn Write, ) -> Result, DataGridExtractError> { - let (names, warnings) = unique_json_names(&context.selected_columns); + 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 { @@ -31,7 +32,8 @@ pub(super) fn write_json_lines( context: &ExtractContext<'_>, output: &mut dyn Write, ) -> Result, DataGridExtractError> { - let (names, warnings) = unique_json_names(&context.selected_columns); + 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")?; @@ -92,15 +94,28 @@ impl Serialize for JsonRow<'_, '_> { } } -fn unique_json_names(columns: &[&DataGridExtractColumn]) -> (Vec, Vec) { - let reserved = columns.iter().map(|column| column.display_name.as_str()).collect::>(); +fn unique_json_names( + columns: &[&DataGridExtractColumn], + camel_case_field_names: bool, +) -> (Vec, Vec) { + 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::>(); + let reserved = base_names.iter().map(String::as_str).collect::>(); let mut emitted = std::collections::HashSet::::new(); let mut suffixes = std::collections::HashMap::::new(); let mut duplicate = false; - let names = columns + let names = base_names .iter() - .map(|column| { - let base = column.display_name.clone(); + .map(|base| { + let base = base.clone(); if emitted.insert(base.clone()) { return base; } @@ -125,3 +140,23 @@ fn unique_json_names(columns: &[&DataGridExtractColumn]) -> (Vec, Vec String { + if !name.contains('_') { + return name.to_owned(); + } + + 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 +} diff --git a/crates/dbx-core/src/data_grid_extractors/tests.rs b/crates/dbx-core/src/data_grid_extractors/tests.rs index d06b1c6bef..7c51b2d999 100644 --- a/crates/dbx-core/src/data_grid_extractors/tests.rs +++ b/crates/dbx-core/src/data_grid_extractors/tests.rs @@ -43,6 +43,7 @@ fn partially_deserialized_options_use_the_canonical_defaults() { assert_eq!(options.sql.insert_mode, crate::data_grid_sql::DataGridCopyInsertMode::Merged); assert!(!options.sql.exclude_primary_keys_from_insert); assert!(options.json.pretty); + assert!(!options.json.camel_case_field_names); } #[test] @@ -350,6 +351,56 @@ fn extracts_compact_json_when_pretty_printing_is_disabled() { assert_eq!(result.text, "[{\"id\":1,\"name\":\"Ada\"},{\"id\":2,\"name\":\"Grace, Hopper\"}]"); } +#[test] +fn converts_snake_case_json_field_names_only_when_enabled() { + let mut request = request(DataGridExtractorId::Json); + request.columns = vec![ + column("user_id", 0), + column("ORDER_ITEM_ID", 1), + column("alreadyCamel", 2), + column("_display__name_", 3), + column("naïve_value", 4), + ]; + request.selected_column_indexes = vec![0, 1, 2, 3, 4]; + request.rows = vec![vec![json!(7), json!(9), json!("kept"), json!("Ada"), json!(true)]]; + + let unchanged = extract_data_grid_selection(request.clone()).expect("default JSON extraction"); + assert_eq!( + serde_json::from_str::(&unchanged.text).expect("valid default JSON"), + json!([{"user_id": 7, "ORDER_ITEM_ID": 9, "alreadyCamel": "kept", "_display__name_": "Ada", "naïve_value": true}]) + ); + + request.options.json.camel_case_field_names = true; + let converted = extract_data_grid_selection(request).expect("camel-case JSON extraction"); + assert_eq!( + serde_json::from_str::(&converted.text).expect("valid converted JSON"), + json!([{"userId": 7, "orderItemId": 9, "alreadyCamel": "kept", "displayName": "Ada", "naïveValue": true}]) + ); +} + +#[test] +fn converts_json_lines_names_and_suffixes_post_conversion_collisions() { + let mut request = request(DataGridExtractorId::JsonLines); + request.columns = vec![column("user_id", 0), column("userId", 1), column("___", 2)]; + request.selected_column_indexes = vec![0, 1, 2]; + request.rows = vec![vec![json!(1), json!(2), json!(3)], vec![json!(4), json!(5), json!(6)]]; + request.options.json.camel_case_field_names = true; + + let result = extract_data_grid_selection(request).expect("camel-case JSON Lines extraction"); + let rows = result + .text + .lines() + .map(|line| serde_json::from_str::(line).expect("valid JSON Lines record")) + .collect::>(); + + assert_eq!( + rows, + vec![json!({"userId": 1, "userId_2": 2, "___": 3}), json!({"userId": 4, "userId_2": 5, "___": 6})] + ); + assert_eq!(result.warnings.len(), 1); + assert_eq!(result.warnings[0].code, DataGridExtractWarningCode::DuplicateJsonColumnNames); +} + #[test] fn builds_null_safe_where_clause_predicates() { let mut request = request(DataGridExtractorId::WhereClause); @@ -704,6 +755,7 @@ fn extractor_contract_uses_the_frontend_camel_case_wire_shape() { assert_eq!(value["selectedColumnIndexes"], json!([0, 1])); assert_eq!(value["selectionKind"], "cells"); assert_eq!(value["options"]["dsv"]["includeColumnHeader"], false); + assert_eq!(value["options"]["json"]["camelCaseFieldNames"], false); assert!(value.get("selected_column_indexes").is_none()); let decoded = serde_json::from_value::(value).expect("deserialize extractor request");