@@ -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+
343460fn 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