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
8 changes: 8 additions & 0 deletions Cargo.lock

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

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ members = [
"crates/sqlbuild-scopes",
"crates/sqlbuild-model-config",
"crates/sqlbuild-render",
"crates/sqlbuild-attachments",
"crates/sqlbuild-analysis",
"crates/sqlbuild-rules",
"crates/sqlbuild-python",
Expand All @@ -27,6 +28,7 @@ order = [
"sqlbuild-scopes",
"sqlbuild-model-config",
"sqlbuild-render",
"sqlbuild-attachments",
"sqlbuild-analysis",
"sqlbuild-rules",
"sqlbuild-python",
Expand Down Expand Up @@ -75,6 +77,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
sha2 = "0.10"
sqlbuild-analysis = { path = "crates/sqlbuild-analysis" }
sqlbuild-attachments = { path = "crates/sqlbuild-attachments" }
sqlbuild-cache = { path = "crates/sqlbuild-cache" }
sqlbuild-config = { path = "crates/sqlbuild-config" }
sqlbuild-core = { path = "crates/sqlbuild-core" }
Expand Down
7 changes: 4 additions & 3 deletions crates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ that can be tested and benchmarked with `cargo test` alone.
| `sqlbuild-scopes` | Declaration scopes: the scope index with Python's record orders and diagnostics, declaration visibility, relationship grants, the scope lookup groups, and the dialect-aware scan of SQL tests and scenarios for their expected models. Anything it cannot reproduce exactly defers to Python. |
| `sqlbuild-model-config` | Model configuration: MODEL header columns and audits, `${...}` template expansion with its environment and context reads, and the template and macro presence scans over authored config values, all read through a trait over the caller's values. Anything it cannot reproduce exactly defers to Python, which also raises every model config error. |
| `sqlbuild-render` | Native rendering: the macro call scanner (a byte-for-byte port of Python's), splicing of rendered calls with code-point spans, and the in-compile memo of recorded macro calls and their replayable events, which can carry results across compiles through the shared native store. Anything it cannot reproduce exactly defers to Python. |
| `sqlbuild-attachments` | Compile attachments for tests, audits, sources, functions, scenarios and seeds: attached generic audit argument merge, raw and quoted argument rendering, and severity and run scope resolution. Anything it cannot reproduce exactly defers to Python, which also raises every attachment error. |
| `sqlbuild-analysis` | SQL analysis over polyglot: SQL tokens, query analysis, the binding catalog, semantic validation and usage, column references, and SQL-test extraction, planning and rendering. |
| `sqlbuild-rules` | Built-in rules and the rules engine, the custom-rule host, SQL lint, quality checks and formatting, rules configuration and the request models. It also owns the build identity script. |
| `sqlbuild-python` | The only PyO3 crate: the `_native` module, its Python classes and functions, conversions from Python objects, and the process allocator. |
Expand All @@ -24,9 +25,9 @@ Dependencies point one way, from the top of this graph to the bottom:

```text
sqlbuild-python
-> sqlbuild-rules -> sqlbuild-analysis -> sqlbuild-model-config -> sqlbuild-render
-> sqlbuild-scopes -> sqlbuild-discovery -> sqlbuild-config -> sqlbuild-sqltext -> sqlbuild-cache
-> sqlbuild-core
-> sqlbuild-rules -> sqlbuild-analysis -> sqlbuild-attachments -> sqlbuild-render
-> sqlbuild-model-config -> sqlbuild-scopes -> sqlbuild-discovery -> sqlbuild-config
-> sqlbuild-sqltext -> sqlbuild-cache -> sqlbuild-core
```

`sqlbuild-config` and `sqlbuild-model-config` do not depend on the crates below them today, and
Expand Down
14 changes: 14 additions & 0 deletions crates/sqlbuild-attachments/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[package]
name = "sqlbuild-attachments"
version.workspace = true
description = "Native compile attachments for SQLBuild: tests, audits, sources, functions, scenarios and seeds rendered with Python's exact rules."
edition.workspace = true
license.workspace = true
publish.workspace = true
rust-version.workspace = true

[lints]
workspace = true

[dependencies]
sqlbuild-sqltext.workspace = true
2 changes: 2 additions & 0 deletions crates/sqlbuild-attachments/src/audits/_helpers/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub(crate) mod parameters;
pub(crate) mod policies;
139 changes: 139 additions & 0 deletions crates/sqlbuild-attachments/src/audits/_helpers/parameters.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//! Python's raw and quoted SQL argument patterns and argument value rendering.

use crate::audits::models::ArgumentValue;

/// One `@name` or `@'name'` occurrence, in byte offsets.
pub(crate) struct ParameterMatch<'sql> {
pub(crate) name: &'sql str,
pub(crate) start: usize,
pub(crate) end: usize,
pub(crate) quoted: bool,
}

/// Every parameter in start order, or None where Python's Unicode whitespace could decide.
pub(crate) fn parameter_matches(sql: &str) -> Option<Vec<ParameterMatch<'_>>> {
let bytes: &[u8] = sql.as_bytes();
let mut matches: Vec<ParameterMatch<'_>> = Vec::new();
let mut quoted_end: usize = 0;
let mut raw_end: usize = 0;
for (index, byte) in bytes.iter().enumerate() {
if *byte != b'@'
|| index
.checked_sub(1)
.is_some_and(|previous| bytes[previous] == b'@')
{
continue;
}
if bytes.get(index + 1) == Some(&b'\'') {
if index < quoted_end {
continue;
}
if let Some(end) = identifier_end(bytes, index + 2)
&& bytes.get(end) == Some(&b'\'')
{
matches.push(ParameterMatch {
name: &sql[index + 2..end],
start: index,
end: end + 1,
quoted: true,
});
quoted_end = end + 1;
}
continue;
}
if index < raw_end {
continue;
}
let Some(end) = identifier_end(bytes, index + 1) else {
continue;
};
if !opens_call(bytes, end)? {
matches.push(ParameterMatch {
name: &sql[index + 1..end],
start: index,
end,
quoted: false,
});
raw_end = end;
}
}
Some(matches)
}

/// Python's `render_sql_argument_value` for one value.
pub(crate) fn render_value(value: &ArgumentValue, quoted: bool) -> String {
match value {
ArgumentValue::List(items) => items
.iter()
.map(|item| render_value(item, quoted))
.collect::<Vec<String>>()
.join(", "),
ArgumentValue::Boolean(true) => "TRUE".to_owned(),
ArgumentValue::Boolean(false) => "FALSE".to_owned(),
ArgumentValue::Null => "NULL".to_owned(),
ArgumentValue::Number(text) => text.clone(),
ArgumentValue::Text(text) if quoted => format!("'{}'", text.replace('\'', "''")),
ArgumentValue::Text(text) => text.clone(),
}
}

/// The end of `[A-Za-z_][A-Za-z0-9_]*` at `start`.
fn identifier_end(bytes: &[u8], start: usize) -> Option<usize> {
let first: u8 = *bytes.get(start)?;
if !(first.is_ascii_alphabetic() || first == b'_') {
return None;
}
let mut end: usize = start + 1;
while bytes
.get(end)
.is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
{
end += 1;
}
Some(end)
}

/// Python's `(?!\s*\()` after a name; non-ASCII whitespace candidates defer.
fn opens_call(bytes: &[u8], mut index: usize) -> Option<bool> {
while let Some(byte) = bytes.get(index) {
if !byte.is_ascii() {
return None;
}
if !matches!(
byte,
b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c | 0x1c..=0x1f
) {
return Some(*byte == b'(');
}
index += 1;
}
Some(false)
}

/// Python's `render_parameterized_sql`, or None where Python raises or decides.
pub(crate) fn render_parameterized_sql(
sql: &str,
arguments: &[(String, ArgumentValue)],
reject_unused: bool,
) -> Option<String> {
let matches = parameter_matches(sql)?;
if reject_unused {
for (name, _) in arguments {
if !matches.iter().any(|item| item.name == name.as_str()) {
return None;
}
}
}
let mut rendered: String = String::with_capacity(sql.len());
let mut previous_end: usize = 0;
for item in &matches {
let (_, value) = arguments
.iter()
.find(|(name, _)| name.as_str() == item.name)?;
rendered.push_str(&sql[previous_end..item.start]);
rendered.push_str(&render_value(value, item.quoted));
previous_end = item.end;
}
rendered.push_str(&sql[previous_end..]);
Some(rendered)
}
33 changes: 33 additions & 0 deletions crates/sqlbuild-attachments/src/audits/_helpers/policies.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//! Python's `resolve_audit_severity` and `resolve_audit_run_scope`.

use crate::audits::models::PolicySource;

const SEVERITIES: [&str; 2] = ["warn", "error"];
const RUN_SCOPES: [&str; 2] = ["final", "delta_and_final"];

/// The severity text; unknown values defer so Python raises.
pub(crate) fn resolve_severity(
instance: Option<&str>,
default: Option<&str>,
) -> Option<&'static str> {
match (instance, default) {
(Some(value), _) | (None, Some(value)) => known(&SEVERITIES, value),
(None, None) => Some("error"),
}
}

/// Where the run scope comes from; unknown values defer so Python raises.
pub(crate) fn resolve_run_scope(
instance: Option<&str>,
default: Option<&str>,
) -> Option<PolicySource> {
match (instance, default) {
(Some(value), _) => known(&RUN_SCOPES, value).map(|_| PolicySource::Instance),
(None, Some(value)) => known(&RUN_SCOPES, value).map(|_| PolicySource::Default),
(None, None) => Some(PolicySource::Fallback),
}
}

fn known(values: &[&'static str], value: &str) -> Option<&'static str> {
values.iter().copied().find(|candidate| *candidate == value)
}
1 change: 1 addition & 0 deletions crates/sqlbuild-attachments/src/audits/main/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod render_attached_audit;
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//! Python's attached generic audit rendering: argument merge, SQL, severity and run scope.

use crate::audits::_helpers::parameters::render_parameterized_sql;
use crate::audits::_helpers::policies::{resolve_run_scope, resolve_severity};
use crate::audits::models::{ArgumentValue, AuditAttachment, PolicySource, RenderedAudit};

/// Render one attachment as Python does, or None where Python raises or decides.
#[must_use]
pub fn render_attached_audit(attachment: &AuditAttachment) -> Option<RenderedAudit> {
if attachment.measurement {
if attachment.instance_severity.is_some() || !attachment.has_thresholds {
return None;
}
} else if attachment.has_thresholds || attachment.has_minimum_samples {
return None;
}
let arguments: Vec<(String, ArgumentValue)> = merged_arguments(attachment)?;
let sql_body: String = render_parameterized_sql(&attachment.sql_body, &arguments, false)?;
let evidence_sql: Option<String> = match &attachment.evidence_sql {
Some(evidence) => Some(render_parameterized_sql(evidence, &arguments, false)?),
None => None,
};
let severity: &'static str = if attachment.has_thresholds {
if attachment.threshold_error {
"error"
} else {
"warn"
}
} else {
resolve_severity(
attachment.instance_severity.as_deref(),
attachment.default_severity.as_deref(),
)?
};
let run_scope_source: PolicySource = resolve_run_scope(
attachment.instance_run_scope.as_deref(),
attachment.default_run_scope.as_deref(),
)?;
Some(RenderedAudit {
sql_body,
evidence_sql,
severity,
run_scope_source,
})
}

/// Python's `merge_audit_arguments`; values that differ only as Python objects defer.
fn merged_arguments(attachment: &AuditAttachment) -> Option<Vec<(String, ArgumentValue)>> {
let mut merged: Vec<(String, ArgumentValue)> = attachment.implicit_arguments.clone();
for (name, value) in &attachment.explicit_arguments {
match merged.iter_mut().find(|(existing, _)| existing == name) {
Some((_, existing)) if existing == value => {}
Some(_) => return None,
None => merged.push((name.clone(), value.clone())),
}
}
Some(merged)
}
6 changes: 6 additions & 0 deletions crates/sqlbuild-attachments/src/audits/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
pub(crate) mod _helpers;
pub mod main;
pub mod models;

#[cfg(test)]
mod tests;
46 changes: 46 additions & 0 deletions crates/sqlbuild-attachments/src/audits/models.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//! Attached audit inputs and the rendering Python's attachment produces from them.

/// One audit argument value as Python holds it; numbers keep Python's own `str()` text.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ArgumentValue {
Null,
Boolean(bool),
Number(String),
Text(String),
List(Vec<ArgumentValue>),
}

/// Where a resolved run scope came from, so Python keeps its own value object.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PolicySource {
Instance,
Default,
Fallback,
}

/// One generic audit attachment: definition SQL, arguments and authored policies.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuditAttachment {
pub sql_body: String,
pub evidence_sql: Option<String>,
pub implicit_arguments: Vec<(String, ArgumentValue)>,
pub explicit_arguments: Vec<(String, ArgumentValue)>,
pub measurement: bool,
pub has_thresholds: bool,
pub has_minimum_samples: bool,
pub threshold_error: bool,
pub instance_severity: Option<String>,
pub default_severity: Option<String>,
pub instance_run_scope: Option<String>,
pub default_run_scope: Option<String>,
}

/// The rendered SQL and the policies one attachment resolves to.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RenderedAudit {
pub sql_body: String,
pub evidence_sql: Option<String>,
/// `warn` or `error`.
pub severity: &'static str,
pub run_scope_source: PolicySource,
}
8 changes: 8 additions & 0 deletions crates/sqlbuild-attachments/src/audits/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#[path = "tests/helpers.rs"]
mod helpers;
#[path = "tests/test_render_attached_audit.rs"]
mod render_attached_audit;
#[path = "tests/test_render_parameterized_sql.rs"]
mod render_parameterized_sql;
#[path = "tests/test_types.rs"]
mod test_types;
Loading
Loading