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
16 changes: 16 additions & 0 deletions src/mapping/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,22 @@ pub enum Statement {
},
/// `where <condition>` — filter array elements by condition
Where { condition: Expr, span: Span },
/// `sort .field asc, .field2 desc` — sort array elements
Sort { keys: Vec<SortKey>, span: Span },
}

/// A sort direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDirection {
Asc,
Desc,
}

/// A single sort key: path + direction.
#[derive(Debug, Clone, PartialEq)]
pub struct SortKey {
pub path: Path,
pub direction: SortDirection,
}

/// A parsed mapping program: a list of statements.
Expand Down
45 changes: 45 additions & 0 deletions src/mapping/eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ fn eval_statement(stmt: &Statement, value: &Value) -> error::Result<Value> {
Statement::Flatten { path, prefix, .. } => eval_flatten(value, path, prefix.as_deref()),
Statement::Nest { paths, target, .. } => eval_nest(value, paths, target),
Statement::Where { condition, .. } => eval_where(value, condition),
Statement::Sort { keys, .. } => eval_sort(value, keys),
}
}

Expand Down Expand Up @@ -289,6 +290,50 @@ fn eval_where(value: &Value, condition: &Expr) -> error::Result<Value> {
}
}

// ---------------------------------------------------------------------------
// sort
// ---------------------------------------------------------------------------

fn eval_sort(value: &Value, keys: &[SortKey]) -> error::Result<Value> {
match value {
Value::Array(arr) => {
let mut sorted = arr.clone();
sorted.sort_by(|a, b| {
for key in keys {
let val_a = resolve_path(a, &key.path.segments).unwrap_or(Value::Null);
let val_b = resolve_path(b, &key.path.segments).unwrap_or(Value::Null);

// Nulls always sort last regardless of direction
let ordering = match (&val_a, &val_b) {
(Value::Null, Value::Null) => std::cmp::Ordering::Equal,
(Value::Null, _) => std::cmp::Ordering::Greater, // null last
(_, Value::Null) => std::cmp::Ordering::Less, // null last
_ => compare_values(&val_a, &val_b).unwrap_or(std::cmp::Ordering::Equal),
};

let ordering = match key.direction {
SortDirection::Asc => ordering,
SortDirection::Desc => {
// Reverse, but keep nulls last
match (&val_a, &val_b) {
(Value::Null, _) | (_, Value::Null) => ordering,
_ => ordering.reverse(),
}
}
};

if ordering != std::cmp::Ordering::Equal {
return ordering;
}
}
std::cmp::Ordering::Equal
});
Ok(Value::Array(sorted))
}
_ => Ok(value.clone()), // non-array: no-op
}
}

// ---------------------------------------------------------------------------
// Expression evaluation
// ---------------------------------------------------------------------------
Expand Down
38 changes: 38 additions & 0 deletions src/mapping/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ impl Parser {
TokenKind::Flatten => self.parse_flatten(),
TokenKind::Nest => self.parse_nest(),
TokenKind::Where => self.parse_where(),
TokenKind::Sort => self.parse_sort(),
_ => {
let suggestion = suggest_keyword(&token.kind);
let msg = if let Some(s) = suggestion {
Expand Down Expand Up @@ -284,6 +285,43 @@ impl Parser {
})
}

fn parse_sort(&mut self) -> error::Result<Statement> {
let start = self.advance().unwrap(); // consume 'sort'
let mut keys = Vec::new();

// Parse first sort key
let path = self.parse_path()?;
let direction = self.parse_sort_direction();
keys.push(SortKey { path, direction });

// Parse additional sort keys separated by commas
while let Some(TokenKind::Comma) = self.peek_kind() {
self.advance(); // consume comma
let path = self.parse_path()?;
let direction = self.parse_sort_direction();
keys.push(SortKey { path, direction });
}

Ok(Statement::Sort {
keys,
span: start.span,
})
}

fn parse_sort_direction(&mut self) -> SortDirection {
match self.peek_kind() {
Some(TokenKind::Asc) => {
self.advance();
SortDirection::Asc
}
Some(TokenKind::Desc) => {
self.advance();
SortDirection::Desc
}
_ => SortDirection::Asc, // default ascending
}
}

fn parse_path_list(&mut self) -> error::Result<Vec<Path>> {
let mut paths = vec![self.parse_path()?];
while let Some(TokenKind::Comma) = self.peek_kind() {
Expand Down
263 changes: 263 additions & 0 deletions tests/sort_operation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
//! Integration tests for issue #22: sort operation.

use indexmap::IndexMap;
use morph::mapping::{eval, parser};
use morph::value::Value;

fn run(mapping: &str, input: &Value) -> Value {
let program = parser::parse_str(mapping).unwrap();
eval::eval(&program, input).unwrap()
}

fn make_map(pairs: &[(&str, Value)]) -> Value {
let mut m = IndexMap::new();
for (k, v) in pairs {
m.insert((*k).to_string(), v.clone());
}
Value::Map(m)
}

fn get_names(val: &Value) -> Vec<String> {
match val {
Value::Array(arr) => arr
.iter()
.map(|v| match v.get_path(".name") {
Some(Value::String(s)) => s.clone(),
_ => "?".into(),
})
.collect(),
_ => panic!("expected array"),
}
}

fn people_data() -> Value {
Value::Array(vec![
make_map(&[
("name", Value::String("Charlie".into())),
("score", Value::Int(70)),
]),
make_map(&[
("name", Value::String("Alice".into())),
("score", Value::Int(90)),
]),
make_map(&[
("name", Value::String("Bob".into())),
("score", Value::Int(80)),
]),
])
}

// ---------------------------------------------------------------------------
// sort .name asc — alphabetical ascending
// ---------------------------------------------------------------------------

#[test]
fn sort_name_asc() {
let result = run("sort .name asc", &people_data());
assert_eq!(get_names(&result), vec!["Alice", "Bob", "Charlie"]);
}

// ---------------------------------------------------------------------------
// sort .name desc — descending
// ---------------------------------------------------------------------------

#[test]
fn sort_name_desc() {
let result = run("sort .name desc", &people_data());
assert_eq!(get_names(&result), vec!["Charlie", "Bob", "Alice"]);
}

// ---------------------------------------------------------------------------
// sort .score desc, .name asc — multi-key
// ---------------------------------------------------------------------------

#[test]
fn sort_multi_key() {
let data = Value::Array(vec![
make_map(&[
("name", Value::String("Bob".into())),
("score", Value::Int(80)),
]),
make_map(&[
("name", Value::String("Alice".into())),
("score", Value::Int(90)),
]),
make_map(&[
("name", Value::String("Charlie".into())),
("score", Value::Int(80)),
]),
make_map(&[
("name", Value::String("Diana".into())),
("score", Value::Int(90)),
]),
]);

let result = run("sort .score desc, .name asc", &data);
// score desc: 90s first, then 80s. Within same score, name asc.
assert_eq!(get_names(&result), vec!["Alice", "Diana", "Bob", "Charlie"]);
}

// ---------------------------------------------------------------------------
// Sort integers
// ---------------------------------------------------------------------------

#[test]
fn sort_integers() {
// Sort objects by an integer field.
let data = Value::Array(vec![
make_map(&[("v", Value::Int(30))]),
make_map(&[("v", Value::Int(10))]),
make_map(&[("v", Value::Int(20))]),
]);
let result = run("sort .v asc", &data);
match &result {
Value::Array(arr) => {
let vals: Vec<i64> = arr
.iter()
.map(|v| match v.get_path(".v") {
Some(Value::Int(i)) => *i,
_ => panic!("expected int"),
})
.collect();
assert_eq!(vals, vec![10, 20, 30]);
}
_ => panic!("expected array"),
}
}

// ---------------------------------------------------------------------------
// Sort floats
// ---------------------------------------------------------------------------

#[test]
fn sort_floats() {
let data = Value::Array(vec![
make_map(&[("v", Value::Float(2.5))]),
make_map(&[("v", Value::Float(1.1))]),
make_map(&[("v", Value::Float(3.7))]),
]);
let result = run("sort .v asc", &data);
match &result {
Value::Array(arr) => {
let vals: Vec<f64> = arr
.iter()
.map(|v| match v.get_path(".v") {
Some(Value::Float(f)) => *f,
_ => panic!("expected float"),
})
.collect();
assert_eq!(vals, vec![1.1, 2.5, 3.7]);
}
_ => panic!("expected array"),
}
}

// ---------------------------------------------------------------------------
// Sort strings
// ---------------------------------------------------------------------------

#[test]
fn sort_strings() {
let data = Value::Array(vec![
make_map(&[("v", Value::String("banana".into()))]),
make_map(&[("v", Value::String("apple".into()))]),
make_map(&[("v", Value::String("cherry".into()))]),
]);
let result = run("sort .v asc", &data);
match &result {
Value::Array(arr) => {
let vals: Vec<&str> = arr
.iter()
.map(|v| match v.get_path(".v") {
Some(Value::String(s)) => s.as_str(),
_ => panic!("expected string"),
})
.collect();
assert_eq!(vals, vec!["apple", "banana", "cherry"]);
}
_ => panic!("expected array"),
}
}

// ---------------------------------------------------------------------------
// Sort with null values (nulls last)
// ---------------------------------------------------------------------------

#[test]
fn sort_nulls_last_asc() {
let data = Value::Array(vec![
make_map(&[("name", Value::String("Charlie".into()))]),
make_map(&[("name", Value::Null)]),
make_map(&[("name", Value::String("Alice".into()))]),
]);
let result = run("sort .name asc", &data);
assert_eq!(get_names(&result), vec!["Alice", "Charlie", "?"]);
// Verify the null is actually last
match &result {
Value::Array(arr) => {
assert_eq!(arr[2].get_path(".name"), Some(&Value::Null));
}
_ => panic!("expected array"),
}
}

#[test]
fn sort_nulls_last_desc() {
let data = Value::Array(vec![
make_map(&[("name", Value::Null)]),
make_map(&[("name", Value::String("Alice".into()))]),
make_map(&[("name", Value::String("Charlie".into()))]),
]);
let result = run("sort .name desc", &data);
// desc: Charlie, Alice, then null last
match &result {
Value::Array(arr) => {
assert_eq!(
arr[0].get_path(".name"),
Some(&Value::String("Charlie".into()))
);
assert_eq!(
arr[1].get_path(".name"),
Some(&Value::String("Alice".into()))
);
assert_eq!(arr[2].get_path(".name"), Some(&Value::Null));
}
_ => panic!("expected array"),
}
}

// ---------------------------------------------------------------------------
// Sort on non-existent field → stable order (all compare equal)
// ---------------------------------------------------------------------------

#[test]
fn sort_nonexistent_field() {
let data = people_data();
let result = run("sort .nonexistent asc", &data);
// All values are null/missing so order is stable (unchanged)
assert_eq!(get_names(&result), vec!["Charlie", "Alice", "Bob"]);
}

// ---------------------------------------------------------------------------
// Sort on non-array → no-op
// ---------------------------------------------------------------------------

#[test]
fn sort_non_array_noop() {
let data = make_map(&[
("name", Value::String("Alice".into())),
("age", Value::Int(30)),
]);
let result = run("sort .name asc", &data);
assert_eq!(result, data);
}

// ---------------------------------------------------------------------------
// Default direction is ascending
// ---------------------------------------------------------------------------

#[test]
fn sort_default_direction_asc() {
let result = run("sort .name", &people_data());
assert_eq!(get_names(&result), vec!["Alice", "Bob", "Charlie"]);
}