From f934ecd192c5d828b2db2edac03cc43ac336edf2 Mon Sep 17 00:00:00 2001 From: Ben Herzberg Date: Thu, 17 Sep 2026 15:01:40 +0300 Subject: [PATCH] Postgres: Parse trailing asterisk after table name in FROM clause PostgreSQL allows an explicit trailing '*' after a table name in FROM to indicate that descendant tables should be included, e.g. SELECT * FROM tbl_name*. This was previously only supported for TRUNCATE and LOCK TABLE targets, not for FROM items. Also renamed the existing has_asterisk fields on TruncateTableTarget and LockTableTarget to has_trailing_asterisk for consistency and clarity. https://www.postgresql.org/docs/current/sql-select.html#SQL-FROM --- src/ast/mod.rs | 8 ++++---- src/ast/query.rs | 8 ++++++++ src/ast/spans.rs | 1 + src/parser/mod.rs | 14 +++++++++---- src/test_utils.rs | 3 +++ tests/sqlparser_bigquery.rs | 3 +++ tests/sqlparser_common.rs | 27 ++++++++++++++++++++++-- tests/sqlparser_hive.rs | 1 + tests/sqlparser_mssql.rs | 14 +++++++++---- tests/sqlparser_mysql.rs | 2 ++ tests/sqlparser_postgres.rs | 41 +++++++++++++++++++++++++++---------- 11 files changed, 97 insertions(+), 25 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 20058b83ab..783a548e88 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -6631,7 +6631,7 @@ pub struct TruncateTableTarget { /// TRUNCATE TABLE name [ * ] /// ``` /// - pub has_asterisk: bool, + pub has_trailing_asterisk: bool, } impl fmt::Display for TruncateTableTarget { @@ -6640,7 +6640,7 @@ impl fmt::Display for TruncateTableTarget { write!(f, "ONLY ")?; }; write!(f, "{}", self.name)?; - if self.has_asterisk { + if self.has_trailing_asterisk { write!(f, " *")?; }; Ok(()) @@ -6688,7 +6688,7 @@ pub struct LockTableTarget { /// Whether `ONLY` was specified to exclude descendant tables. pub only: bool, /// Whether `*` was specified to explicitly include descendant tables. - pub has_asterisk: bool, + pub has_trailing_asterisk: bool, } impl fmt::Display for LockTableTarget { @@ -6697,7 +6697,7 @@ impl fmt::Display for LockTableTarget { write!(f, "ONLY ")?; } write!(f, "{}", self.name)?; - if self.has_asterisk { + if self.has_trailing_asterisk { write!(f, " *")?; } Ok(()) diff --git a/src/ast/query.rs b/src/ast/query.rs index 296e4e8ca6..4dbb21ee71 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -1494,6 +1494,10 @@ pub enum TableFactor { /// Optional index hints(mysql) /// See: index_hints: Vec, + /// Whether a trailing `*` was specified, e.g. `FROM tab*`, to explicitly + /// include descendant tables, as supported by + /// [Postgres](https://www.postgresql.org/docs/current/sql-select.html#SQL-FROM). + has_trailing_asterisk: bool, }, /// A derived table (a parenthesized subquery), optionally `LATERAL`. Derived { @@ -2225,11 +2229,15 @@ impl fmt::Display for TableFactor { json_path, sample, index_hints, + has_trailing_asterisk, } => { name.fmt(f)?; if let Some(json_path) = json_path { json_path.fmt(f)?; } + if *has_trailing_asterisk { + write!(f, "*")?; + } if !partitions.is_empty() { write!(f, " PARTITION ({})", display_comma_separated(partitions))?; } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 7acbd7d0b4..0d769d25d8 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -1979,6 +1979,7 @@ impl Spanned for TableFactor { json_path: _, sample: _, index_hints: _, + has_trailing_asterisk: _, } => union_spans( name.0 .iter() diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 15f135fffa..70475aba5b 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1121,11 +1121,11 @@ impl<'a> Parser<'a> { let table_names = self.parse_comma_separated(|p| { let only = p.parse_keyword(Keyword::ONLY); let name = p.parse_object_name(false)?; - let has_asterisk = p.consume_token(&Token::Mul); + let has_trailing_asterisk = p.consume_token(&Token::Mul); Ok(TruncateTableTarget { name, only, - has_asterisk, + has_trailing_asterisk, }) })?; @@ -16748,6 +16748,10 @@ impl<'a> Parser<'a> { } else { let name = self.parse_object_name(true)?; + // Postgres/Snowflake: `FROM tab*` explicitly includes descendant tables. + // https://www.postgresql.org/docs/current/sql-select.html#SQL-FROM + let has_trailing_asterisk = self.consume_token(&Token::Mul); + let json_path = match &self.peek_token_ref().token { Token::LBracket if self.dialect.supports_partiql() => Some(self.parse_json_path()?), _ => None, @@ -16819,6 +16823,7 @@ impl<'a> Parser<'a> { json_path, sample, index_hints, + has_trailing_asterisk, }; while let Some(kw) = self.parse_one_of_keywords(&[Keyword::PIVOT, Keyword::UNPIVOT]) { @@ -16869,6 +16874,7 @@ impl<'a> Parser<'a> { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }) } @@ -19707,12 +19713,12 @@ impl<'a> Parser<'a> { fn parse_lock_table_target(&mut self) -> Result { let only = self.parse_keyword(Keyword::ONLY); let name = self.parse_object_name(false)?; - let has_asterisk = self.consume_token(&Token::Mul); + let has_trailing_asterisk = self.consume_token(&Token::Mul); Ok(LockTableTarget { name, only, - has_asterisk, + has_trailing_asterisk, }) } diff --git a/src/test_utils.rs b/src/test_utils.rs index c4d1d0db2e..6ccc3d94f1 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -391,6 +391,7 @@ pub fn table(name: impl Into) -> TableFactor { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, } } @@ -406,6 +407,7 @@ pub fn table_from_name(name: ObjectName) -> TableFactor { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, } } @@ -425,6 +427,7 @@ pub fn table_with_alias( json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, } } diff --git a/tests/sqlparser_bigquery.rs b/tests/sqlparser_bigquery.rs index 97f71cfe76..5f4c98caeb 100644 --- a/tests/sqlparser_bigquery.rs +++ b/tests/sqlparser_bigquery.rs @@ -1756,6 +1756,7 @@ fn parse_table_time_travel() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, joins: vec![] },] @@ -1866,6 +1867,7 @@ fn parse_merge() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, table ); @@ -1881,6 +1883,7 @@ fn parse_merge() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, source ); diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 2de6062b28..245bab8f9c 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -600,6 +600,7 @@ fn parse_update_with_table_alias() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, joins: vec![], }, @@ -702,6 +703,7 @@ fn parse_select_with_table_alias() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, joins: vec![], }] @@ -900,6 +902,7 @@ fn parse_where_delete_with_alias_statement() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, from[0].relation, ); @@ -916,6 +919,7 @@ fn parse_where_delete_with_alias_statement() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, joins: vec![], }]), @@ -7756,6 +7760,7 @@ fn parse_joins_on() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, global, join_operator: f(JoinConstraint::On(Expr::BinaryOp { @@ -7898,6 +7903,7 @@ fn parse_joins_using() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, global: false, join_operator: f(JoinConstraint::Using(vec![ObjectName::from(vec![ @@ -7993,6 +7999,7 @@ fn parse_natural_join() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, global: false, join_operator: f(JoinConstraint::Natural), @@ -10431,6 +10438,7 @@ fn parse_merge() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, } ); assert_eq!(table, table_no_into); @@ -11867,6 +11875,7 @@ fn parse_pivot_table() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }), aggregate_functions: vec![ expected_function("a", None), @@ -11947,6 +11956,7 @@ fn parse_pivot_table() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }), aggregate_functions: vec![ ExprWithAlias { @@ -12026,6 +12036,7 @@ fn parse_unpivot_table() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }), null_inclusion: None, value: Expr::Identifier(Ident::new("quantity")), @@ -12282,6 +12293,7 @@ fn parse_select_table_with_index_hints() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, joins: vec![], }] @@ -12311,6 +12323,7 @@ fn parse_pivot_unpivot_table() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }), null_inclusion: None, value: Expr::Identifier(Ident::new("population")), @@ -17807,12 +17820,12 @@ fn parse_truncate_only() { TruncateTableTarget { name: ObjectName::from(vec![Ident::new("employee")]), only: false, - has_asterisk: false, + has_trailing_asterisk: false, }, TruncateTableTarget { name: ObjectName::from(vec![Ident::new("dept")]), only: true, - has_asterisk: false, + has_trailing_asterisk: false, }, ]; @@ -17896,6 +17909,7 @@ fn test_nested_join_without_parentheses() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, joins: vec![Join { relation: TableFactor::Table { @@ -17909,6 +17923,7 @@ fn test_nested_join_without_parentheses() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, global: false, join_operator: JoinOperator::Inner(JoinConstraint::On(Expr::BinaryOp { @@ -17963,6 +17978,7 @@ fn test_nested_join_without_parentheses() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, joins: vec![Join { relation: TableFactor::Table { @@ -17976,6 +17992,7 @@ fn test_nested_join_without_parentheses() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, global: false, join_operator: JoinOperator::Join(JoinConstraint::On(Expr::BinaryOp { @@ -18030,6 +18047,7 @@ fn test_nested_join_without_parentheses() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, joins: vec![Join { relation: TableFactor::Table { @@ -18043,6 +18061,7 @@ fn test_nested_join_without_parentheses() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, global: false, join_operator: JoinOperator::Left(JoinConstraint::On(Expr::BinaryOp { @@ -18097,6 +18116,7 @@ fn test_nested_join_without_parentheses() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, joins: vec![Join { relation: TableFactor::Table { @@ -18110,6 +18130,7 @@ fn test_nested_join_without_parentheses() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, global: false, join_operator: JoinOperator::Right(JoinConstraint::On(Expr::BinaryOp { @@ -18164,6 +18185,7 @@ fn test_nested_join_without_parentheses() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, joins: vec![Join { relation: TableFactor::Table { @@ -18177,6 +18199,7 @@ fn test_nested_join_without_parentheses() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, global: false, join_operator: JoinOperator::FullOuter(JoinConstraint::On( diff --git a/tests/sqlparser_hive.rs b/tests/sqlparser_hive.rs index 21e76e23b6..ee35c66c80 100644 --- a/tests/sqlparser_hive.rs +++ b/tests/sqlparser_hive.rs @@ -472,6 +472,7 @@ fn parse_delimited_identifiers() { json_path: _, sample: _, index_hints: _, + has_trailing_asterisk: _, } => { assert_eq!( ObjectName::from(vec![Ident::with_quote('"', "a table")]), diff --git a/tests/sqlparser_mssql.rs b/tests/sqlparser_mssql.rs index 4510f953e5..07a05a5b7e 100644 --- a/tests/sqlparser_mssql.rs +++ b/tests/sqlparser_mssql.rs @@ -75,7 +75,8 @@ fn parse_table_time_travel() { with_ordinality: false, json_path: None, sample: None, - index_hints: vec![] + index_hints: vec![], + has_trailing_asterisk: false, }, joins: vec![] },] @@ -490,7 +491,8 @@ fn parse_mssql_openjson() { partitions: vec![], json_path: None, sample: None, - index_hints: vec![] + index_hints: vec![], + has_trailing_asterisk: false, }, joins: vec![Join { relation: TableFactor::OpenJsonTable { @@ -544,7 +546,8 @@ fn parse_mssql_openjson() { partitions: vec![], json_path: None, sample: None, - index_hints: vec![] + index_hints: vec![], + has_trailing_asterisk: false, }, joins: vec![Join { relation: TableFactor::OpenJsonTable { @@ -598,7 +601,8 @@ fn parse_mssql_openjson() { partitions: vec![], json_path: None, sample: None, - index_hints: vec![] + index_hints: vec![], + has_trailing_asterisk: false, }, joins: vec![Join { relation: TableFactor::OpenJsonTable { @@ -653,6 +657,7 @@ fn parse_mssql_openjson() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, joins: vec![Join { relation: TableFactor::OpenJsonTable { @@ -687,6 +692,7 @@ fn parse_mssql_openjson() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, joins: vec![Join { relation: TableFactor::OpenJsonTable { diff --git a/tests/sqlparser_mysql.rs b/tests/sqlparser_mysql.rs index 97f213743a..b9c3ff6c2a 100644 --- a/tests/sqlparser_mysql.rs +++ b/tests/sqlparser_mysql.rs @@ -2747,6 +2747,7 @@ fn parse_update_with_joins() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, joins: vec![Join { relation: TableFactor::Table { @@ -2760,6 +2761,7 @@ fn parse_update_with_joins() { json_path: None, sample: None, index_hints: vec![], + has_trailing_asterisk: false, }, global: false, join_operator: JoinOperator::Join(JoinConstraint::On(Expr::BinaryOp { diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index d71e49b27a..99f27ba3da 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -5745,7 +5745,7 @@ fn parse_truncate() { let table_names = vec![TruncateTableTarget { name: table_name.clone(), only: false, - has_asterisk: false, + has_trailing_asterisk: false, }]; assert_eq!( Statement::Truncate(Truncate { @@ -5770,7 +5770,7 @@ fn parse_truncate_with_options() { let table_names = vec![TruncateTableTarget { name: table_name.clone(), only: true, - has_asterisk: false, + has_trailing_asterisk: false, }]; assert_eq!( @@ -5800,12 +5800,12 @@ fn parse_truncate_with_table_list() { TruncateTableTarget { name: table_name_a.clone(), only: false, - has_asterisk: false, + has_trailing_asterisk: false, }, TruncateTableTarget { name: table_name_b.clone(), only: false, - has_asterisk: false, + has_trailing_asterisk: false, }, ]; @@ -5830,7 +5830,7 @@ fn parse_truncate_with_descendant() { let table_names = vec![TruncateTableTarget { name: ObjectName::from(vec![Ident::new("t")]), only: false, - has_asterisk: true, + has_trailing_asterisk: true, }]; assert_eq!( @@ -5853,17 +5853,17 @@ fn parse_truncate_with_descendant() { TruncateTableTarget { name: ObjectName::from(vec![Ident::new("parent")]), only: true, - has_asterisk: false, + has_trailing_asterisk: false, }, TruncateTableTarget { name: ObjectName::from(vec![Ident::new("child")]), only: false, - has_asterisk: true, + has_trailing_asterisk: true, }, TruncateTableTarget { name: ObjectName::from(vec![Ident::new("grandchild")]), only: false, - has_asterisk: false, + has_trailing_asterisk: false, }, ]; @@ -9598,10 +9598,10 @@ fn parse_lock_table() { assert_eq!(lock.tables.len(), 2); assert_eq!(lock.tables[0].name.to_string(), "public.widgets"); assert!(lock.tables[0].only); - assert!(!lock.tables[0].has_asterisk); + assert!(!lock.tables[0].has_trailing_asterisk); assert_eq!(lock.tables[1].name.to_string(), "analytics.events"); assert!(!lock.tables[1].only); - assert!(lock.tables[1].has_asterisk); + assert!(lock.tables[1].has_trailing_asterisk); assert_eq!(lock.lock_mode, Some(LockTableMode::ShareRowExclusive)); assert!(lock.nowait); } @@ -9630,7 +9630,7 @@ fn parse_lock_table() { assert_eq!(lock.tables.len(), 1); assert_eq!(lock.tables[0].name.to_string(), "public.widgets"); assert!(!lock.tables[0].only); - assert!(!lock.tables[0].has_asterisk); + assert!(!lock.tables[0].has_trailing_asterisk); assert_eq!(lock.lock_mode, Some(expected_mode)); assert!(!lock.nowait); } @@ -9953,3 +9953,22 @@ fn parse_insert_by_name_keywords_as_table_and_alias() { statement => panic!("Expected INSERT statement, got: {statement:?}"), } } + +#[test] +fn parse_from_table_with_trailing_asterisk() { + // `*` after a table name explicitly includes descendant tables. + // + match pg().verified_stmt("SELECT * FROM tbl_name*") { + Statement::Query(query) => match *query.body { + SetExpr::Select(select) => match &select.from[0].relation { + TableFactor::Table { + has_trailing_asterisk, + .. + } => assert!(*has_trailing_asterisk), + relation => panic!("Expected TableFactor::Table, got: {relation:?}"), + }, + body => panic!("Expected SetExpr::Select, got: {body:?}"), + }, + statement => panic!("Expected SELECT statement, got: {statement:?}"), + } +}