Skip to content

Commit 10a0182

Browse files
authored
Add validated pipeline projection aliases (#72)
1 parent ad237bc commit 10a0182

13 files changed

Lines changed: 517 additions & 32 deletions

File tree

crates/hubuum-filter/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ The current native stages provide:
6565
- broad, value-only, key-only, field, reject, and truthiness filtering;
6666
- typed boolean predicates with explicit casts, null/missing tests, fanout,
6767
quoted JSON literals, and `NOT`/`AND`/`OR` composition;
68-
- projection and selector-based value extraction;
68+
- projection with validated output aliases and selector-based value extraction;
6969
- stable multi-key sorting with strict scalar, date, version, natural, and
7070
IPv4/IPv6 casts, fanout reduction, and null order;
7171
- head, tail, and count;

crates/hubuum-filter/src/model.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,20 +229,34 @@ impl PipeStage {
229229
#[derive(Debug, Clone, PartialEq, Eq)]
230230
pub struct ProjectTerm {
231231
selector: Selector,
232+
alias: Option<OutputName>,
232233
drop: bool,
233234
}
234235

235236
impl ProjectTerm {
236237
pub fn keep(selector: impl Into<String>) -> Result<Self, PipelineError> {
237238
Ok(Self {
238239
selector: Selector::new(selector)?,
240+
alias: None,
241+
drop: false,
242+
})
243+
}
244+
245+
pub fn aliased(
246+
selector: impl Into<String>,
247+
alias: impl Into<String>,
248+
) -> Result<Self, PipelineError> {
249+
Ok(Self {
250+
selector: Selector::new(selector)?,
251+
alias: Some(OutputName::new(alias)?),
239252
drop: false,
240253
})
241254
}
242255

243256
pub fn drop(selector: impl Into<String>) -> Result<Self, PipelineError> {
244257
Ok(Self {
245258
selector: Selector::new(selector)?,
259+
alias: None,
246260
drop: true,
247261
})
248262
}
@@ -254,6 +268,14 @@ impl ProjectTerm {
254268
pub fn is_drop(&self) -> bool {
255269
self.drop
256270
}
271+
272+
pub fn alias(&self) -> Option<&str> {
273+
self.alias.as_ref().map(OutputName::as_str)
274+
}
275+
276+
pub fn output_name(&self) -> &str {
277+
self.alias().unwrap_or_else(|| self.selector.as_str())
278+
}
257279
}
258280

259281
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -349,7 +371,7 @@ pub(crate) fn validate_projection_terms(terms: &[ProjectTerm]) -> Result<(), Pip
349371
terms
350372
.iter()
351373
.filter(|term| !term.is_drop())
352-
.map(|term| term.selector().as_str()),
374+
.map(ProjectTerm::output_name),
353375
)
354376
}
355377

crates/hubuum-filter/src/parse.rs

Lines changed: 196 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,18 @@ pub(crate) fn validate_pipeline_output_names(stages: &[PipeStage]) -> Result<(),
6565
}
6666
}
6767
PipeStage::Columns(terms) if grouped_names.is_some() => {
68+
let names = grouped_names.as_ref().expect("group names exist");
69+
for alias in terms.iter().filter_map(ProjectTerm::alias) {
70+
if names.contains(alias) {
71+
return Err(PipelineError::Pipe(format!(
72+
"Pipe stage 'P' alias '{alias}' conflicts with a group or aggregate output name"
73+
)));
74+
}
75+
}
6876
let keepers = terms
6977
.iter()
7078
.filter(|term| !term.is_drop())
71-
.map(|term| term.selector().to_string())
79+
.map(|term| term.output_name().to_string())
7280
.collect::<HashSet<_>>();
7381
if !keepers.is_empty() {
7482
grouped_names = Some(keepers);
@@ -137,6 +145,9 @@ fn parse_stage(stage: &str) -> Result<PipeStage, PipelineError> {
137145
if let Some(typed) = parse_typed_predicate_stage(stage) {
138146
return typed;
139147
}
148+
if let Some(projection) = parse_projection_stage_source(stage) {
149+
return projection;
150+
}
140151
if let Some(sort) = parse_sort_stage_source(stage) {
141152
return sort;
142153
}
@@ -163,7 +174,9 @@ fn parse_stage(stage: &str) -> Result<PipeStage, PipelineError> {
163174
require_arg_count(parts[0].as_str(), &parts, 1)?;
164175
Ok(PipeStage::Count)
165176
}
166-
"columns" | "P" => parse_columns_stage(&parts),
177+
"columns" | "P" => {
178+
unreachable!("projection stages are parsed from their original source")
179+
}
167180
"sort" | "S" => unreachable!("sort stages are parsed from their original source"),
168181
"G" => parse_group_stage(&parts),
169182
"A" => parse_aggregate_stage(&parts),
@@ -306,32 +319,60 @@ fn parse_count(name: &str, value: Option<&String>) -> Result<Option<usize>, Pipe
306319
.transpose()
307320
}
308321

309-
fn parse_columns_stage(parts: &[String]) -> Result<PipeStage, PipelineError> {
310-
if parts.len() < 2 {
322+
fn parse_projection_stage_source(stage: &str) -> Option<Result<PipeStage, PipelineError>> {
323+
let verb_end = stage
324+
.char_indices()
325+
.find_map(|(index, ch)| ch.is_whitespace().then_some(index))
326+
.unwrap_or(stage.len());
327+
let verb = &stage[..verb_end];
328+
if !matches!(verb, "P" | "columns") {
329+
return None;
330+
}
331+
332+
let source = stage[verb_end..].trim();
333+
Some(parse_projection_source(verb, source))
334+
}
335+
336+
fn parse_projection_source(name: &str, source: &str) -> Result<PipeStage, PipelineError> {
337+
if source.is_empty() {
311338
return Err(PipelineError::Pipe(format!(
312339
"Pipe stage '{}' requires at least one column",
313-
parts[0]
340+
name
314341
)));
315342
}
316343

317-
let columns = parts
344+
let parts = split(source).ok_or_else(|| {
345+
PipelineError::Parse("Parsing quoted projection terms failed".to_string())
346+
})?;
347+
let has_alias = parts
318348
.iter()
319349
.skip(1)
320-
.flat_map(|part| part.split(','))
321-
.map(str::trim)
322-
.filter(|column| !column.is_empty())
323-
.map(|column| {
324-
column
325-
.strip_prefix('!')
326-
.map(ProjectTerm::drop)
327-
.unwrap_or_else(|| ProjectTerm::keep(column))
328-
})
329-
.collect::<Result<Vec<_>, _>>()?;
350+
.any(|part| part.eq_ignore_ascii_case("AS"));
351+
let columns = if has_alias {
352+
split_projection_terms(source)?
353+
.into_iter()
354+
.enumerate()
355+
.map(|(index, term)| parse_projection_term(term, index + 1))
356+
.collect::<Result<Vec<_>, _>>()?
357+
} else {
358+
parts
359+
.iter()
360+
.flat_map(|part| part.split(','))
361+
.map(str::trim)
362+
.filter(|column| !column.is_empty())
363+
.map(|column| {
364+
column
365+
.strip_prefix('!')
366+
.map(ProjectTerm::drop)
367+
.unwrap_or_else(|| ProjectTerm::keep(column))
368+
})
369+
.collect::<Result<Vec<_>, _>>()?
370+
};
330371

331372
if columns.is_empty() {
332373
return Err(PipelineError::Pipe(format!(
333374
"Pipe stage '{}' requires at least one column",
334-
parts[0]
375+
name
335376
)));
336377
}
337378

@@ -340,6 +381,82 @@ fn parse_columns_stage(parts: &[String]) -> Result<PipeStage, PipelineError> {
340381
Ok(PipeStage::Columns(columns))
341382
}
342383

384+
fn split_projection_terms(source: &str) -> Result<Vec<&str>, PipelineError> {
385+
let mut terms = Vec::new();
386+
let mut quote = None;
387+
let mut escaped = false;
388+
let mut start = 0;
389+
for (index, ch) in source.char_indices() {
390+
if escaped {
391+
escaped = false;
392+
continue;
393+
}
394+
if ch == '\\' {
395+
escaped = true;
396+
continue;
397+
}
398+
match quote {
399+
Some(active) if ch == active => quote = None,
400+
Some(_) => {}
401+
None if matches!(ch, '\'' | '"') => quote = Some(ch),
402+
None if ch == ',' => {
403+
let term = source[start..index].trim();
404+
if term.is_empty() {
405+
return Err(PipelineError::Parse(
406+
"Projection terms cannot be empty".to_string(),
407+
));
408+
}
409+
terms.push(term);
410+
start = index + ch.len_utf8();
411+
}
412+
None => {}
413+
}
414+
}
415+
let term = source[start..].trim();
416+
if term.is_empty() {
417+
return Err(PipelineError::Parse(
418+
"Projection terms cannot be empty".to_string(),
419+
));
420+
}
421+
terms.push(term);
422+
Ok(terms)
423+
}
424+
425+
fn parse_projection_term(source: &str, term_number: usize) -> Result<ProjectTerm, PipelineError> {
426+
let parts = split(source).ok_or_else(|| {
427+
PipelineError::Parse(format!(
428+
"Parsing quoted projection term {term_number} failed"
429+
))
430+
})?;
431+
let Some(selector) = parts.first() else {
432+
return Err(PipelineError::Parse(format!(
433+
"Projection term {term_number} cannot be empty"
434+
)));
435+
};
436+
let (selector, drop) = selector
437+
.strip_prefix('!')
438+
.map_or((selector.as_str(), false), |selector| (selector, true));
439+
440+
if parts.len() == 1 {
441+
return if drop {
442+
ProjectTerm::drop(selector)
443+
} else {
444+
ProjectTerm::keep(selector)
445+
};
446+
}
447+
if parts.len() != 3 || !parts[1].eq_ignore_ascii_case("AS") {
448+
return Err(PipelineError::Parse(format!(
449+
"Projection term {term_number} must be selector [AS output-name]; commas are required between terms when any alias is used"
450+
)));
451+
}
452+
if drop {
453+
return Err(PipelineError::Parse(format!(
454+
"Projection drop term {term_number} cannot use AS"
455+
)));
456+
}
457+
ProjectTerm::aliased(selector, &parts[2])
458+
}
459+
343460
fn parse_sort_stage_source(stage: &str) -> Option<Result<PipeStage, PipelineError>> {
344461
let verb_end = stage
345462
.char_indices()
@@ -728,6 +845,42 @@ mod tests {
728845
);
729846
}
730847

848+
#[test]
849+
fn projection_aliases_parse_with_mixed_and_quoted_terms() {
850+
let (_, stages) = split_pipeline(
851+
"object list | P Name AS Host, data.interfaces[].ip AS 'IP Addresses', state, !secret",
852+
)
853+
.expect("aliased projection");
854+
let PipeStage::Columns(terms) = &stages[0] else {
855+
panic!("expected projection")
856+
};
857+
858+
assert_eq!(terms.len(), 4);
859+
assert_eq!(terms[0].selector().as_str(), "Name");
860+
assert_eq!(terms[0].alias(), Some("Host"));
861+
assert_eq!(terms[1].alias(), Some("IP Addresses"));
862+
assert_eq!(terms[2].output_name(), "state");
863+
assert!(terms[3].is_drop());
864+
}
865+
866+
#[test]
867+
fn aliased_projection_terms_require_commas_and_reject_drop_aliases() {
868+
let missing_comma = split_pipeline("object list | P Name AS Host state AS State")
869+
.expect_err("aliased terms need commas");
870+
assert!(missing_comma.to_string().contains("commas are required"));
871+
872+
let drop_alias = split_pipeline("object list | P Name AS Host, !secret AS Hidden")
873+
.expect_err("drop aliases must fail");
874+
assert!(drop_alias.to_string().contains("drop term 2 cannot use AS"));
875+
876+
for source in [
877+
"object list | P Name AS Host,",
878+
"object list | P Name AS Host,, state",
879+
] {
880+
assert!(split_pipeline(source).is_err(), "{source}");
881+
}
882+
}
883+
731884
#[test]
732885
fn typed_predicates_preserve_quotes_and_legacy_filters() {
733886
let (_command, stages) = split_pipeline(
@@ -865,6 +1018,8 @@ mod tests {
8651018
fn output_names_must_be_unique_during_parsing() {
8661019
for (line, stage, name) in [
8671020
("object list | P a a", "P", "a"),
1021+
("object list | P a AS x, b AS x", "P", "x"),
1022+
("object list | P x, b AS x", "P", "x"),
8681023
("object list | G a AS x b AS x", "G", "x"),
8691024
("object list | G a AS x | A count AS x", "A", "x"),
8701025
("object list | G a | A count AS n | A count AS n", "A", "n"),
@@ -879,11 +1034,15 @@ mod tests {
8791034
#[test]
8801035
fn quoted_output_aliases_with_spaces_remain_valid() {
8811036
let (_, stages) =
882-
split_pipeline("object list | G os_version AS 'OS Version' | A count AS 'Host Count'")
1037+
split_pipeline("object list | P Name AS 'Host, Name' | G os_version AS 'OS Version' | A count AS 'Host Count'")
8831038
.expect("spaced aliases should parse");
8841039

8851040
assert!(matches!(
886-
&stages[..],
1041+
&stages[0],
1042+
PipeStage::Columns(terms) if terms[0].alias() == Some("Host, Name")
1043+
));
1044+
assert!(matches!(
1045+
&stages[1..],
8871046
[PipeStage::Group(keys), PipeStage::Aggregate(spec)]
8881047
if keys[0].alias() == "OS Version" && spec.alias() == "Host Count"
8891048
));
@@ -892,11 +1051,29 @@ mod tests {
8921051
#[test]
8931052
fn empty_output_aliases_are_rejected() {
8941053
for line in [
1054+
"object list | P name AS ''",
8951055
"object list | G name AS ''",
8961056
"object list | G name | A count AS ''",
8971057
] {
8981058
let error = split_pipeline(line).expect_err("empty alias should fail");
8991059
assert!(error.to_string().contains("output name cannot be empty"));
9001060
}
9011061
}
1062+
1063+
#[test]
1064+
fn projection_aliases_cannot_overwrite_group_or_aggregate_names() {
1065+
for (line, name) in [
1066+
("object list | G rack AS Rack | P name AS Rack", "Rack"),
1067+
(
1068+
"object list | G rack AS Rack | A count AS Hosts | P name AS Hosts",
1069+
"Hosts",
1070+
),
1071+
] {
1072+
let error = split_pipeline(line).expect_err("grouped alias collision should fail");
1073+
let message = error.to_string();
1074+
assert!(message.contains("stage 'P'"), "{message}");
1075+
assert!(message.contains(name), "{message}");
1076+
assert!(message.contains("group or aggregate"), "{message}");
1077+
}
1078+
}
9021079
}

0 commit comments

Comments
 (0)