diff --git a/crates/sqlquack-semantics/src/bigquery/profile/tests/test_output_types.rs b/crates/sqlquack-semantics/src/bigquery/profile/tests/test_output_types.rs index ec40682fd..bcc9150c9 100644 --- a/crates/sqlquack-semantics/src/bigquery/profile/tests/test_output_types.rs +++ b/crates/sqlquack-semantics/src/bigquery/profile/tests/test_output_types.rs @@ -1,6 +1,10 @@ use crate::bigquery::profile::tests::helpers::bigquery_output_type; use crate::bigquery::profile::tests::helpers::typed_bigquery_shop_schema; use crate::bigquery::profile::tests::test_types::BigqueryTypeTestCase; +use crate::dialects::constants::BIGQUERY; +use crate::lineage::tests::helpers::duckdb_shop_schema; +use crate::lineage::tests::helpers::output_ranges; +use crate::lineage::tests::test_types::StrictRangeTestCase; /// Each expected type is the one GoogleSQL's analyzer gives the same query over the same columns. #[test] @@ -172,3 +176,39 @@ fn given_bigquery_queries_when_typing_outputs_then_each_has_the_analyzer_type() ); } } + +#[test] +fn given_a_strict_parse_when_tracing_lineage_then_each_output_has_its_items_byte_range() { + let test_cases = [StrictRangeTestCase { + description: "a literal aliased after a two-byte character, a qualified column, a labelled call, a bare column and a qualified star", + sql: "SELECT 'é' AS e, w.status, UPPER(w.status) loud, id, w.* FROM orders AS w", + expected_ranges: &[ + 7..16, + 18..26, + 28..48, + 50..52, + 54..57, + 54..57, + 54..57, + 54..57, + 54..57, + ], + }]; + for test_case in test_cases { + let statement = sqlquack_syntax_bigquery::parse_strict(test_case.sql) + .unwrap_or_else(|failure| panic!("{}: {failure:?}", test_case.description)) + .statements + .remove(0); + assert_eq!( + output_ranges(&statement, &BIGQUERY, &duckdb_shop_schema(), test_case.sql), + test_case + .expected_ranges + .iter() + .cloned() + .map(Some) + .collect::>(), + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-semantics/src/clickhouse/profile/tests/test_lineage.rs b/crates/sqlquack-semantics/src/clickhouse/profile/tests/test_lineage.rs index a5e7f8d67..6013a14c1 100644 --- a/crates/sqlquack-semantics/src/clickhouse/profile/tests/test_lineage.rs +++ b/crates/sqlquack-semantics/src/clickhouse/profile/tests/test_lineage.rs @@ -11,7 +11,10 @@ use crate::lineage::_helpers::findings::statement::statement_findings; use crate::lineage::main::statement_lineage::statement_lineage; use crate::lineage::models::LineageContext; use crate::lineage::models::Nullability::{NonNull, Nullable, Unknown}; +use crate::lineage::tests::helpers::duckdb_shop_schema; +use crate::lineage::tests::helpers::output_ranges; use crate::lineage::tests::helpers::owned_findings; +use crate::lineage::tests::test_types::StrictRangeTestCase; use crate::lineage::tests::test_types::{EngineTypeReplayTestCase, QueryFindingsTestCase}; /// Result types ClickHouse 26.1 reported for built-in calls and expressions, under SQLBuild's settings. @@ -398,3 +401,44 @@ fn given_a_name_joined_relations_share_when_resolving_then_clickhouse_reads_the_ ); } } + +#[test] +fn given_a_strict_parse_when_tracing_lineage_then_each_output_has_its_items_byte_range() { + let test_cases = [StrictRangeTestCase { + description: "a literal aliased after a two-byte character, a qualified column, a labelled call, a bare column and a qualified star", + sql: "SELECT 'é' AS e, w.status, UPPER(w.status) loud, id, w.* FROM orders AS w", + expected_ranges: &[ + 7..16, + 18..26, + 28..48, + 50..52, + 54..57, + 54..57, + 54..57, + 54..57, + 54..57, + ], + }]; + for test_case in test_cases { + let statement = sqlquack_syntax_clickhouse::parse_strict(test_case.sql) + .unwrap_or_else(|failure| panic!("{}: {failure:?}", test_case.description)) + .statements + .remove(0); + assert_eq!( + output_ranges( + &statement, + &CLICKHOUSE, + &duckdb_shop_schema(), + test_case.sql + ), + test_case + .expected_ranges + .iter() + .cloned() + .map(Some) + .collect::>(), + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-semantics/src/databricks/profile/tests/test_lineage.rs b/crates/sqlquack-semantics/src/databricks/profile/tests/test_lineage.rs index e703d616e..e6b8ee549 100644 --- a/crates/sqlquack-semantics/src/databricks/profile/tests/test_lineage.rs +++ b/crates/sqlquack-semantics/src/databricks/profile/tests/test_lineage.rs @@ -1,8 +1,12 @@ use crate::databricks::profile::tests::helpers::databricks_findings; use crate::databricks::profile::tests::helpers::databricks_outputs; use crate::databricks::profile::tests::test_types::DatabricksOutputsTestCase; +use crate::dialects::constants::DATABRICKS; +use crate::lineage::tests::helpers::duckdb_shop_schema; +use crate::lineage::tests::helpers::output_ranges; use crate::lineage::tests::helpers::owned_findings; use crate::lineage::tests::test_types::QueryFindingsTestCase; +use crate::lineage::tests::test_types::StrictRangeTestCase; #[test] fn given_a_databricks_query_when_checking_it_then_only_rules_databricks_shares_are_found() { @@ -191,3 +195,44 @@ fn given_a_databricks_query_when_naming_and_typing_outputs_then_they_are_the_war ); } } + +#[test] +fn given_a_strict_parse_when_tracing_lineage_then_each_output_has_its_items_byte_range() { + let test_cases = [StrictRangeTestCase { + description: "a literal aliased after a two-byte character, a qualified column, a labelled call, a bare column and a qualified star", + sql: "SELECT 'é' AS e, w.status, UPPER(w.status) loud, id, w.* FROM orders AS w", + expected_ranges: &[ + 7..16, + 18..26, + 28..48, + 50..52, + 54..57, + 54..57, + 54..57, + 54..57, + 54..57, + ], + }]; + for test_case in test_cases { + let statement = sqlquack_syntax_databricks::parse_strict(test_case.sql) + .unwrap_or_else(|failure| panic!("{}: {failure:?}", test_case.description)) + .statements + .remove(0); + assert_eq!( + output_ranges( + &statement, + &DATABRICKS, + &duckdb_shop_schema(), + test_case.sql + ), + test_case + .expected_ranges + .iter() + .cloned() + .map(Some) + .collect::>(), + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-semantics/src/duckdb/profile/tests/test_lineage_details.rs b/crates/sqlquack-semantics/src/duckdb/profile/tests/test_lineage_details.rs index a563c8d22..6383f355b 100644 --- a/crates/sqlquack-semantics/src/duckdb/profile/tests/test_lineage_details.rs +++ b/crates/sqlquack-semantics/src/duckdb/profile/tests/test_lineage_details.rs @@ -3,8 +3,11 @@ use sqlquack_syntax::{Dialect, ParseOptions}; use crate::dialects::constants::DUCKDB; use crate::lineage::main::query_lineage::query_lineage; use crate::lineage::models::{LineageContext, TransformKind}; +use crate::lineage::tests::helpers::duckdb_shop_schema; +use crate::lineage::tests::helpers::output_ranges; use crate::lineage::tests::helpers::{shop_schema, written}; use crate::lineage::tests::test_types::LineageDetailTestCase; +use crate::lineage::tests::test_types::StrictRangeTestCase; #[test] fn given_a_query_when_tracing_lineage_then_values_rows_and_untraced_outputs_are_reported() { @@ -108,3 +111,39 @@ fn given_a_query_when_tracing_lineage_then_values_rows_and_untraced_outputs_are_ ); } } + +#[test] +fn given_a_strict_parse_when_tracing_lineage_then_each_output_has_its_items_byte_range() { + let test_cases = [StrictRangeTestCase { + description: "a literal aliased after a two-byte character, a qualified column, a labelled call, a bare column and a qualified star", + sql: "SELECT 'é' AS e, w.status, UPPER(w.status) loud, id, w.* FROM orders AS w", + expected_ranges: &[ + 7..16, + 18..26, + 28..48, + 50..52, + 54..57, + 54..57, + 54..57, + 54..57, + 54..57, + ], + }]; + for test_case in test_cases { + let statement = sqlquack_syntax_duckdb::parse_strict(test_case.sql) + .unwrap_or_else(|failure| panic!("{}: {failure:?}", test_case.description)) + .statements + .remove(0); + assert_eq!( + output_ranges(&statement, &DUCKDB, &duckdb_shop_schema(), test_case.sql), + test_case + .expected_ranges + .iter() + .cloned() + .map(Some) + .collect::>(), + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-semantics/src/lineage/_helpers/tracing/queries.rs b/crates/sqlquack-semantics/src/lineage/_helpers/tracing/queries.rs index 88a6db529..3b7b89365 100644 --- a/crates/sqlquack-semantics/src/lineage/_helpers/tracing/queries.rs +++ b/crates/sqlquack-semantics/src/lineage/_helpers/tracing/queries.rs @@ -7,6 +7,7 @@ use sqlquack_syntax::Expression; use sqlquack_syntax::ExpressionWalk; use sqlquack_syntax::expressions::names::models::{Identifier, Literal, Star}; use sqlquack_syntax::expressions::queries::models::{Cte, OrderBy, Select, Union, With}; +use sqlquack_syntax::tokens::models::Span; use crate::dialects::main::dialect_rules::dialect_rules; use crate::lineage::_helpers::tracing::expressions::{expression_lineage, is_aggregate}; @@ -583,7 +584,7 @@ fn select_query( name: output_name(projection, context), key: output_key(projection, context), lineage: expression_lineage(projection, &scope, context), - expression: projection.source_span(), + expression: item_range(projection), }; query.rows.rows_of(&column.lineage); if !holds_aggregate(projection) { @@ -1018,3 +1019,15 @@ fn is_star(projection: &Expression) -> bool { _ => false, } } + +/// A select item's source range; a column's runs from its qualifier, which some parsers' column span omits. +fn item_range(projection: &Expression) -> Option { + let qualifier = match projection { + Expression::Column(column) => column.table.as_ref().and_then(|table| table.span), + _ => None, + }; + match (qualifier, projection.source_span()) { + (Some(qualifier), Some(name)) => Some(qualifier.through(name)), + (_, range) => range, + } +} diff --git a/crates/sqlquack-semantics/src/lineage/tests/helpers.rs b/crates/sqlquack-semantics/src/lineage/tests/helpers.rs index 2a045929c..364ee0017 100644 --- a/crates/sqlquack-semantics/src/lineage/tests/helpers.rs +++ b/crates/sqlquack-semantics/src/lineage/tests/helpers.rs @@ -1,10 +1,12 @@ use std::collections::HashMap; +use std::ops::Range; use sqlquack_parse::parse_statements_with; -use sqlquack_syntax::{CallMode, Dialect, ParseOptions}; +use sqlquack_syntax::{CallMode, Dialect, Expression, ParseOptions}; use crate::dialects::types::DialectProfile; use crate::lineage::_helpers::findings::statement::statement_findings; +use crate::lineage::main::statement_lineage::statement_lineage; use crate::lineage::models::LineageContext; use crate::lineage::models::NullabilityRule; use crate::lineage::models::{Nullability, SchemaColumn, SourceColumn}; @@ -19,6 +21,25 @@ impl LineageSchema for ShopSchema { } } +/// Each output's byte range in `sql` when lineage traces `statement` under `profile`. +pub(crate) fn output_ranges( + statement: &Expression, + profile: &dyn DialectProfile, + schema: &ShopSchema, + sql: &str, +) -> Vec>> { + let context = LineageContext { + profile, + schema, + sql: Some(sql), + }; + statement_lineage(statement, context) + .outputs + .into_iter() + .map(|output| output.expression_range) + .collect() +} + /// Orders, customers and products, as a Snowflake catalog spells unquoted names. pub(crate) fn shop_schema() -> ShopSchema { schema_of([ diff --git a/crates/sqlquack-semantics/src/lineage/tests/test_types.rs b/crates/sqlquack-semantics/src/lineage/tests/test_types.rs index 21b2809f9..d968b58c8 100644 --- a/crates/sqlquack-semantics/src/lineage/tests/test_types.rs +++ b/crates/sqlquack-semantics/src/lineage/tests/test_types.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::ops::Range; use sqlquack_syntax::Dialect; @@ -28,6 +29,13 @@ pub(crate) struct LineageDetailTestCase { pub(crate) expected_rows_resolved: bool, } +/// A query and each output's expected byte range, read by one dialect's strict parser. +pub(crate) struct StrictRangeTestCase { + pub(crate) description: &'static str, + pub(crate) sql: &'static str, + pub(crate) expected_ranges: &'static [Range], +} + /// A query and, per output, the SQL text its expression range covers, or none. pub(crate) struct OutputRangeTestCase { pub(crate) description: &'static str, diff --git a/crates/sqlquack-semantics/src/postgres/profile/tests/test_query_lineage.rs b/crates/sqlquack-semantics/src/postgres/profile/tests/test_query_lineage.rs index 30ed288bb..4e0da033f 100644 --- a/crates/sqlquack-semantics/src/postgres/profile/tests/test_query_lineage.rs +++ b/crates/sqlquack-semantics/src/postgres/profile/tests/test_query_lineage.rs @@ -4,8 +4,10 @@ use sqlquack_syntax::Dialect; use crate::dialects::constants::POSTGRES; use crate::lineage::models::TransformKind; +use crate::lineage::tests::helpers::output_ranges; use crate::lineage::tests::helpers::{duckdb_shop_schema, typed_column, typed_duckdb_shop_schema}; use crate::lineage::tests::test_types::QueryLineageTestCase; +use crate::lineage::tests::test_types::StrictRangeTestCase; use crate::lineage::tests::test_types::TypedShopSchema; use crate::postgres::profile::tests::helpers::lineage_outputs; @@ -238,3 +240,39 @@ fn given_a_union_of_untyped_columns_when_tracing_postgres_lineage_then_it_stays_ ); } } + +#[test] +fn given_a_strict_parse_when_tracing_lineage_then_each_output_has_its_items_byte_range() { + let test_cases = [StrictRangeTestCase { + description: "a literal aliased after a two-byte character, a qualified column, a labelled call, a bare column and a qualified star", + sql: "SELECT 'é' AS e, w.status, UPPER(w.status) loud, id, w.* FROM orders AS w", + expected_ranges: &[ + 7..16, + 18..26, + 28..48, + 50..52, + 54..57, + 54..57, + 54..57, + 54..57, + 54..57, + ], + }]; + for test_case in test_cases { + let statement = sqlquack_syntax_postgres::parse_strict(test_case.sql) + .unwrap_or_else(|failure| panic!("{}: {failure:?}", test_case.description)) + .statements + .remove(0); + assert_eq!( + output_ranges(&statement, &POSTGRES, &duckdb_shop_schema(), test_case.sql), + test_case + .expected_ranges + .iter() + .cloned() + .map(Some) + .collect::>(), + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-semantics/src/snowflake/profile/tests/test_lineage_details.rs b/crates/sqlquack-semantics/src/snowflake/profile/tests/test_lineage_details.rs index 0e8bbc663..917d75a77 100644 --- a/crates/sqlquack-semantics/src/snowflake/profile/tests/test_lineage_details.rs +++ b/crates/sqlquack-semantics/src/snowflake/profile/tests/test_lineage_details.rs @@ -4,7 +4,9 @@ use crate::dialects::constants::DUCKDB; use crate::dialects::constants::SNOWFLAKE; use crate::lineage::main::query_lineage::query_lineage; use crate::lineage::models::{LineageContext, TransformKind}; +use crate::lineage::tests::helpers::output_ranges; use crate::lineage::tests::helpers::{duckdb_shop_schema, shop_schema, written}; +use crate::lineage::tests::test_types::StrictRangeTestCase; use crate::lineage::tests::test_types::{LineageDetailTestCase, OutputRangeTestCase}; const ALL_ORDER_COLUMNS: [&str; 5] = [ @@ -685,3 +687,42 @@ fn given_a_query_when_tracing_lineage_then_each_output_points_at_the_bytes_that_ ); } } + +#[test] +fn given_a_strict_parse_when_tracing_lineage_then_each_output_has_its_items_byte_range() { + let test_cases = [StrictRangeTestCase { + description: "a literal aliased after a two-byte character, a qualified column, a labelled call, a bare column and a qualified star", + sql: "SELECT 'é' AS e, w.status, UPPER(w.status) loud, id, w.* FROM orders AS w", + expected_ranges: &[ + 7..16, + 18..26, + 28..48, + 50..52, + 54..57, + 54..57, + 54..57, + 54..57, + 54..57, + ], + }]; + for test_case in test_cases { + let statement = sqlquack_parse::parse_strict( + test_case.sql, + Dialect::Snowflake, + &ParseOptions::default(), + ) + .unwrap_or_else(|failure| panic!("{}: {failure:?}", test_case.description)) + .remove(0); + assert_eq!( + output_ranges(&statement, &SNOWFLAKE, &shop_schema(), test_case.sql), + test_case + .expected_ranges + .iter() + .cloned() + .map(Some) + .collect::>(), + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-semantics/src/sqlite/profile/tests/test_lineage_details.rs b/crates/sqlquack-semantics/src/sqlite/profile/tests/test_lineage_details.rs index 9b97571eb..62851062e 100644 --- a/crates/sqlquack-semantics/src/sqlite/profile/tests/test_lineage_details.rs +++ b/crates/sqlquack-semantics/src/sqlite/profile/tests/test_lineage_details.rs @@ -5,7 +5,10 @@ use sqlquack_syntax::{Dialect, ParseOptions}; use crate::dialects::constants::SQLITE; use crate::lineage::main::query_lineage::query_lineage; use crate::lineage::models::{LineageContext, TransformKind}; +use crate::lineage::tests::helpers::duckdb_shop_schema; +use crate::lineage::tests::helpers::output_ranges; use crate::lineage::tests::helpers::{shop_schema, typed_column, written}; +use crate::lineage::tests::test_types::StrictRangeTestCase; use crate::lineage::tests::test_types::{ LineageDetailTestCase, QueryLineageTestCase, TypedShopSchema, }; @@ -175,3 +178,29 @@ fn given_union_branches_when_tracing_sqlite_lineage_then_only_integers_a_real_co ); } } + +#[test] +fn given_a_strict_parse_when_tracing_lineage_then_each_output_has_its_items_byte_range() { + let test_cases = [StrictRangeTestCase { + description: "a literal aliased after a two-byte character, a qualified column, a labelled call and a bare column", + sql: "SELECT 'é' AS e, w.status, UPPER(w.status) loud, id FROM orders AS w", + expected_ranges: &[7..16, 18..26, 28..48, 50..52], + }]; + for test_case in test_cases { + let statement = sqlquack_syntax_sqlite::parse_strict(test_case.sql) + .unwrap_or_else(|failure| panic!("{}: {failure:?}", test_case.description)) + .statements + .remove(0); + assert_eq!( + output_ranges(&statement, &SQLITE, &duckdb_shop_schema(), test_case.sql), + test_case + .expected_ranges + .iter() + .cloned() + .map(Some) + .collect::>(), + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-semantics/src/sqlserver/profile/tests/test_lineage.rs b/crates/sqlquack-semantics/src/sqlserver/profile/tests/test_lineage.rs index eb9666f28..bdeaa39f8 100644 --- a/crates/sqlquack-semantics/src/sqlserver/profile/tests/test_lineage.rs +++ b/crates/sqlquack-semantics/src/sqlserver/profile/tests/test_lineage.rs @@ -1,5 +1,9 @@ +use crate::dialects::constants::SQL_SERVER; +use crate::lineage::tests::helpers::duckdb_shop_schema; +use crate::lineage::tests::helpers::output_ranges; use crate::lineage::tests::helpers::owned_findings; use crate::lineage::tests::test_types::QueryFindingsTestCase; +use crate::lineage::tests::test_types::StrictRangeTestCase; use crate::sqlserver::profile::tests::helpers::sqlserver_findings; use crate::sqlserver::profile::tests::helpers::sqlserver_outputs; use crate::sqlserver::profile::tests::helpers::sqlserver_shop_schema; @@ -245,3 +249,44 @@ fn given_a_sql_server_query_when_typing_then_outputs_take_the_engines_types() { ); } } + +#[test] +fn given_a_strict_parse_when_tracing_lineage_then_each_output_has_its_items_byte_range() { + let test_cases = [StrictRangeTestCase { + description: "a literal aliased after a two-byte character, a qualified column, a labelled call, a bare column and a qualified star", + sql: "SELECT 'é' AS e, w.status, UPPER(w.status) loud, id, w.* FROM orders AS w", + expected_ranges: &[ + 7..16, + 18..26, + 28..48, + 50..52, + 54..57, + 54..57, + 54..57, + 54..57, + 54..57, + ], + }]; + for test_case in test_cases { + let statement = sqlquack_syntax_sqlserver::parse_strict(test_case.sql) + .unwrap_or_else(|failure| panic!("{}: {failure:?}", test_case.description)) + .statements + .remove(0); + assert_eq!( + output_ranges( + &statement, + &SQL_SERVER, + &duckdb_shop_schema(), + test_case.sql + ), + test_case + .expected_ranges + .iter() + .cloned() + .map(Some) + .collect::>(), + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-syntax-clickhouse/src/parse/_helpers/values/expressions.rs b/crates/sqlquack-syntax-clickhouse/src/parse/_helpers/values/expressions.rs index cf5a0d4b7..9707b9bac 100644 --- a/crates/sqlquack-syntax-clickhouse/src/parse/_helpers/values/expressions.rs +++ b/crates/sqlquack-syntax-clickhouse/src/parse/_helpers/values/expressions.rs @@ -74,10 +74,12 @@ impl Parser<'_> { /// `ParserExpressionWithOptionalAlias`: an alias may also be a bare name that is no keyword. pub(crate) fn aliased(&mut self) -> Parsing { + let first: usize = self.at; let this: Expression = self.expression()?; let bare: bool = !matches!(&this, Expression::Column(column) if column.table.is_none() && !column.name.quoted && column.name.name.eq_ignore_ascii_case("FROM")); - self.optional_alias(this, bare) + let node: Expression = self.optional_alias(this, bare)?; + Ok(self.spanned(first, node)) } /// `ParserAlias` after `this`, when one follows. diff --git a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/queries/select.rs b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/queries/select.rs index c5d75ca54..dcee74000 100644 --- a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/queries/select.rs +++ b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/queries/select.rs @@ -63,7 +63,7 @@ impl Parser { Expression::Star(Box::new(star)) }; - expressions.push(self.spanned(item_start, star_expr)); + expressions.push(self.item_spanned(item_start, star_expr)); } else { let leading_comments = self.current_leading_comments().to_vec(); let expr = self.parse_expression()?; @@ -121,7 +121,7 @@ impl Parser { expr }; - expressions.push(self.spanned(item_start, expr)); + expressions.push(self.item_spanned(item_start, expr)); } if !self.match_token(TokenType::Comma) { diff --git a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/support/spans.rs b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/support/spans.rs index 10ddb61e3..0057fbdfe 100644 --- a/crates/sqlquack-syntax-snowflake/src/parser/_helpers/support/spans.rs +++ b/crates/sqlquack-syntax-snowflake/src/parser/_helpers/support/spans.rs @@ -7,8 +7,24 @@ use sqlquack_syntax::traversal::types::AstNode; impl Parser { /// SQLQuack: `expr` with the range from token `start` to the last consumed token, when located. - pub(crate) fn spanned(&self, start: usize, mut expr: Expression) -> Expression { - if !self.config.record_node_spans || self.current <= start { + pub(crate) fn spanned(&self, start: usize, expr: Expression) -> Expression { + if !self.config.record_node_spans { + return expr; + } + self.filled_span(start, expr) + } + + /// SQLQuack: a select item with its range when the parse records item ranges, which lineage reports. + pub(crate) fn item_spanned(&self, start: usize, expr: Expression) -> Expression { + if !self.config.record_item_spans { + return expr; + } + self.filled_span(start, expr) + } + + /// SQLQuack: `expr` with the range from token `start` to the last consumed token. + fn filled_span(&self, start: usize, mut expr: Expression) -> Expression { + if self.current <= start { return expr; } if let (Some(first), Some(last)) = diff --git a/crates/sqlquack-syntax-snowflake/src/parser/models.rs b/crates/sqlquack-syntax-snowflake/src/parser/models.rs index 52dba2b2b..534c1c8c1 100644 --- a/crates/sqlquack-syntax-snowflake/src/parser/models.rs +++ b/crates/sqlquack-syntax-snowflake/src/parser/models.rs @@ -136,6 +136,7 @@ impl Dialect { enforce_input(sql, &complexity_guard)?; let source: Arc = Arc::from(sql); let (tokens, token_guard_stats) = self.tokenizer.tokenize_for_parser(&source)?; + let strict: bool = significant.is_some(); if let Some(significant) = significant { significant.extend(tokens.iter().map(|token| (token.span, token.token_type))); } @@ -143,6 +144,7 @@ impl Dialect { dialect: Some(self.dialect_type), complexity_guard, record_node_spans: mode == CallMode::FaithfulLocated, + record_item_spans: mode == CallMode::FaithfulLocated || strict, ..Default::default() }; let mut parser = Parser::with_parser_tokens(tokens, token_guard_stats, config, source); @@ -449,6 +451,8 @@ pub(crate) struct ParserConfig { pub complexity_guard: ComplexityGuardOptions, /// SQLQuack: record each expression and statement node's source range (for locations). pub record_node_spans: bool, + /// SQLQuack: record each select item's source range, which lineage reports; a located or strict parse does. + pub record_item_spans: bool, /// Whether a Snowflake CREATE SEMANTIC VIEW is being checked, where a window may PARTITION BY EXCLUDING. pub(crate) in_semantic_view: bool, } diff --git a/crates/sqlquack-syntax-sqlserver/src/parse/_helpers/statements/queries.rs b/crates/sqlquack-syntax-sqlserver/src/parse/_helpers/statements/queries.rs index f2897b841..c264ccb4b 100644 --- a/crates/sqlquack-syntax-sqlserver/src/parse/_helpers/statements/queries.rs +++ b/crates/sqlquack-syntax-sqlserver/src/parse/_helpers/statements/queries.rs @@ -13,6 +13,7 @@ use sqlquack_syntax::expressions::statements::models::Version; use sqlquack_syntax::expressions::tables::models::{IndexTableHint, WithTableHint}; use sqlquack_syntax::expressions::types::models::Tuple; use sqlquack_syntax::tokens::models::{SourceSpan, Span}; +use sqlquack_syntax::traversal::types::AstNode; use sqlquack_syntax::{DataType, Expression}; use crate::parse::_helpers::tree::cursor::{Parser, Parsing, identifier, is_any_name, is_name}; @@ -368,8 +369,18 @@ impl<'sql> Parser<'sql> { }) } - /// One select item: `*`, `t.*`, `expr [[AS] alias]`, `alias = expr` or `@v = expr`. + /// One select item, with its range: lineage reports it as its output's range. fn select_item(&mut self) -> Parsing { + let first: usize = self.at; + let mut item: Expression = self.unranged_select_item()?; + if let SourceSpan(Some(range)) = self.range_from(first) { + item.fill_span(range); + } + Ok(item) + } + + /// One select item: `*`, `t.*`, `expr [[AS] alias]`, `alias = expr` or `@v = expr`. + fn unranged_select_item(&mut self) -> Parsing { if self.eat_char('*') { return Ok(Expression::Star(Box::new(empty_star(None)))); }