Skip to content
Open
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
1 change: 1 addition & 0 deletions cli-engine/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2707,6 +2707,7 @@ fn apply_global_flags(middleware: &mut Middleware, flags: &GlobalFlags, timeout:
middleware.verbose = flags.verbose.clone();
middleware.dry_run = flags.dry_run;
middleware.fields = flags.fields.clone();
middleware.fields_explicit = flags.fields_explicit;
middleware.filter = flags.filter.clone();
middleware.expr = flags.expr.clone();
middleware.reason = flags.reason.clone();
Expand Down
9 changes: 9 additions & 0 deletions cli-engine/src/flags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ pub struct GlobalFlags {
pub dry_run: bool,
/// Field projection.
pub fields: String,
/// Whether `fields` came from an explicit `--fields` flag on the command
/// line, rather than clap filling in a command's `default_fields` (a
/// command with `default_fields` set registers it as that flag's native
/// default, so `fields` is non-empty even when the user never typed
/// `--fields` — this is the only reliable way to tell the two apart).
pub fields_explicit: bool,
/// JMESPath per-item filter.
pub filter: String,
/// JMESPath whole-result expression.
Expand All @@ -92,6 +98,7 @@ impl Default for GlobalFlags {
verbose: String::new(),
dry_run: false,
fields: String::new(),
fields_explicit: false,
filter: String::new(),
expr: String::new(),
schema: false,
Expand Down Expand Up @@ -561,6 +568,8 @@ pub fn global_flags_from_matches(
.get_one::<String>("fields")
.cloned()
.unwrap_or_default(),
fields_explicit: matches.value_source("fields")
== Some(clap::parser::ValueSource::CommandLine),
filter: matches
.get_one::<String>("filter")
.cloned()
Expand Down
72 changes: 61 additions & 11 deletions cli-engine/src/middleware.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::{
collections::BTreeMap,
collections::{BTreeMap, BTreeSet},
future::Future,
sync::Arc,
time::{Duration, Instant},
Expand All @@ -17,6 +17,7 @@ use crate::{
output::{
Envelope, HumanViewRegistry, NextAction, OutputFormat, PipelineOpts, apply_pipeline,
build_error_envelope, is_valid_output_format, render_human_with_registry_selected,
unknown_fields_message,
},
};

Expand Down Expand Up @@ -489,6 +490,10 @@ pub struct Middleware {
pub dry_run: bool,
/// User field projection.
pub fields: String,
/// Whether `fields` came from an explicit `--fields` flag rather than a
/// command's `default_fields` fallback. See
/// [`GlobalFlags::fields_explicit`](crate::GlobalFlags::fields_explicit).
pub fields_explicit: bool,
/// JMESPath per-item list predicate.
pub filter: String,
/// JMESPath whole-result expression.
Expand Down Expand Up @@ -1044,19 +1049,63 @@ impl Middleware {
}
}
let output_format = self.output_format.parse::<OutputFormat>()?;
// The effective field selection: an explicit `--fields` wins, otherwise
// the command's `default_fields` is the default. The same selection is
// applied two ways. With a registered human view, it narrows which of the
// view's columns show, so the view reads the full payload — the data is
// not projected, which would otherwise blank out the kept columns.
// Everywhere else (JSON/TOON, or generic human output) it projects the
// output data. Empty / `all` / `*` keeps everything.
let effective_fields = if self.fields.is_empty() {
default_fields
} else {
// The effective field selection: an explicit `--fields` wins —
// including an explicit empty string, which keeps everything, same
// as `all`/`*` — otherwise the command's `default_fields` is the
// default. Gated on `fields_explicit` rather than
// `self.fields.is_empty()`: once a command has `default_fields` set,
// clap fills `self.fields` with that same non-empty string whether
// or not the user typed `--fields`, so emptiness can't tell "user
// explicitly cleared it" apart from "user never touched it" — only
// `value_source` (what `fields_explicit` is built from) can. The
// same selection is applied two ways: with a registered human view,
// it narrows which of the view's columns show, so the view reads
// the full payload — the data is not projected, which would
// otherwise blank out the kept columns. Everywhere else (JSON/TOON,
// or generic human output) it projects the output data.
let effective_fields = if self.fields_explicit {
self.fields.as_str()
} else {
default_fields
};
let human_view = output_format == OutputFormat::Human && self.human_views.has_view(view_id);
// `apply_pipeline` never sees `effective_fields` for a registered
// view (`projection_fields` below is forced to `""` so the view reads
// the full payload), and the view's own column narrowing
// (`select_columns` in `human.rs`) silently skips a name with no
// matching column — the same "typo produces an empty/partial table
// instead of an error" gap `apply_pipeline`'s field validation
// closes elsewhere. So an explicit `--fields` (never a
// `default_fields` fallback — same reasoning as
// `PipelineOpts::fields_are_default`) is checked against the view's
// column catalog here instead.
if human_view
&& self.fields_explicit
&& let Some(columns) = self.human_views.columns(view_id)
{
let fields = effective_fields.trim();
if !fields.is_empty() && fields != "all" && fields != "*" {
let known: BTreeSet<String> =
columns.iter().map(|column| column.field.clone()).collect();
let unknown: BTreeSet<&str> = fields
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty() && !known.contains(*part))
.collect();
if !unknown.is_empty() {
let unknown: Vec<&str> = unknown.into_iter().collect();
let err = CliCoreError::message(unknown_fields_message(&unknown, &known));
return self.render_error(
&err,
&self.app_id,
start,
user_args,
effective_args,
identity,
);
}
}
}
let projection_fields = if human_view { "" } else { effective_fields };
if let Some(data) = &mut envelope.data {
let pagination = apply_pipeline(
Expand All @@ -1067,6 +1116,7 @@ impl Middleware {
offset: self.offset,
expr: self.expr.clone(),
fields: projection_fields.to_owned(),
fields_are_default: !self.fields_explicit,
},
Comment thread
jpage-godaddy marked this conversation as resolved.
)?;
if let Some(pagination) = pagination {
Expand Down
20 changes: 17 additions & 3 deletions cli-engine/src/output/fields.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ pub struct FieldTree {
children: BTreeMap<String, Option<Box<FieldTree>>>,
}

impl FieldTree {
/// Iterates the top-level field names requested at this level of the tree.
pub(crate) fn top_level_names(&self) -> impl Iterator<Item = &str> {
self.children.keys().map(String::as_str)
}
}

/// Parses comma-separated field paths.
#[must_use]
pub fn parse_fields(fields: &str) -> FieldTree {
Expand All @@ -29,7 +36,14 @@ pub fn filter_fields(data: &Value, fields: &str) -> Value {
if fields.is_empty() || fields == "all" || fields == "*" {
return data.clone();
}
let allowed = parse_fields(fields);
project_fields(data, &parse_fields(fields))
}

/// Applies an already-parsed field-selection tree, so a caller that also
/// needs the tree (e.g. to validate requested names) can parse the
/// `--fields` string once and reuse it here instead of paying for
/// [`parse_fields`] a second time.
pub(crate) fn project_fields(data: &Value, allowed: &FieldTree) -> Value {
match data {
Value::Array(items) => {
if items
Expand All @@ -42,13 +56,13 @@ pub fn filter_fields(data: &Value, fields: &str) -> Value {
items
.iter()
.map(|item| match item {
Value::Object(map) => Value::Object(filter_map(map, &allowed)),
Value::Object(map) => Value::Object(filter_map(map, allowed)),
other => other.clone(),
})
.collect(),
)
}
Value::Object(map) => Value::Object(filter_map(map, &allowed)),
Value::Object(map) => Value::Object(filter_map(map, allowed)),
other => other.clone(),
}
}
Expand Down
2 changes: 2 additions & 0 deletions cli-engine/src/output/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub use envelope::{
Envelope, ErrorEnvelope, Metadata, NextAction, NextActionParam, PaginationMeta,
build_detailed_error_envelope, build_error_envelope,
};
pub(crate) use fields::project_fields;
pub use fields::{FieldTree, filter_fields, parse_fields};
pub(crate) use human::terminal_width;
pub use human::{
Expand All @@ -32,6 +33,7 @@ pub use human::{
render_human_with_registry_selected, render_human_with_view,
};
pub use json::render_json;
pub(crate) use pipeline::unknown_fields_message;
pub use pipeline::{PipelineOpts, apply_pipeline};
pub use renderer::{
OutputFormat, RendererFactory, is_valid_output_format, render, render_data, render_data_format,
Expand Down
Loading