Skip to content
Merged
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
2 changes: 1 addition & 1 deletion crates/sqlquack-core/src/diagnostics/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ pub const CODES: &[CodeEntry] = &[
help: Some(
"the help names the divergence and the fixes that work in the current mode: allow \
it in the policy file, drop --strict or --loud-divergences-only, or confirm its \
assumption; the built-in policy refuses every one",
assumption; the built-in policy refuses every one but the clock outside strict mode",
),
explanation: "The construct at the span converts only by relying on the known \
divergence the label names, where DuckDB may raise or differ from Snowflake as the \
Expand Down
2 changes: 1 addition & 1 deletion crates/sqlquack-rules/src/catalogue/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ pub const RULES: &[RuleSpec] = &[
RuleSpec {
id: FROM_RELATION,
constructs: &["node:table"],
conditions: "The relation is a CTE or has typed columns in the schema. A schema relation may be declared as TABLE, SCHEMA.TABLE or DATABASE.SCHEMA.TABLE, and a name reads the declared relation whose parts agree with the parts it writes, the longest agreement winning; it is written as the declared name. A name matching several declared relations equally, a qualified name a CTE of its declared name would capture, and two differently written names reading one declared relation are refused.",
conditions: "The relation is a CTE or has typed columns in the schema. A schema relation may be declared as TABLE, SCHEMA.TABLE or DATABASE.SCHEMA.TABLE, and a name reads the declared relation whose parts agree with the parts it writes, the longest agreement winning; it is written as the declared name. A name matching several declared relations equally, a qualified name a CTE of its declared name would capture, and two differently written names reading one declared relation are refused. A base table's VARIANT, OBJECT or ARRAY column the statement may read, by name, through a star other than COUNT(*)'s, a positional column, PIVOT, UNPIVOT or a NATURAL join, is guarded as PARSE_JSON's result is and relies on json_fidelity; a column it never reads is neither guarded nor relied on.",
failures: &[],
},
RuleSpec {
Expand Down
62 changes: 60 additions & 2 deletions crates/sqlquack-rules/src/conversion/_helpers/queries/sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ use crate::conversion::_helpers::support::collecting::{
use crate::conversion::_helpers::support::scope::{Context, Scope};
use crate::conversion::_helpers::support::site::{Placement, Site};
use crate::conversion::_helpers::support::tree::{
Payload, field, function_construct, identifier_name, integer_literal, list, node_construct,
node_kind, payload_object, require_understood,
Payload, field, function_construct, identifier_name, integer_literal, is_inert, list,
node_construct, node_kind, payload_object, require_understood,
};
use crate::conversion::constants::{
ASOF_JOIN, ASOF_KIND, CORRELATED_DERIVED, FROM_RELATION, FROM_SUBQUERY, GENERATOR_MAX_ROWS,
Expand All @@ -35,6 +35,9 @@ use crate::conversion::constants::{
TABLE_WRAPPER, UNALIASED_SUBQUERY_PREFIX, USING_ALIAS_PREFIX,
};
use crate::conversion::constants::{FULL_JOIN_KEYWORD, INNER_JOIN_KEYWORD};

/// How the parsed tree spells a NATURAL join's kind, lower-cased.
const NATURAL_JOIN_PREFIX: &str = "natural";
use sqlquack_semantics::lineage::main::stored_columns::stored_columns;
use sqlquack_semantics::names::constants::RELATION_KEY_SEPARATOR;
use sqlquack_semantics::names::main::relation_key::relation_key;
Expand Down Expand Up @@ -598,6 +601,7 @@ fn table_source(
for column in columns
.iter()
.filter(|column| !is_cte && column.data_type == SnowflakeType::SemiStructured)
.filter(|column| may_read(context.locator.tree(), &column.name))
{
let quoted = quoted_identifier(&column.name, log);
guarded.push(format!("{} AS {quoted}", guarded_column(&quoted, context)?));
Expand Down Expand Up @@ -886,3 +890,57 @@ fn reads_enclosing(query: &Value, outer: &Scope<'_>, context: &Context<'_>) -> b
Err(refusal) => refusal.reason == CORRELATED_DERIVED,
}
}

/// Whether `tree` names column `name`, or reads columns unnamed: `*`, `$n`, PIVOT or NATURAL.
fn may_read(tree: &Value, name: &str) -> bool {
let plain = !name.is_empty()
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'$');
!plain || reads_unnamed_or(tree, name)
}

/// The walk behind `may_read`.
fn reads_unnamed_or(tree: &Value, name: &str) -> bool {
match tree {
Value::String(text) => {
text.eq_ignore_ascii_case(name)
|| text.to_ascii_lowercase().starts_with(NATURAL_JOIN_PREFIX)
}
Value::Array(items) => items.iter().any(|item| reads_unnamed_or(item, name)),
Value::Object(fields) => fields.iter().any(|(key, value)| match key.as_str() {
"star" | "parameter" | "pivot" | "unpivot" | "columns" => true,
"count" | "aggregate_function" if counts_rows(value) => false,
_ => reads_unnamed_or(value, name),
}),
_ => false,
}
}

/// Whether a COUNT payload is COUNT(*) alone, which reads no column.
fn counts_rows(payload: &Value) -> bool {
let Some(fields) = payload.as_object() else {
return false;
};
let star =
|value: &Value| matches!(node_kind(value), Some(("star", star)) if is_inert_star(star));
let named_count = fields
.get("name")
.and_then(Value::as_str)
.is_none_or(|called| called.eq_ignore_ascii_case("COUNT"));
named_count
&& fields.iter().all(|(key, value)| match key.as_str() {
"this" => star(value),
"args" => value
.as_array()
.is_some_and(|items| items.len() == 1 && star(&items[0])),
"name" => true,
_ => is_inert(value),
})
}

/// A bare `*`, without a qualifier, ILIKE, EXCLUDE, REPLACE or RENAME.
fn is_inert_star(star: &Value) -> bool {
star.as_object()
.is_none_or(|fields| fields.values().all(is_inert))
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ impl<'a> Locator<'a> {
}
}

/// The statement tree being converted, positions stripped.
pub(crate) fn tree(&self) -> &'a Value {
self.tree
}

/// `sql`, converted from `node`, with its unidentified raises identified and located at `node`.
pub(crate) fn identified(&self, sql: &str, node: &Value) -> String {
let (sql, ids) = identify_raises(sql);
Expand Down
14 changes: 14 additions & 0 deletions crates/sqlquack-rules/src/session/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,17 @@

/// The profile name reported when the built-in default stands in for a policy.
pub const BUILTIN_PROFILE: &str = "the built-in default";

/// The clock functions the built-in default converts with a warning outside strict mode.
pub const BUILTIN_CLOCK_CONSTRUCTS: [&str; 2] = ["fn:CURRENT_DATE", "fn:CURRENT_TIMESTAMP"];

/// Why the built-in default converts the clock functions, printed with the warning.
pub const BUILTIN_CLOCK_REASON: &str = "CURRENT_DATE() and CURRENT_TIMESTAMP() take their value \
when the query runs, in DuckDB as in Snowflake, so the value differs between runs; the \
converted SQL reads DuckDB's clock in the DuckDB session's time zone, which must match \
Snowflake's TIMEZONE (UTC) for the date and time to agree. --strict refuses them";

/// The session time zone the clock functions rest on under the built-in default.
pub const BUILTIN_CLOCK_ZONE: &str = "CURRENT_DATE() and CURRENT_TIMESTAMP() give the date and \
time in the session's time zone, so they agree only where Snowflake's TIMEZONE and DuckDB's \
TimeZone are both UTC";
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//! `builtin_conversion_options`: the built-in default's options, which allow only the clock.

use crate::conversion::models::{AllowedDivergence, ConversionOptions};
use crate::session::constants::{
BUILTIN_CLOCK_CONSTRUCTS, BUILTIN_CLOCK_REASON, BUILTIN_CLOCK_ZONE,
};
use sqlquack_policy::policy::constants::{CURRENT_TIME, SESSION_TIMEZONE_UTC};

/// The built-in default's options: the clock converts with a warning unless `refuse_clock`.
pub fn builtin_conversion_options(refuse_clock: bool) -> ConversionOptions {
let mut options = ConversionOptions::default();
if refuse_clock {
return options;
}
let constructs: Vec<String> = BUILTIN_CLOCK_CONSTRUCTS
.iter()
.map(|construct| (*construct).to_owned())
.collect();
for (divergence, reason, assumption) in [
(CURRENT_TIME, BUILTIN_CLOCK_REASON, false),
(SESSION_TIMEZONE_UTC, BUILTIN_CLOCK_ZONE, true),
] {
options.divergences.insert(
divergence.to_owned(),
AllowedDivergence {
constructs: constructs.clone(),
reason: reason.to_owned(),
assumption,
},
);
}
options
}
1 change: 1 addition & 0 deletions crates/sqlquack-rules/src/session/main/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod builtin_conversion_options;
pub mod confirm_utc_session;
pub mod conversion_options;
pub mod parse_session;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use sqlquack_policy::policy::models::{Policy, SessionValue};

use crate::session::_helpers::session_profiles::{matching_profile, value_text};
use crate::session::constants::BUILTIN_PROFILE;
use crate::session::main::builtin_conversion_options::builtin_conversion_options;
use crate::session::main::confirm_utc_session::confirm_utc_session;
use crate::session::main::conversion_options::conversion_options;

Expand All @@ -28,10 +29,19 @@ pub fn session_conversion_options(
.filter(|name| !builtin_accepts(name))
.collect();
return match unknown.is_empty() {
true => Ok((
with_namespace(ConversionOptions::default(), parameters),
BUILTIN_PROFILE.to_owned(),
)),
true => {
let mut options = builtin_conversion_options(strict);
if parameters
.get(TIMEZONE_PARAMETER)
.is_some_and(|zone| zone != SESSION_TIME_ZONE)
{
options.divergences.remove(SESSION_TIMEZONE_UTC);
}
Ok((
with_namespace(options, parameters),
BUILTIN_PROFILE.to_owned(),
))
}
false => Err(format!(
"the built-in default records no session profiles, so it takes only TIMEZONE, \
DATABASE, SCHEMA and the result-neutral parameters, not {}; pass --policy with a policy file whose \
Expand Down
26 changes: 22 additions & 4 deletions crates/sqlquack/src/cli/_helpers/convert_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,10 @@ pub(crate) fn chosen_options(
) -> Result<ConversionOptions, String> {
let options = match policy {
Some(policy) => caller_choices(options, policy, &arguments.conversion),
None if arguments.conversion.loud_divergences_only => ConversionOptions {
divergences: Default::default(),
..options
},
None => options,
};
with_overrides(
Expand All @@ -266,10 +270,15 @@ pub(crate) fn analysis_report(
let assumptions: Vec<String> = provenance
.divergences
.keys()
.filter(|id| {
policy
.and_then(|policy| policy.divergences.get(assumption_id(id)))
.is_some_and(|divergence| divergence.assumption)
.filter(|id| match policy {
Some(policy) => policy
.divergences
.get(assumption_id(id))
.is_some_and(|divergence| divergence.assumption),
None => options
.divergences
.get(id.as_str())
.is_some_and(|divergence| divergence.assumption),
})
.cloned()
.collect();
Expand All @@ -284,6 +293,15 @@ pub(crate) fn analysis_report(
)
})
.into_iter()
.chain(
provenance
.divergences
.keys()
.filter(|_| policy.is_none())
.filter_map(|id| options.divergences.get(id.as_str()))
.filter(|divergence| !divergence.assumption)
.map(|divergence| divergence.reason.clone()),
)
.collect();
Ok(ConvertReport {
converted,
Expand Down
25 changes: 18 additions & 7 deletions crates/sqlquack/src/cli/_helpers/evidence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ use crate::analysis::main::assumption_id::assumption_id;
use crate::analysis::main::confirm_assumption::confirm_assumption;
use crate::analysis::main::refuse_value_divergences::refuse_value_divergences;
use crate::cli::models::{EvidencePaths, PolicyArguments};
use sqlquack_policy::policy::constants::{DECLARED_OFFSET, PROJECT_UDF, SESSION_TIMEZONE_UTC};
use sqlquack_policy::policy::constants::{
CURRENT_TIME, DECLARED_OFFSET, PROJECT_UDF, SESSION_TIMEZONE_UTC,
};
use sqlquack_rules::session::main::builtin_conversion_options::builtin_conversion_options;
use sqlquack_rules::session::main::confirm_utc_session::confirm_utc_session;
use sqlquack_rules::session::main::conversion_options::conversion_options;

Expand All @@ -44,7 +47,7 @@ pub(crate) fn chosen_policy(path: Option<&Path>, builtin: bool) -> Result<Option
if !path.is_file() {
return Err(format!(
"no policy file at {}; pass --policy with an existing file, or leave --policy out to \
use the built-in policy, which refuses every known divergence",
use the built-in policy, which refuses every known divergence but the clock",
path.display()
));
}
Expand All @@ -59,7 +62,7 @@ pub(crate) fn divergence_options(
arguments: &PolicyArguments,
) -> ConversionOptions {
let Some(policy) = policy else {
return ConversionOptions::default();
return builtin_conversion_options(arguments.strict || arguments.loud_divergences_only);
};
let options = conversion_options(policy, arguments.strict);
let options = match arguments.utc_session {
Expand Down Expand Up @@ -126,10 +129,18 @@ fn divergence_help(
(policy, arguments, parameters): (Option<&Policy>, &PolicyArguments, &BTreeMap<String, String>),
) -> String {
let Some(policy) = policy else {
return format!(
"the built-in policy refuses every known divergence; pass --policy with a policy file \
that allows `{id}`, such as the repository's sqlquack.toml"
);
return match [CURRENT_TIME, SESSION_TIMEZONE_UTC].contains(&id) {
true => format!(
"the built-in policy allows `{id}` only for CURRENT_DATE() and \
CURRENT_TIMESTAMP() in a UTC session, without --strict or \
--loud-divergences-only; drop those flags, or pass --policy with a policy file \
that allows `{id}` here, such as the repository's sqlquack.toml"
),
false => format!(
"the built-in policy refuses every known divergence but the clock; pass --policy \
with a policy file that allows `{id}`, such as the repository's sqlquack.toml"
),
};
};
let file = arguments
.policy
Expand Down
2 changes: 1 addition & 1 deletion crates/sqlquack/src/cli/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ pub struct PolicyArguments {
/// The policy file; by default `sqlquack.toml` here, or the built-in policy without one.
#[arg(long)]
pub policy: Option<PathBuf>,
/// Use the built-in policy even where a policy file exists: every known divergence is refused.
/// Use the built-in policy even where a policy file exists: it refuses all but the clock.
#[arg(long, conflicts_with = "policy")]
pub builtin_policy: bool,
/// Refuse every known divergence the policy refuses in strict mode.
Expand Down
2 changes: 2 additions & 0 deletions crates/sqlquack/tests/cli.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#[path = "cli/helpers.rs"]
mod helpers;
#[path = "cli/test_builtin_policy.rs"]
mod test_builtin_policy;
#[path = "cli/test_check.rs"]
mod test_check;
#[path = "cli/test_compare.rs"]
Expand Down
10 changes: 10 additions & 0 deletions crates/sqlquack/tests/cli/fixtures/semi_structured_tickets.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"relations": {
"TICKETS": [
["TICKET_ID", "NUMBER(38,0)"],
["SUBJECT", "VARCHAR"],
["TAGS", "ARRAY"],
["DETAILS", "VARIANT"]
]
}
}
7 changes: 6 additions & 1 deletion crates/sqlquack/tests/cli/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ fn converted_value_with_macros(report: &serde_json::Value) -> String {
.expect("the converted statement returns a text value")
}

/// The exit code of `arguments` beside a copy of the repository's `policy_file`, over two SQL files.
/// The exit code of `arguments` beside a copy of the repository's `policy_file`, over three SQL files.
pub(crate) fn policy_free_exit_code(arguments: &[&str], policy_file: Option<&str>) -> Option<i32> {
let directory = tempfile::tempdir().expect("temporary directory");
std::fs::write(directory.path().join("query.sql"), "SELECT 1 AS total").expect("query written");
Expand All @@ -226,6 +226,11 @@ pub(crate) fn policy_free_exit_code(arguments: &[&str], policy_file: Option<&str
"SELECT CURRENT_TIMESTAMP() AS t",
)
.expect("query written");
std::fs::write(
directory.path().join("json.sql"),
"SELECT PARSE_JSON('1') AS j",
)
.expect("query written");
policy_file.iter().for_each(|path| {
std::fs::copy(
repository_root().join(path),
Expand Down
Loading
Loading