Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,25 @@ 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
with:
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

Expand Down
50 changes: 29 additions & 21 deletions benches/flatten.rs
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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();
Expand All @@ -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);
criterion_main!(benches);
22 changes: 19 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -161,8 +165,18 @@ impl<'a> Flattener<'a> {
}

fn flatten_array(&self, builder: &mut Map<String, Value>, 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 {
Expand All @@ -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();
}
}

Expand Down
165 changes: 165 additions & 0 deletions tests/integration_test.rs
Original file line number Diff line number Diff line change
@@ -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");
}