Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ use sqlquack_syntax::Parsed;
use sqlquack_syntax_clickhouse::parse_strict;

use crate::analysis_route::_helpers::clickhouse_spelling::clickhouse_spelling;
use crate::analysis_route::constants::{
CLICKHOUSE_DIALECT, CLICKHOUSE_STUB_PREFIX, STATEMENTS_CONSTRUCT,
};
use crate::analysis_route::constants::{CLICKHOUSE_DIALECT, CLICKHOUSE_STUB_PREFIX};
use crate::analysis_route::main::clickhouse_type_match::clickhouse_type_match;
use crate::analysis_route::models::{ClickHouseDialect, ParserGap, TypeMatch};
use crate::analysis_route::types::NativeDialect;
Expand All @@ -20,19 +18,11 @@ impl NativeDialect for ClickHouseDialect {
CLICKHOUSE_DIALECT
}

fn read_whole(&self, sql: &str) -> Result<(), ParserGap> {
let parsed: Parsed = self.parse(sql)?;
match (parsed.gaps.first(), parsed.statements.len()) {
(Some(gap), _) => Err(ParserGap {
construct: gap.construct.to_owned(),
offset: gap.start,
}),
(None, 1) => Ok(()),
(None, _) => Err(ParserGap {
construct: STATEMENTS_CONSTRUCT.to_owned(),
offset: sql.len(),
}),
}
fn parse(&self, sql: &str) -> Result<Parsed, ParserGap> {
parse_strict(sql).map_err(|error| ParserGap {
construct: error.message,
offset: error.position,
})
}

fn stub_prefix(&self) -> &'static str {
Expand All @@ -41,14 +31,6 @@ impl NativeDialect for ClickHouseDialect {
}

impl ClickHouseDialect {
/// Every statement of `sql` with the parser's named gaps, or the first error.
pub fn parse(&self, sql: &str) -> Result<Parsed, ParserGap> {
parse_strict(sql).map_err(|error| ParserGap {
construct: error.message,
offset: error.position,
})
}

/// The ClickHouse profile: names compared with case, types as ClickHouse 26.1 reports them.
#[must_use]
pub fn profile(&self) -> &'static dyn DialectProfile {
Expand Down
2 changes: 2 additions & 0 deletions crates/sqlbuild-analysis/src/analysis_route/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ pub(crate) const CLICKHOUSE_DIALECT: &str = "clickhouse";
pub(crate) const CLICKHOUSE_STUB_PREFIX: &str = "__sqlbuild_";
/// The construct of SQL holding more than one statement.
pub(crate) const STATEMENTS_CONSTRUCT: &str = "more than one statement";
/// The construct of a statement a parser kept as unparsed text.
pub(crate) const UNPARSED_STATEMENT_CONSTRUCT: &str = "unparsed statement";
/// Each SQL type alias whose name Polyglot respells for ClickHouse, case-insensitive, with the
/// spelling SQLBuild's type fallback reads as Polyglot's canonical name (`{}` keeps the arguments)
/// and, where the fallback's family differs from Polyglot's, Polyglot's family at the top level.
Expand Down
2 changes: 2 additions & 0 deletions crates/sqlbuild-analysis/src/analysis_route/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ mod test_clickhouse_dialect;
mod test_clickhouse_type_match;
#[path = "tests/test_native_gap.rs"]
mod test_native_gap;
#[path = "tests/test_planner_routes.rs"]
mod test_planner_routes;
#[path = "tests/test_types.rs"]
mod test_types;
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
use std::collections::BTreeMap;

use crate::diff_selection::main::raw_query_shape::raw_query_shape;
use crate::planner_fixtures::main::star_keys::star_keys;
use crate::planner_fixtures::models::{FixtureColumn, FixtureStarRelation};
use crate::planner_migrations::main::migration_fingerprint::migration_fingerprint;
use crate::planner_migrations::main::query_shape::query_shape;
use crate::relation_names::main::extract_relation_names::extract_relation_names;
use crate::relation_names::models::RelationName;

/// A ClickHouse query the native parser reads, and one it stops at (a named gap).
const READABLE: &str = "SELECT o.id, o.amount AS total FROM orders AS o";
const UNREADABLE: &str = "SELECT o.id FROM orders AS o INTO OUTFILE 'x.csv'";

#[test]
fn given_clickhouse_sql_when_proving_a_column_migration_then_only_sql_read_whole_has_a_shape() {
let shape = query_shape(READABLE, "clickhouse")
.expect("no internal failure")
.expect("readable SQL has a shape");
let names: Vec<&str> = shape.projections.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["id", "total"]);
assert!(
query_shape(UNREADABLE, "clickhouse")
.expect("no internal failure")
.is_none()
);
}

#[test]
fn given_clickhouse_sql_when_fingerprinting_a_migration_then_formatting_is_ignored_and_gaps_have_none()
{
let identities: BTreeMap<String, String> = BTreeMap::new();
let fingerprint = |sql: &str| migration_fingerprint(sql, "", &identities, "clickhouse");
let base = fingerprint(READABLE).expect("no internal failure");
assert!(base.is_some());
assert_eq!(
fingerprint("SELECT o.id,\n o.amount AS total -- note\nFROM orders AS o").expect("ok"),
base
);
assert_ne!(
fingerprint("SELECT o.id, o.amount AS sum FROM orders AS o").expect("ok"),
base
);
assert_eq!(fingerprint(UNREADABLE).expect("ok"), None);
}

#[test]
fn given_a_clickhouse_diff_query_when_reading_its_shape_then_unreadable_sql_is_refused() {
let shape = raw_query_shape(READABLE, "clickhouse").expect("readable");
assert_eq!(shape.root_key, "select");
assert!(!shape.writes_or_locks);
assert!(raw_query_shape(UNREADABLE, "clickhouse").is_err());
}

#[test]
fn given_clickhouse_sql_when_extracting_relation_names_then_ctes_are_skipped_and_gaps_have_none() {
let extracted = extract_relation_names(
"WITH c AS (SELECT 1 AS id) SELECT * FROM shop.orders AS o JOIN c ON o.id = c.id",
Some("clickhouse"),
)
.expect("no failure")
.expect("readable");
assert_eq!(
extracted.relations,
[RelationName {
name: "orders".to_owned(),
schema: Some("shop".to_owned()),
database: None,
}]
);
assert_eq!(
extract_relation_names(UNREADABLE, Some("clickhouse")).expect("no failure"),
None
);
}

#[test]
fn given_a_clickhouse_star_when_selecting_fixture_dependencies_then_unsettled_stars_take_every_relation()
{
let relation = |name: &str| FixtureStarRelation {
key: ("model".to_owned(), name.to_owned()),
columns: vec![FixtureColumn {
name: "id".to_owned(),
folded_name: "id".to_owned(),
sql_type: Some("Int32".to_owned()),
nullable: Some(false),
}],
authoritative: true,
};
let relations = [relation("orders"), relation("customers")];
let keys = |sql: &str| star_keys(sql, &relations, Some("clickhouse"), None);
let orders = vec![("model".to_owned(), "orders".to_owned())];
let every: Vec<(String, String)> = relations.iter().map(|r| r.key.clone()).collect();
let joined = "FROM orders AS o JOIN customers AS c ON o.id = c.id";
assert_eq!(keys(&format!("SELECT o.* {joined}")), orders);
assert_eq!(keys(&format!("SELECT count(*) AS n {joined}")), Vec::new());
assert_eq!(keys(&format!("SELECT * {joined}")), every);
assert_eq!(keys(&format!("SELECT zz.* {joined}")), every);
assert_eq!(
keys(&format!("SELECT o.* {joined} INTO OUTFILE 'x.csv'")),
every
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use crate::analysis_route::models::{LocatedGap, ParserGap, TypeMatch};
use crate::analysis_route::types::NativeDialect;
use sqlquack_syntax::expressions::names::models::Null;
use sqlquack_syntax::{Expression, Parsed};

pub(super) struct NativeGapTestCase {
pub(super) description: &'static str,
Expand Down Expand Up @@ -31,7 +33,7 @@ impl NativeDialect for PivotlessDialect {
"postgres"
}

fn read_whole(&self, sql: &str) -> Result<(), ParserGap> {
fn parse(&self, sql: &str) -> Result<Parsed, ParserGap> {
let refused = ["PIVOT", "@@@", "__sqlbuild_udf_", "__cursor_", "@'"]
.iter()
.filter_map(|word| sql.find(word).map(|offset| (offset, *word)))
Expand All @@ -41,7 +43,10 @@ impl NativeDialect for PivotlessDialect {
construct: word.to_owned(),
offset,
}),
None => Ok(()),
None => Ok(Parsed {
statements: vec![Expression::Null(Null)],
gaps: Vec::new(),
}),
}
}

Expand Down
29 changes: 27 additions & 2 deletions crates/sqlbuild-analysis/src/analysis_route/types.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,39 @@
//! What a dialect's native analysis module provides.

use sqlquack_syntax::{Expression, Parsed};

use crate::analysis_route::constants::{STATEMENTS_CONSTRUCT, UNPARSED_STATEMENT_CONSTRUCT};
use crate::analysis_route::models::ParserGap;

/// A dialect whose SQL SQLQuack analyses in place of Polyglot.
pub trait NativeDialect: Sync {
/// The adapter's analysis dialect name, such as `postgres`.
fn name(&self) -> &'static str;

/// Whether the native parser reads `sql` whole: one statement, no gap and no unparsed bytes.
fn read_whole(&self, sql: &str) -> Result<(), ParserGap>;
/// Every statement of `sql` with the parser's named gaps, or the first error.
fn parse(&self, sql: &str) -> Result<Parsed, ParserGap>;

/// The one statement of `sql`, with no gap and not a lone unparsed command.
fn read_whole(&self, sql: &str) -> Result<Expression, ParserGap> {
let mut parsed: Parsed = self.parse(sql)?;
if let Some(gap) = parsed.gaps.first() {
return Err(ParserGap {
construct: gap.construct.to_owned(),
offset: gap.start,
});
}
let unread = |construct: &str| ParserGap {
construct: construct.to_owned(),
offset: sql.len(),
};
if parsed.statements.len() != 1 {
return Err(unread(STATEMENTS_CONSTRUCT));
}
match parsed.statements.remove(0) {
Expression::Command(_) => Err(unread(UNPARSED_STATEMENT_CONSTRUCT)),
statement => Ok(statement),
}
}

/// The engine-accepted prefix of UDF and cursor stubs: `__sqlbuild_`, or `__sqlbuild.` for T-SQL.
fn stub_prefix(&self) -> &'static str;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,54 @@
//! Parse one raw diff query as the Polyglot wheel's guarded `parse_one` does and read its shape.
//! Parse one raw diff query with the dialect's parser and read its shape: SQLQuack where native,
//! else as the Polyglot wheel's guarded `parse_one` does.

use polyglot_sql::dialects::Dialect;
use polyglot_sql::traversal::ExpressionWalk;
use polyglot_sql::{ComplexityGuardOptions, Expression, ParseOptions};
use sqlquack_syntax::traversal::types::ExpressionWalk as NativeWalk;

use crate::analysis_route::main::analysis_route::analysis_route;
use crate::analysis_route::models::AnalysisRoute;
use crate::diff_selection::constants::{RAW_QUERY_GUARD, RAW_QUERY_READ_ONLY_ROOTS};
use crate::diff_selection::models::RawQueryShape;

/// The query's shape, or the text of the exception the wheel raises for it.
/// The query's shape, or why it is not one statement the dialect's parser reads whole.
pub fn raw_query_shape(sql: &str, dialect: &str) -> Result<RawQueryShape, String> {
match analysis_route(Some(dialect)) {
AnalysisRoute::Native(native) => {
let root: sqlquack_syntax::Expression = native
.read_whole(sql)
.map_err(|gap| format!("the {dialect} parser cannot read {}", gap.construct))?;
native_shape(&root)
}
AnalysisRoute::Legacy => polyglot_shape(sql, dialect),
}
}

/// The shape of a statement SQLQuack read whole.
fn native_shape(root: &sqlquack_syntax::Expression) -> Result<RawQueryShape, String> {
let root_key: String = root.variant_name().to_lowercase();
let node_keys: Vec<String> = if RAW_QUERY_READ_ONLY_ROOTS.contains(&root_key.as_str()) {
NativeWalk::dfs(root)
.map(|node| node.variant_name().to_lowercase())
.collect()
} else {
Vec::new()
};
let writes_or_locks: bool = match root {
sqlquack_syntax::Expression::Select(select) => {
select.into.is_some() || !select.locks.is_empty()
}
_ => false,
};
Ok(RawQueryShape {
root_key,
node_keys,
writes_or_locks,
})
}

/// The shape Polyglot reads, or the text of the exception the wheel raises.
fn polyglot_shape(sql: &str, dialect: &str) -> Result<RawQueryShape, String> {
let parser: Dialect =
Dialect::get_by_name(dialect).ok_or_else(|| format!("Unknown dialect: {dialect}"))?;
let guard: ComplexityGuardOptions =
Expand Down
71 changes: 71 additions & 0 deletions crates/sqlbuild-analysis/src/planner_fixtures/main/star_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ use polyglot_sql::{
AnalyzeQueryOptions, DialectType, SchemaColumn, SchemaTable, ValidationSchema, analyze_query,
};
use sqlbuild_core::text::main::python_casefold::python_casefold;
use sqlquack_syntax::Expression as NativeExpression;
use sqlquack_syntax::traversal::types::ExpressionWalk as NativeWalk;

use crate::analysis_route::main::analysis_route::analysis_route;
use crate::analysis_route::models::AnalysisRoute;
use crate::analysis_route::types::NativeDialect;
use crate::planner_fixtures::_helpers::stub_markers::stub_markers;
use crate::planner_fixtures::models::FixtureStarRelation;

Expand All @@ -32,6 +37,9 @@ pub fn star_keys(
{
return Vec::new();
}
if let AnalysisRoute::Native(native) = analysis_route(dialect) {
return native_star_keys(native, sql, relations);
}
let Ok(dialect) =
DialectType::from_str(dialect.filter(|name| !name.is_empty()).unwrap_or("generic"))
else {
Expand Down Expand Up @@ -123,3 +131,66 @@ fn column_type(sql_type: Option<&str>) -> String {
.unwrap_or("UNKNOWN")
.to_owned()
}

/// Star dependencies from SQLQuack's tree, a superset of what each star reads: a bare star selects
/// every relation in the query, and a star whose qualifier the tree does not settle, or SQL the
/// parser cannot read whole, selects every relation given.
fn native_star_keys(
native: &dyn NativeDialect,
sql: &str,
relations: &[FixtureStarRelation],
) -> Vec<(String, String)> {
let every = || {
relations
.iter()
.map(|relation| relation.key.clone())
.collect()
};
let Ok(sql) = stub_markers(sql) else {
return every();
};
let Ok(root) = native.read_whole(&sql) else {
return every();
};
let mut aliases: HashMap<String, String> = HashMap::new();
let mut stars: Vec<Option<String>> = Vec::new();
for node in NativeWalk::dfs(&root) {
match node {
NativeExpression::Table(table) => {
let name: String = python_casefold(&table.name.name);
aliases.insert(name.clone(), name.clone());
if let Some(alias) = &table.alias {
aliases.insert(python_casefold(&alias.name), name);
}
}
NativeExpression::Select(select) => {
stars.extend(select.expressions.iter().filter_map(|projection| {
match projection {
NativeExpression::Star(star) => Some(
star.table
.as_ref()
.map(|table| python_casefold(&table.name)),
),
_ => None,
}
}));
}
_ => {}
}
}
let mut names: HashSet<String> = HashSet::new();
for star in &stars {
match star.as_ref().map(|qualifier| aliases.get(qualifier)) {
Some(Some(name)) => {
names.insert(name.clone());
}
None => names.extend(aliases.values().cloned()),
Some(None) => return every(),
}
}
relations
.iter()
.filter(|relation| names.contains(&python_casefold(&relation.key.1)))
.map(|relation| relation.key.clone())
.collect()
}
Loading
Loading