Skip to main content

nu_command/filters/
group_by.rs

1use indexmap::IndexMap;
2use nu_engine::{ClosureEval, command_prelude::*};
3use nu_protocol::{
4    FromValue, ast::PathMember, engine::Closure, shell_error::generic::GenericError,
5};
6
7#[derive(Clone)]
8pub struct GroupBy;
9
10impl Command for GroupBy {
11    fn name(&self) -> &str {
12        "group-by"
13    }
14
15    fn signature(&self) -> Signature {
16        Signature::build("group-by")
17            .input_output_types(vec![(Type::List(Box::new(Type::Any)), Type::Any)])
18            .switch(
19                "to-table",
20                "Return a table with \"groups\" and \"items\" columns.",
21                None,
22            )
23            .switch(
24                "prune",
25                "Remove a column after grouping, if applicable.",
26                None,
27            )
28            .rest(
29                "grouper",
30                SyntaxShape::OneOf(vec![
31                    SyntaxShape::CellPath,
32                    SyntaxShape::Closure(None),
33                    SyntaxShape::Closure(Some(vec![SyntaxShape::Any])),
34                ]),
35                "The path to the column to group on.",
36            )
37            .category(Category::Filters)
38    }
39
40    fn description(&self) -> &str {
41        "Splits a list or table into groups, and returns a record containing those groups."
42    }
43
44    fn extra_description(&self) -> &str {
45        r#"the group-by command makes some assumptions:
46    - if the input data is not a string, the grouper will convert the key to string but the values will remain in their original format. e.g. with bools, "true" and true would be in the same group (see example).
47    - datetime is formatted based on your configuration setting. use `format date` to change the format.
48    - filesize is formatted based on your configuration setting. use `format filesize` to change the format.
49    - some nushell values are not supported, such as closures.
50    - null group keys are never mapped to the empty string. The default record output omits null groups (records cannot use null as a key); use --to-table to include them as null values. Optional cell paths (e.g. `foo?`) still ignore rows where access yields null."#
51    }
52
53    fn run(
54        &self,
55        engine_state: &EngineState,
56        stack: &mut Stack,
57        call: &Call,
58        input: PipelineData,
59    ) -> Result<PipelineData, ShellError> {
60        group_by(engine_state, stack, call, input)
61    }
62
63    fn examples(&self) -> Vec<Example<'_>> {
64        vec![
65            Example {
66                description: "Group items by the \"type\" column's values.",
67                example: "ls | group-by type",
68                result: None,
69            },
70            Example {
71                description: "Group items by the \"foo\" column's values, ignoring records without a \"foo\" column.",
72                example: "open cool.json | group-by foo?",
73                result: None,
74            },
75            Example {
76                description: "Group using a block which is evaluated against each input value.",
77                example: "[foo.txt bar.csv baz.txt] | group-by { path parse | get extension }",
78                result: Some(Value::test_record(record! {
79                    "txt" => Value::test_list(vec![
80                        Value::test_string("foo.txt"),
81                        Value::test_string("baz.txt"),
82                    ]),
83                    "csv" => Value::test_list(vec![Value::test_string("bar.csv")]),
84                })),
85            },
86            Example {
87                description: "You can also group by raw values by leaving out the argument.",
88                example: "['1' '3' '1' '3' '2' '1' '1'] | group-by",
89                result: Some(Value::test_record(record! {
90                    "1" => Value::test_list(vec![
91                        Value::test_string("1"),
92                        Value::test_string("1"),
93                        Value::test_string("1"),
94                        Value::test_string("1"),
95                    ]),
96                    "3" => Value::test_list(vec![
97                        Value::test_string("3"),
98                        Value::test_string("3"),
99                    ]),
100                    "2" => Value::test_list(vec![Value::test_string("2")]),
101                })),
102            },
103            Example {
104                description: "You can also output a table instead of a record.",
105                example: "['1' '3' '1' '3' '2' '1' '1'] | group-by --to-table",
106                result: Some(Value::test_list(vec![
107                    Value::test_record(record! {
108                        "group" => Value::test_string("1"),
109                        "items" => Value::test_list(vec![
110                            Value::test_string("1"),
111                            Value::test_string("1"),
112                            Value::test_string("1"),
113                            Value::test_string("1"),
114                        ]),
115                    }),
116                    Value::test_record(record! {
117                        "group" => Value::test_string("3"),
118                        "items" => Value::test_list(vec![
119                            Value::test_string("3"),
120                            Value::test_string("3"),
121                        ]),
122                    }),
123                    Value::test_record(record! {
124                        "group" => Value::test_string("2"),
125                        "items" => Value::test_list(vec![Value::test_string("2")]),
126                    }),
127                ])),
128            },
129            Example {
130                description: "Group bools, whether they are strings or actual bools.",
131                example: r#"[true "true" false "false"] | group-by"#,
132                result: Some(Value::test_record(record! {
133                    "true" => Value::test_list(vec![
134                        Value::test_bool(true),
135                        Value::test_string("true"),
136                    ]),
137                    "false" => Value::test_list(vec![
138                        Value::test_bool(false),
139                        Value::test_string("false"),
140                    ]),
141                })),
142            },
143            Example {
144                description: "Group items by multiple columns' values.",
145                example: r#"[
146        [name, lang, year];
147        [andres, rb, "2019"],
148        [jt, rs, "2019"],
149        [storm, rs, "2021"]
150    ]
151    | group-by lang year"#,
152                result: Some(Value::test_record(record! {
153                    "rb" => Value::test_record(record! {
154                        "2019" => Value::test_list(
155                            vec![Value::test_record(record! {
156                                    "name" => Value::test_string("andres"),
157                                    "lang" => Value::test_string("rb"),
158                                    "year" => Value::test_string("2019"),
159                            })],
160                        ),
161                    }),
162                    "rs" => Value::test_record(record! {
163                            "2019" => Value::test_list(
164                                vec![Value::test_record(record! {
165                                        "name" => Value::test_string("jt"),
166                                        "lang" => Value::test_string("rs"),
167                                        "year" => Value::test_string("2019"),
168                                })],
169                            ),
170                            "2021" => Value::test_list(
171                                vec![Value::test_record(record! {
172                                        "name" => Value::test_string("storm"),
173                                        "lang" => Value::test_string("rs"),
174                                        "year" => Value::test_string("2021"),
175                                })],
176                            ),
177                    }),
178                })),
179            },
180            Example {
181                description: "Group items by multiple columns' values.",
182                example: r#"[
183        [name, lang, year];
184        [andres, rb, "2019"],
185        [jt, rs, "2019"],
186        [storm, rs, "2021"]
187    ]
188    | group-by lang year --to-table"#,
189                result: Some(Value::test_list(vec![
190                    Value::test_record(record! {
191                        "lang" => Value::test_string("rb"),
192                        "year" => Value::test_string("2019"),
193                        "items" => Value::test_list(vec![
194                            Value::test_record(record! {
195                                "name" => Value::test_string("andres"),
196                                "lang" => Value::test_string("rb"),
197                                "year" => Value::test_string("2019"),
198                            })
199                        ]),
200                    }),
201                    Value::test_record(record! {
202                        "lang" => Value::test_string("rs"),
203                        "year" => Value::test_string("2019"),
204                        "items" => Value::test_list(vec![
205                            Value::test_record(record! {
206                                "name" => Value::test_string("jt"),
207                                "lang" => Value::test_string("rs"),
208                                "year" => Value::test_string("2019"),
209                            })
210                        ]),
211                    }),
212                    Value::test_record(record! {
213                        "lang" => Value::test_string("rs"),
214                        "year" => Value::test_string("2021"),
215                        "items" => Value::test_list(vec![
216                            Value::test_record(record! {
217                                "name" => Value::test_string("storm"),
218                                "lang" => Value::test_string("rs"),
219                                "year" => Value::test_string("2021"),
220                            })
221                        ]),
222                    }),
223                ])),
224            },
225            Example {
226                description: "Group items by column and delete the original.",
227                example: r#"[
228        [name, lang, year];
229        [andres, rb, "2019"],
230        [jt, rs, "2019"],
231        [storm, rs, "2021"]
232    ]
233    | group-by lang --prune"#,
234                #[cfg(test)] // Cannot test this example, it requires the nu-cmd-extra crate.
235                result: None,
236                #[cfg(not(test))]
237                result: Some(Value::test_record(record! {
238                        "rb" => Value::test_list(vec![Value::test_record(record! {
239                                        "name" => Value::test_string("andres"),
240                                        "year" => Value::test_string("2019"),
241                                })],
242                            ),
243                        "rs" => Value::test_list(
244                                    vec![
245                                    Value::test_record(record! {
246                                            "name" => Value::test_string("jt"),
247                                            "year" => Value::test_string("2019"),
248                                    }),
249                                    Value::test_record(record! {
250                                            "name" => Value::test_string("storm"),
251                                            "year" => Value::test_string("2021"),
252                                    })
253                            ]),
254                })),
255            },
256        ]
257    }
258}
259
260pub fn group_by(
261    engine_state: &EngineState,
262    stack: &mut Stack,
263    call: &Call,
264    input: PipelineData,
265) -> Result<PipelineData, ShellError> {
266    let head = call.head;
267    let groupers: Vec<Spanned<Grouper>> = call.rest(engine_state, stack, 0)?;
268    let to_table = call.has_flag(engine_state, stack, "to-table")?;
269    let prune = call.has_flag(engine_state, stack, "prune")?;
270    let config = &stack.get_config(engine_state);
271
272    let values: Vec<Value> = input.into_iter().collect();
273    if values.is_empty() {
274        let val = if to_table {
275            Value::list(Vec::new(), head)
276        } else {
277            Value::record(Record::new(), head)
278        };
279        return Ok(val.into_pipeline_data());
280    }
281
282    let grouped = match &groupers[..] {
283        [first, rest @ ..] => {
284            let mut grouped =
285                Grouped::new(first.as_ref(), prune, values, config, engine_state, stack)?;
286            for grouper in rest {
287                grouped.subgroup(grouper.as_ref(), prune, config, engine_state, stack)?;
288            }
289            grouped
290        }
291        [] => Grouped::empty(values, config),
292    };
293
294    let value = if to_table {
295        let column_names = groupers_to_column_names(&groupers)?;
296        grouped.into_table(&column_names, head)
297    } else {
298        grouped.into_record(head)
299    };
300
301    Ok(value.into_pipeline_data())
302}
303
304fn groupers_to_column_names(groupers: &[Spanned<Grouper>]) -> Result<Vec<String>, ShellError> {
305    if groupers.is_empty() {
306        return Ok(vec!["group".into(), "items".into()]);
307    }
308
309    let mut closure_idx: usize = 0;
310    let grouper_names = groupers.iter().map(|grouper| {
311        grouper.as_ref().map(|item| match item {
312            Grouper::CellPath { val } => val.to_column_name(),
313            Grouper::Closure { .. } => {
314                closure_idx += 1;
315                format!("closure_{}", closure_idx - 1)
316            }
317        })
318    });
319
320    let mut name_set: Vec<Spanned<String>> = Vec::with_capacity(grouper_names.len());
321
322    for name in grouper_names {
323        if name.item == "items" {
324            return Err(ShellError::Generic(
325                GenericError::new(
326                    "grouper arguments can't be named `items`",
327                    "here",
328                    name.span,
329                )
330                .with_help("instead of a cell-path, try using a closure: { get items }"),
331            ));
332        }
333
334        if let Some(conflicting_name) = name_set
335            .iter()
336            .find(|elem| elem.as_ref().item == name.item.as_str())
337        {
338            return Err(ShellError::Generic(
339                GenericError::new(
340                    "grouper arguments result in colliding column names",
341                    "duplicate column names",
342                    conflicting_name.span.append(name.span),
343                )
344                .with_help("instead of a cell-path, try using a closure or renaming columns")
345                .with_inner([ShellError::ColumnDefinedTwice {
346                    col_name: conflicting_name.item.clone(),
347                    first_use: conflicting_name.span,
348                    second_use: name.span,
349                }]),
350            ));
351        }
352
353        name_set.push(name);
354    }
355
356    let column_names: Vec<String> = name_set
357        .into_iter()
358        .map(|elem| elem.item)
359        .chain(["items".into()])
360        .collect();
361    Ok(column_names)
362}
363
364/// Internal group key. `Nothing` is distinct from the empty string so null and `""`
365/// do not collapse. Record output omits `Nothing` keys; `--to-table` emits them as null.
366#[derive(Debug, Clone, PartialEq, Eq, Hash)]
367enum GroupKey {
368    Nothing,
369    String(String),
370}
371
372impl GroupKey {
373    fn from_value(value: &Value, config: &nu_protocol::Config) -> Self {
374        if value.is_nothing() {
375            Self::Nothing
376        } else {
377            Self::String(value.to_expanded_string(", ", config))
378        }
379    }
380
381    fn into_value(self, span: Span) -> Value {
382        match self {
383            Self::Nothing => Value::nothing(span),
384            Self::String(s) => Value::string(s, span),
385        }
386    }
387}
388
389fn path_has_optional_member(column_name: &CellPath) -> bool {
390    column_name.members.iter().any(|member| match member {
391        PathMember::String { optional, .. } => *optional,
392        PathMember::Int { optional, .. } => *optional,
393    })
394}
395
396fn group_cell_path(
397    column_name: &CellPath,
398    prune: bool,
399    values: Vec<Value>,
400    config: &nu_protocol::Config,
401) -> Result<IndexMap<GroupKey, Vec<Value>>, ShellError> {
402    let mut groups = IndexMap::<_, Vec<_>>::new();
403    let optional_path = path_has_optional_member(column_name);
404
405    for mut value in values.into_iter() {
406        let key_val = value.follow_cell_path(&column_name.members)?;
407
408        // Optional cell paths (`col?`) drop rows when access yields nothing (missing
409        // column or explicit null). Required paths keep null as a distinct group key.
410        if key_val.is_nothing() && optional_path {
411            continue;
412        }
413
414        let key = GroupKey::from_value(key_val.as_ref(), config);
415
416        if prune {
417            // it's okay if this fails since pruning is best-effort
418            let _ = value.remove_data_at_cell_path(&column_name.members);
419
420            // also try pruning parent, if it has now become empty
421            let parent = column_name.members.split_last().map(|(_, head)| head);
422
423            if let Some(parent) = parent
424                && let Ok(parent_value) = value.follow_cell_path(parent)
425                && parent_value.is_empty()
426            {
427                let _ = value.remove_data_at_cell_path(parent);
428            }
429        }
430
431        groups.entry(key).or_default().push(value);
432    }
433
434    Ok(groups)
435}
436
437fn group_closure(
438    values: Vec<Value>,
439    span: Span,
440    closure: Closure,
441    engine_state: &EngineState,
442    stack: &mut Stack,
443) -> Result<IndexMap<GroupKey, Vec<Value>>, ShellError> {
444    let mut groups = IndexMap::<_, Vec<_>>::new();
445    let mut closure = ClosureEval::new(engine_state, stack, closure);
446    let config = &stack.get_config(engine_state);
447
448    for value in values {
449        let key_val = closure.run_with_value(value.clone())?.into_value(span)?;
450        let key = GroupKey::from_value(&key_val, config);
451
452        groups.entry(key).or_default().push(value);
453    }
454
455    Ok(groups)
456}
457
458enum Grouper {
459    CellPath { val: CellPath },
460    Closure { val: Box<Closure> },
461}
462
463impl FromValue for Grouper {
464    fn from_value(v: Value) -> Result<Self, ShellError> {
465        match v {
466            Value::CellPath { val, .. } => Ok(Grouper::CellPath { val }),
467            Value::Closure { val, .. } => Ok(Grouper::Closure { val }),
468            _ => Err(ShellError::TypeMismatch {
469                err_message: "unsupported grouper type".to_string(),
470                span: v.span(),
471            }),
472        }
473    }
474}
475
476struct Grouped {
477    groups: Tree,
478}
479
480enum Tree {
481    Leaf(IndexMap<GroupKey, Vec<Value>>),
482    Branch(IndexMap<GroupKey, Grouped>),
483}
484
485impl Grouped {
486    fn empty(values: Vec<Value>, config: &nu_protocol::Config) -> Self {
487        let mut groups = IndexMap::<_, Vec<_>>::new();
488
489        for value in values.into_iter() {
490            let key = GroupKey::from_value(&value, config);
491            groups.entry(key).or_default().push(value);
492        }
493
494        Self {
495            groups: Tree::Leaf(groups),
496        }
497    }
498
499    fn new(
500        grouper: Spanned<&Grouper>,
501        prune: bool,
502        values: Vec<Value>,
503        config: &nu_protocol::Config,
504        engine_state: &EngineState,
505        stack: &mut Stack,
506    ) -> Result<Self, ShellError> {
507        let groups = match grouper.item {
508            Grouper::CellPath { val } => group_cell_path(val, prune, values, config)?,
509            Grouper::Closure { val } => group_closure(
510                values,
511                grouper.span,
512                Closure::clone(val),
513                engine_state,
514                stack,
515            )?,
516        };
517        Ok(Self {
518            groups: Tree::Leaf(groups),
519        })
520    }
521
522    fn subgroup(
523        &mut self,
524        grouper: Spanned<&Grouper>,
525        prune: bool,
526        config: &nu_protocol::Config,
527        engine_state: &EngineState,
528        stack: &mut Stack,
529    ) -> Result<(), ShellError> {
530        let groups = match &mut self.groups {
531            Tree::Leaf(groups) => std::mem::take(groups)
532                .into_iter()
533                .map(|(key, values)| -> Result<_, ShellError> {
534                    let leaf = Self::new(grouper, prune, values, config, engine_state, stack)?;
535                    Ok((key, leaf))
536                })
537                .collect::<Result<IndexMap<_, _>, ShellError>>()?,
538            Tree::Branch(nested_groups) => {
539                let mut nested_groups = std::mem::take(nested_groups);
540                for v in nested_groups.values_mut() {
541                    v.subgroup(grouper, prune, config, engine_state, stack)?;
542                }
543                nested_groups
544            }
545        };
546        self.groups = Tree::Branch(groups);
547        Ok(())
548    }
549
550    fn into_table(self, column_names: &[String], head: Span) -> Value {
551        self._into_table(head)
552            .into_iter()
553            .map(|row| {
554                row.into_iter()
555                    .rev()
556                    .zip(column_names)
557                    .map(|(val, key)| (key.clone(), val))
558                    .collect::<Record>()
559                    .into_value(head)
560            })
561            .collect::<Vec<_>>()
562            .into_value(head)
563    }
564
565    fn _into_table(self, head: Span) -> Vec<Vec<Value>> {
566        match self.groups {
567            Tree::Leaf(leaf) => leaf
568                .into_iter()
569                .map(|(group, values)| vec![values.into_value(head), group.into_value(head)])
570                .collect::<Vec<Vec<Value>>>(),
571            Tree::Branch(branch) => branch
572                .into_iter()
573                .flat_map(|(group, items)| {
574                    let group_val = group.into_value(head);
575                    let mut inner = items._into_table(head);
576                    for row in &mut inner {
577                        row.push(group_val.clone());
578                    }
579                    inner
580                })
581                .collect(),
582        }
583    }
584
585    fn into_record(self, head: Span) -> Value {
586        match self.groups {
587            Tree::Leaf(leaf) => Value::record(
588                leaf.into_iter()
589                    // Records cannot use null as a key; omit null groups rather than
590                    // mapping them to the empty string (which collides with "").
591                    .filter_map(|(k, v)| match k {
592                        GroupKey::String(key) => Some((key, v.into_value(head))),
593                        GroupKey::Nothing => None,
594                    })
595                    .collect(),
596                head,
597            ),
598            Tree::Branch(branch) => {
599                let values = branch
600                    .into_iter()
601                    .filter_map(|(k, v)| match k {
602                        GroupKey::String(key) => Some((key, v.into_record(head))),
603                        GroupKey::Nothing => None,
604                    })
605                    .collect();
606                Value::record(values, head)
607            }
608        }
609    }
610}
611
612#[cfg(test)]
613mod test {
614    use super::*;
615
616    #[test]
617    fn test_examples() -> nu_test_support::Result {
618        nu_test_support::test().examples(GroupBy)
619    }
620}