feat(copy): add camel-case JSON field names

Closes #4784
This commit is contained in:
t8y2 committed 2026-08-14 23:07:21 +00:00
1 parent a0de029a23
commit dc2d3ea1dc
14 files changed
+136 -12

No files matched your search

@@ -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 }
}
}
@@ -10,7 +10,8 @@ pub(super) fn write_json(
context: &ExtractContext<'_>,
output: &mut dyn Write,
) -> Result<Vec<DataGridExtractWarning>, 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<Vec<DataGridExtractWarning>, 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<String>, Vec<DataGridExtractWarning>) {
let reserved = columns.iter().map(|column| column.display_name.as_str()).collect::<std::collections::HashSet<_>>();
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 = 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<String>, Vec<Da
};
(names, warnings)
}
fn lower_camel_json_name(name: &str) -> 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
}
@@ -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::<Value>(&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::<Value>(&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::<Value>(line).expect("valid JSON Lines record"))
.collect::<Vec<_>>();
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::<DataGridExtractRequest>(value).expect("deserialize extractor request");