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
43 changes: 40 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ rayon = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# The Python module; abi3, so one build loads into every CPython from 3.12.
saphyr-parser = "0.1"
pyo3 = { version = "0.30.1", features = ["abi3-py312"] }
sha2 = "0.10"
sqlquack = { path = "crates/sqlquack" }
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ They share the policy flags (`--policy`, `--builtin-policy`, `--strict`, `--utc-
on divergences whose converted SQL raises rather than returns another value). Every command with a report takes
`--format text|json`; `--json` is short for `--format json`.

The folder holding the policy file is a project, laid out as a SQLBuild project. `convert` and
`census` read the tables in `sources/*.yml` (SQLBuild's source format), the functions in
`functions/sql/*.sql` (a `FUNCTION ( ... );` header, then the body) and `functions/python/*.py` (a
`@udf(...)` decorator imported from `sqlbuild.functions` or `sqlquack`), and the
overrides in `overrides/`, so a SQLBuild project's folders work unchanged. `sqlquack.toml` holds
`[project.session]` (as `--session`) and `[project.confirm]` with `python_runtime`, `utc_session`
and `declared_offsets` (as the confirmation flags), and folder paths under `[project]` only where
they differ from the defaults. Flags apply on top for one run, `--schema` adds to the project's
declarations, and a name declared twice is an error. The Python API takes the folder as
`ConvertOptions(project=...)`.

HASH is refused by default: the converted SQL computes DuckDB's hash, which is consistent within one
run but never Snowflake's value. A project that relies on HASH only for joins, grouping,
deduplication or change detection can opt in. A policy file is complete on its own, so start from
Expand Down
17 changes: 3 additions & 14 deletions crates/sqlquack-evidence/src/verify/_helpers/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ pub(crate) fn session_check(
return SessionCheck::Mismatch(format!(
"session parameter {key} was recorded as {}, but the profile sets {}",
actual.as_deref().unwrap_or("nothing"),
parameter_text(value)
value.text()
));
}
}
Expand All @@ -51,25 +51,14 @@ pub(crate) fn session_check(
return SessionCheck::Mismatch(format!(
"recorded before {key} was pinned, and the account default is {}, not {}",
default.map_or("unrecorded", String::as_str),
parameter_text(value)
value.text()
));
}
}
SessionCheck::Matches
}

/// A parameter value as `SHOW PARAMETERS` writes it.
pub(crate) fn parameter_text(value: &SessionValue) -> String {
match value {
SessionValue::Boolean(flag) => flag.to_string(),
SessionValue::Integer(number) => number.to_string(),
SessionValue::Text(text) => text.clone(),
}
}

/// Whether a reported value is the profile's, ignoring case and surrounding space.
fn same(actual: &str, value: &SessionValue) -> bool {
actual
.trim()
.eq_ignore_ascii_case(parameter_text(value).trim())
actual.trim().eq_ignore_ascii_case(value.text().trim())
}
3 changes: 1 addition & 2 deletions crates/sqlquack-evidence/src/verify/tests/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ use crate::results::models::{
RecordedFailure, RecordedOutcome, ResultMetadata, SessionDefaults, WarehouseColumn,
};
use crate::verify::_helpers::compile_errors::undeclared_compile_error;
use crate::verify::_helpers::session::parameter_text;
use crate::verify::main::verify_cases::verify_cases;
use crate::verify::models::VerifyInputs;
use crate::verify::tests::test_types::{RoundTrip, Tampering, UnchangedSqlForTests};
Expand Down Expand Up @@ -330,7 +329,7 @@ fn record_stand_in(pending: &PendingCase, results: &Path) {
.session_parameters
.iter()
.chain(&pending.pinned_parameters)
.map(|(key, value)| (key.clone(), Some(parameter_text(value))))
.map(|(key, value)| (key.clone(), Some(value.text())))
.collect(),
),
parameters_sha256: Some(STAND_IN_PARAMETERS_SHA256.to_owned()),
Expand Down
50 changes: 50 additions & 0 deletions crates/sqlquack-policy/src/policy/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,45 @@ pub struct Policy {
/// Limits on converting one statement; absent, the built-in defaults apply.
#[serde(default)]
pub conversion_budget: Option<ConversionBudgetPolicy>,
/// The project around the file: its folders, session and confirmations.
#[serde(default)]
pub project: ProjectPolicy,
}

/// `[project]`: folders that differ from the defaults, the caller's session and what it confirms.
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectPolicy {
/// The folder of source YAML files, relative to the file; `sources` when absent.
#[serde(default)]
pub sources: Option<String>,
/// The folder of SQL and Python function files, relative to the file; `functions` when absent.
#[serde(default)]
pub functions: Option<String>,
/// The folder of override files, relative to the file; `overrides` when absent.
#[serde(default)]
pub overrides: Option<String>,
/// Snowflake session parameters of the caller's session, as `--session` gives them.
#[serde(default)]
pub session: BTreeMap<String, SessionValue>,
/// What the caller confirms, as the command-line confirmation flags do.
#[serde(default)]
pub confirm: ProjectConfirm,
}

/// `[project.confirm]`: assumptions the project confirms for every run.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectConfirm {
/// `--python-runtime`.
#[serde(default)]
pub python_runtime: bool,
/// `--utc-session`.
#[serde(default)]
pub utc_session: bool,
/// `--confirm-declared-offsets`.
#[serde(default)]
pub declared_offsets: bool,
}

/// How long converting one statement may take and how much SQL it may emit, before it is refused.
Expand Down Expand Up @@ -151,6 +190,17 @@ pub enum SessionValue {
Text(String),
}

impl SessionValue {
/// The value as text: booleans in lower case, integers in digits, text as written.
pub fn text(&self) -> String {
match self {
Self::Boolean(flag) => flag.to_string(),
Self::Integer(number) => number.to_string(),
Self::Text(text) => text.clone(),
}
}
}

/// The input tags a category's case files must cover between them.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
Expand Down
6 changes: 5 additions & 1 deletion crates/sqlquack-python/src/bindings/_helpers/convert_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ pub(crate) fn convert_json(sql: &str, request: &str, path: &str) -> PyResult<Str
json: false,
},
conversion: PolicyArguments {
policy: request.policy.unwrap_or_else(|| POLICY_FILE_NAME.into()),
policy: match (request.policy, request.project) {
(Some(policy), _) => policy,
(None, Some(project)) => project.join(POLICY_FILE_NAME),
(None, None) => POLICY_FILE_NAME.into(),
},
builtin_policy: request.builtin_policy,
strict: request.strict,
utc_session: request.utc_session,
Expand Down
2 changes: 2 additions & 0 deletions crates/sqlquack-python/src/bindings/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ pub struct Rendered<T: Serialize> {
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ConvertRequest {
/// The project folder, whose policy file is the default `--policy`.
pub project: Option<PathBuf>,
/// `--schema`.
pub schema: Option<PathBuf>,
/// `--policy`; the command line's default when absent.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use serde_json::Value;
use sqlquack_core::classification::models::Construct;
use sqlquack_core::provenance::models::ProvenanceLog;
use sqlquack_core::refusals::constants::NEEDS_DIVERGENCE;
use sqlquack_core::refusals::main::outside_conditions::outside_conditions;
use sqlquack_core::refusals::main::refused_divergence::refused_divergence;
use sqlquack_core::refusals::models::Refusal;
Expand Down Expand Up @@ -68,8 +69,8 @@ fn rely_on_python(name: &str, construct: &Construct, context: &Context<'_>) -> R
if !context.options.divergences.contains_key(PROJECT_UDF) {
return Err(Refusal {
reason: format!(
"the Python function {name}, which runs only where a Python runtime registers it: \
assumption `{PROJECT_UDF}`, which needs --python-runtime"
"the Python function {name}, which runs only where a Python runtime registers it,\
{NEEDS_DIVERGENCE}{PROJECT_UDF}`, an assumption the caller has not confirmed"
),
..refused_divergence(construct.clone(), PROJECT_UDF)
});
Expand Down
17 changes: 3 additions & 14 deletions crates/sqlquack-rules/src/session/_helpers/session_profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ pub(crate) fn matching_profile(
.snowflake
.get(name)
.or_else(|| defaults.pinned.get(name))
.map(value_text)
.map(SessionValue::text)
})
};
let mut unmatched: BTreeSet<String> = BTreeSet::new();
Expand Down Expand Up @@ -79,22 +79,11 @@ pub(crate) fn matching_profile(
))
}

/// A parameter value as text: booleans in lower case, integers in digits, text as written.
pub(crate) fn value_text(value: &SessionValue) -> String {
match value {
SessionValue::Boolean(flag) => flag.to_string(),
SessionValue::Integer(number) => number.to_string(),
SessionValue::Text(text) => text.clone(),
}
}

/// Whether a caller's value is the profile's: booleans ignoring case, others exactly.
fn same_value(given: Option<&str>, recorded: &SessionValue) -> bool {
match (given, recorded) {
(Some(given), SessionValue::Boolean(_)) => {
given.eq_ignore_ascii_case(&value_text(recorded))
}
(Some(given), _) => given == value_text(recorded),
(Some(given), SessionValue::Boolean(_)) => given.eq_ignore_ascii_case(&recorded.text()),
(Some(given), _) => given == recorded.text(),
(None, _) => false,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use sqlquack_policy::policy::constants::{
};
use sqlquack_policy::policy::models::{Policy, SessionValue};

use crate::session::_helpers::session_profiles::{matching_profile, value_text};
use crate::session::_helpers::session_profiles::matching_profile;
use crate::session::constants::BUILTIN_PROFILE;
use crate::session::main::confirm_utc_session::confirm_utc_session;
use crate::session::main::conversion_options::conversion_options;
Expand Down Expand Up @@ -46,7 +46,7 @@ pub fn session_conversion_options(
.session_profiles
.get(&profile)
.and_then(|matched| matched.snowflake.get(QUOTED_IDENTIFIERS_IGNORE_CASE))
.is_some_and(|value| value_text(value) == value_text(&SessionValue::Boolean(true)));
.is_some_and(|value| value.text() == SessionValue::Boolean(true).text());
let mut options = ConversionOptions {
quoted_identifiers_ignore_case: ignore_case,
..conversion_options(policy, strict)
Expand Down
1 change: 1 addition & 0 deletions crates/sqlquack/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ path = "src/bin/sqlquack.rs"
clap.workspace = true
rayon.workspace = true
serde.workspace = true
saphyr-parser.workspace = true
serde_json.workspace = true
sha2.workspace = true
sqlquack-compare.workspace = true
Expand Down
Loading
Loading