diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 2748839..6db3c52 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -30,13 +30,17 @@ jobs: run: | git fetch origin ${{ github.base_ref }} git checkout origin/${{ github.base_ref }} - cargo bench --bench flatten -- --save-baseline main + cargo bench --bench flatten --color never -- --save-baseline main - name: Benchmark PR branch run: | git checkout ${{ github.event.pull_request.head.sha }} - cargo bench --bench flatten -- --baseline main > bench_output.txt 2>&1 - cat bench_output.txt + cargo bench --bench flatten --color never -- --baseline main > bench_output.txt 2>&1 + + - name: Extract comparison summary + run: | + grep -E "(^flatten_|^\s+(time:|thrpt:|change:)|Performance has)" bench_output.txt > bench_summary.txt || echo "No comparison data" > bench_summary.txt + cat bench_summary.txt - name: Comment PR uses: actions/github-script@v9 @@ -44,7 +48,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const fs = require('fs'); - const output = fs.readFileSync('bench_output.txt', 'utf8'); + const output = fs.readFileSync('bench_summary.txt', 'utf8'); const body = `## Benchmark Results diff --git a/benches/flatten.rs b/benches/flatten.rs index 089116d..fa23a12 100644 --- a/benches/flatten.rs +++ b/benches/flatten.rs @@ -1,6 +1,6 @@ // smooth-json/benches/flatten.rs -use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; -use serde_json::{json, Map, Value}; +use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; +use serde_json::{Map, Value, json}; use smooth_json::Flattener; /// Build a nested object of the given depth and breadth. Example shape: @@ -60,15 +60,19 @@ fn bench_flatten_inputs(c: &mut Criterion) { }); // alt_array_flattening = true - group.bench_with_input(BenchmarkId::new("alt_array_flattening", name), &input, |b, v| { - let fl = Flattener { - alt_array_flattening: true, - ..Default::default() - }; - b.iter(|| { - let _ = fl.flatten(black_box(v)); - }) - }); + group.bench_with_input( + BenchmarkId::new("alt_array_flattening", name), + &input, + |b, v| { + let fl = Flattener { + alt_array_flattening: true, + ..Default::default() + }; + b.iter(|| { + let _ = fl.flatten(black_box(v)); + }) + }, + ); } group.finish(); @@ -94,19 +98,23 @@ fn bench_collision_cases(c: &mut Criterion) { }); // With alt_array_flattening enabled (different aggregation behavior) - group.bench_with_input(BenchmarkId::new("collisions_alt_flatten", n), &input, |b, v| { - let fl = Flattener { - alt_array_flattening: true, - ..Default::default() - }; - b.iter(|| { - let _ = fl.flatten(black_box(v)); - }) - }); + group.bench_with_input( + BenchmarkId::new("collisions_alt_flatten", n), + &input, + |b, v| { + let fl = Flattener { + alt_array_flattening: true, + ..Default::default() + }; + b.iter(|| { + let _ = fl.flatten(black_box(v)); + }) + }, + ); } group.finish(); } criterion_group!(benches, bench_flatten_inputs, bench_collision_cases); -criterion_main!(benches); \ No newline at end of file +criterion_main!(benches); diff --git a/src/lib.rs b/src/lib.rs index becdf67..26949ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,9 +10,9 @@ //! use smooth_json::Flattener; //! ``` -use serde_json::json; use serde_json::Map; use serde_json::Value; +use serde_json::json; /// Flattener is the main driver when flattening JSON /// # Examples @@ -95,7 +95,11 @@ impl<'a> Flattener<'a> { /// /// A formatted key string with the separator inserted between prefix and suffix. fn build_key(&self, prefix: &str, suffix: &str) -> String { - format!("{prefix}{}{suffix}", self.separator) + let mut key = String::with_capacity(prefix.len() + self.separator.len() + suffix.len()); + key.push_str(prefix); + key.push_str(self.separator); + key.push_str(suffix); + key } /// Flattens JSON variants into a JSON object @@ -161,8 +165,18 @@ impl<'a> Flattener<'a> { } fn flatten_array(&self, builder: &mut Map, identifier: &str, obj: &[Value]) { + // Empty arrays should be preserved, instead of being omitted + if obj.is_empty() { + builder.insert(identifier.to_string(), Value::Array(vec![])); + return; + } + + use std::fmt::Write; + let mut index_buf = String::new(); + for (k, v) in obj.iter().enumerate() { - let with_key = self.build_key(identifier, &k.to_string()); + write!(&mut index_buf, "{}", k).unwrap(); + let with_key = self.build_key(identifier, &index_buf); let current_identifier = if self.preserve_arrays { with_key.as_str() } else { @@ -179,6 +193,8 @@ impl<'a> Flattener<'a> { Value::Array(obj_arr) => self.flatten_array(builder, current_identifier, obj_arr), _ => self.flatten_value(builder, current_identifier, v, self.alt_array_flattening), } + + index_buf.clear(); } } diff --git a/tests/integration_test.rs b/tests/integration_test.rs new file mode 100644 index 0000000..6b956d8 --- /dev/null +++ b/tests/integration_test.rs @@ -0,0 +1,165 @@ +use serde_json::json; +use smooth_json::Flattener; + +#[test] +fn basic_user_profile() { + let flattener = Flattener::new(); + + let user_profile = json!({ + "id": 12345, + "username": "johndoe", + "email": "john@example.com", + "profile": { + "first_name": "John", + "last_name": "Doe", + "age": 30 + }, + "preferences": { + "theme": "dark", + "notifications": { + "email": true, + "push": false + } + } + }); + + let flattened = flattener.flatten(&user_profile); + + assert_eq!(flattened["id"], 12345); + assert_eq!(flattened["username"], "johndoe"); + assert_eq!(flattened["email"], "john@example.com"); + assert_eq!(flattened["profile.first_name"], "John"); + assert_eq!(flattened["profile.last_name"], "Doe"); + assert_eq!(flattened["profile.age"], 30); + assert_eq!(flattened["preferences.theme"], "dark"); + assert_eq!(flattened["preferences.notifications.email"], true); + assert_eq!(flattened["preferences.notifications.push"], false); +} + +#[test] +fn github_api_issue_response() { + let flattener = Flattener::new(); + + let issue = json!({ + "id": 1, + "number": 42, + "title": "Found a bug", + "state": "open", + "user": { + "login": "octocat", + "id": 1, + "avatar_url": "https://github.com/images/error/octocat_happy.gif" + }, + "labels": [ + {"id": 1, "name": "bug", "color": "d73a4a"}, + {"id": 2, "name": "enhancement", "color": "a2eeef"} + ], + "assignees": [] + }); + + let flattened = flattener.flatten(&issue); + + assert_eq!(flattened["id"], 1); + assert_eq!(flattened["number"], 42); + assert_eq!(flattened["title"], "Found a bug"); + assert_eq!(flattened["state"], "open"); + assert_eq!(flattened["user.login"], "octocat"); + assert_eq!(flattened["user.id"], 1); + assert_eq!( + flattened["user.avatar_url"], + "https://github.com/images/error/octocat_happy.gif" + ); + + // Labels should be flattened into arrays + assert!(flattened["labels.id"].is_array()); + assert_eq!(flattened["labels.id"][0], 1); + assert_eq!(flattened["labels.id"][1], 2); + assert_eq!(flattened["labels.name"][0], "bug"); + assert_eq!(flattened["labels.name"][1], "enhancement"); + + // Empty arrays should be preserved when flattening + assert!(flattened["assignees"].is_array()); + assert_eq!(flattened["assignees"].as_array().unwrap().len(), 0); +} + +#[test] +fn log_entry_with_metadata() { + let flattener = Flattener::new(); + + let log_entry = json!({ + "timestamp": "2024-01-15T10:30:00Z", + "level": "ERROR", + "message": "Database connection failed", + "context": { + "service": "api-gateway", + "environment": "production", + "region": "us-east-1", + "error": { + "type": "ConnectionError", + "code": 500, + "details": "Connection timeout after 30s" + } + }, + "tags": ["database", "critical"] + }); + + let flattened = flattener.flatten(&log_entry); + + assert_eq!(flattened["timestamp"], "2024-01-15T10:30:00Z"); + assert_eq!(flattened["level"], "ERROR"); + assert_eq!(flattened["message"], "Database connection failed"); + assert_eq!(flattened["context.service"], "api-gateway"); + assert_eq!(flattened["context.environment"], "production"); + assert_eq!(flattened["context.region"], "us-east-1"); + assert_eq!(flattened["context.error.type"], "ConnectionError"); + assert_eq!(flattened["context.error.code"], 500); + assert_eq!( + flattened["context.error.details"], + "Connection timeout after 30s" + ); + assert_eq!(flattened["tags"][0], "database"); + assert_eq!(flattened["tags"][1], "critical"); +} + +#[test] +fn custom_separator_integration() { + let flattener = Flattener { + separator: "_", + ..Default::default() + }; + + let data = json!({ + "user": { + "profile": { + "name": "Alice" + } + } + }); + + let flattened = flattener.flatten(&data); + + assert_eq!(flattened["user_profile_name"], "Alice"); + assert!(flattened.get("user.profile.name").is_none()); +} + +#[test] +fn preserve_arrays_integration() { + let flattener = Flattener { + preserve_arrays: true, + ..Default::default() + }; + + let data = json!({ + "items": [ + {"id": 1, "name": "First"}, + {"id": 2, "name": "Second"} + ] + }); + + let flattened = flattener.flatten(&data); + + assert_eq!(flattened["items.0.id"], 1); + assert_eq!(flattened["items.0.name"], "First"); + assert_eq!(flattened["items.1.id"], 2); + assert_eq!(flattened["items.1.name"], "Second"); +}