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

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

13 changes: 13 additions & 0 deletions crates/sqlbuild-analysis/src/query_analysis/_helpers/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use polyglot_sql::{
ReferenceConfidence, SchemaValidationOptions, TransformKind, ValidationResult,
ValidationSchema, analyze_query, analyze_query_for_project_projections,
};
#[cfg(test)]
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use serde::ser::SerializeTuple;
use serde::{Deserialize, Serialize, Serializer};
Expand All @@ -22,6 +23,7 @@ const ANALYSIS_WORKER_STACK_BYTES: usize = 16 * 1024 * 1024;
const COMPACT_ANALYSIS_BATCH_SIZE: usize = 64;
const LARGE_COMPACT_SQL_BYTES: usize = 32 * 1024 * 1024;

#[cfg(test)]
#[derive(Debug, Deserialize)]
struct AnalysisBatchRequest {
requests: Vec<AnalysisRequest>,
Expand Down Expand Up @@ -92,13 +94,15 @@ impl AnalysisRequest {
}
}

#[cfg(test)]
#[derive(Debug, Serialize)]
#[serde(untagged)]
enum AnalysisResponse {
Success { analysis: QueryAnalysis },
Failure { error: String },
}

#[cfg(test)]
#[derive(Debug, Deserialize)]
struct ProjectAnalysisBatchRequest {
requests: Vec<ProjectAnalysisRequest>,
Expand Down Expand Up @@ -139,6 +143,7 @@ struct CompactProjectAnalysisProjectionRequest {
resource_names: HashMap<String, String>,
}

#[cfg(test)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProjectAnalysisRequest {
Expand All @@ -163,6 +168,7 @@ struct LineageResource {
resource_name: String,
}

#[cfg(test)]
#[derive(Debug, Serialize)]
#[serde(untagged)]
enum ProjectAnalysisResponse {
Expand Down Expand Up @@ -279,6 +285,7 @@ fn default_true() -> bool {
true
}

#[cfg(test)]
pub(crate) fn analyze_json(request_json: &str) -> Result<String, String> {
let request: AnalysisBatchRequest =
serde_json::from_str(request_json).map_err(|error| error.to_string())?;
Expand All @@ -298,6 +305,7 @@ pub(crate) fn analyze_json(request_json: &str) -> Result<String, String> {
serde_json::to_string(&responses).map_err(|error| error.to_string())
}

#[cfg(test)]
pub(crate) fn analyze_project_json(request_json: &str) -> Result<String, String> {
let request: ProjectAnalysisBatchRequest =
serde_json::from_str(request_json).map_err(|error| error.to_string())?;
Expand All @@ -317,10 +325,12 @@ pub(crate) fn analyze_project_json(request_json: &str) -> Result<String, String>
serde_json::to_string(&responses).map_err(|error| error.to_string())
}

#[cfg(test)]
pub(crate) fn analyze_project_compact_json(request_json: &str) -> Result<String, String> {
analyze_project_compact_with_catalog(request_json, None)
}

#[cfg(test)]
pub(crate) fn analyze_project_compact_with_catalog(
request_json: &str,
catalog: Option<&crate::semantic_validation::models::ProjectCatalog>,
Expand Down Expand Up @@ -1077,6 +1087,7 @@ fn project_query_templates(
results
}

#[cfg(test)]
fn analyze_request(request: AnalysisRequest) -> AnalysisResponse {
match query_analysis(request, false) {
Ok(analysis) => AnalysisResponse::Success { analysis },
Expand Down Expand Up @@ -1110,13 +1121,15 @@ fn query_analysis(
.map_err(|error| error.to_string())
}

#[cfg(test)]
fn analyze_project_request(request: ProjectAnalysisRequest) -> ProjectAnalysisResponse {
match analyze_project_result(request) {
Ok(analysis) => ProjectAnalysisResponse::Success { analysis },
Err(error) => ProjectAnalysisResponse::Failure { error },
}
}

#[cfg(test)]
fn analyze_project_result(request: ProjectAnalysisRequest) -> Result<ProjectAnalysis, String> {
let analysis = query_analysis(request.query, true)?;
Ok(project_analysis(ProjectAnalysisInputs {
Expand Down
3 changes: 0 additions & 3 deletions crates/sqlbuild-analysis/src/query_analysis/main/analyze.rs

This file was deleted.

This file was deleted.

This file was deleted.

3 changes: 0 additions & 3 deletions crates/sqlbuild-analysis/src/query_analysis/main/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
pub mod analyze;
pub mod analyze_project;
pub mod analyze_project_compact;
pub mod annotated_output_types;
pub mod output_types;
pub mod prepare_project_catalog;
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::query_analysis::main::analyze_project_compact::analyze_project_compact_json;
use crate::query_analysis::engine::analyze_project_compact_json;
use serde_json::{Value, json};

pub(crate) fn borrowed_facts_preserve_named_outputs_and_terminal_sources() -> bool {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use crate::query_analysis::main::analyze::analyze_json;
use crate::query_analysis::main::analyze_project::analyze_project_json;
use crate::query_analysis::engine::{analyze_json, analyze_project_json};
use crate::query_analysis::tests::helpers::{
binding_warnings_preserve_declared_relationship_evidence,
borrowed_facts_preserve_named_outputs_and_terminal_sources,
Expand Down
4 changes: 3 additions & 1 deletion crates/sqlbuild-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ path = "src/bin/sqlbuild.rs"
blake3.workspace = true
getrandom.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_json = { workspace = true, features = ["arbitrary_precision"] }
sqlbuild-build-features.workspace = true
sqlbuild-cache.workspace = true
sqlbuild-config.workspace = true
sqlbuild-core.workspace = true
sqlbuild-cursor.workspace = true
sqlbuild-model-config.workspace = true
sqlbuild-rules.workspace = true

[target.'cfg(unix)'.dependencies]
Expand Down
75 changes: 75 additions & 0 deletions crates/sqlbuild-cli/src/cost_store/_helpers/artifacts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//! Run directory confinement and decoding of native cost artifacts.

use std::fs;
use std::path::{Path, PathBuf};

use sqlbuild_core::cost_report::errors::CostArtifactError;
use sqlbuild_core::cost_report::main::check_cost_run_id::check_cost_run_id;
use sqlbuild_core::cost_report::main::read_cost_artifact::read_cost_artifact;
use sqlbuild_core::cost_report::models::CostRun;
use sqlbuild_core::json::models::{JsonInteger, JsonValue};

use crate::cost_store::errors::CostStoreError;

pub(crate) const ARTIFACT_NAME: &str = "run.json";
pub(crate) const TEMPORARY_NAME: &str = ".run.json.tmp";

pub(crate) fn root(project_dir: &Path) -> PathBuf {
project_dir.join("target").join("executions")
}

pub(crate) fn directory(project_dir: &Path, run_id: &str) -> Result<PathBuf, CostStoreError> {
check_cost_run_id(run_id)?;
let root = root(project_dir);
let directory = root.join(run_id);
if let (Ok(resolved), Ok(resolved_root)) = (directory.canonicalize(), root.canonicalize())
&& !resolved.starts_with(resolved_root)
{
return Err(CostArtifactError::new(
"cost run path must remain under the project execution directory",
)
.into());
}
Ok(directory)
}

fn entry(
(key, payload): (String, serde_json::Value),
) -> Result<(String, JsonValue), CostArtifactError> {
Ok((key, value(payload)?))
}

fn value(value: serde_json::Value) -> Result<JsonValue, CostArtifactError> {
Ok(match value {
serde_json::Value::Null => JsonValue::Null,
serde_json::Value::Bool(value) => JsonValue::Bool(value),
serde_json::Value::String(value) => JsonValue::String(value),
serde_json::Value::Number(value) => {
if let Some(integer) = JsonInteger::parse(&value.to_string()) {
JsonValue::Integer(integer)
} else {
JsonValue::Float(value.as_f64().ok_or_else(|| {
CostArtifactError::new("cost run artifact number is out of range")
})?)
}
}
serde_json::Value::Array(values) => JsonValue::Array(
values
.into_iter()
.map(self::value)
.collect::<Result<_, _>>()?,
),
serde_json::Value::Object(values) => {
JsonValue::Object(values.into_iter().map(entry).collect::<Result<_, _>>()?)
}
})
}

pub(crate) fn load(path: &Path) -> Result<CostRun, CostStoreError> {
let bytes = fs::read(path)?;
let text = String::from_utf8(bytes)
.map_err(|_| CostArtifactError::new("cost run artifact is not UTF-8 text"))?;
let payload = serde_json::from_str(&text)
.map_err(|_| CostArtifactError::new("cost run artifact is not valid JSON"))?;
Ok(read_cost_artifact(&value(payload)?)?)
}
1 change: 1 addition & 0 deletions crates/sqlbuild-cli/src/cost_store/_helpers/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub(crate) mod artifacts;
22 changes: 22 additions & 0 deletions crates/sqlbuild-cli/src/cost_store/errors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//! Cost artifact failures, distinguished from filesystem failures at the Python edge.

use sqlbuild_core::cost_report::errors::CostArtifactError;
use std::io;

#[derive(Debug)]
pub enum CostStoreError {
Io(io::Error),
Artifact(CostArtifactError),
}

impl From<io::Error> for CostStoreError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}

impl From<CostArtifactError> for CostStoreError {
fn from(error: CostArtifactError) -> Self {
Self::Artifact(error)
}
}
33 changes: 33 additions & 0 deletions crates/sqlbuild-cli/src/cost_store/main/list_cost_runs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//! Enumerate readable, confined run artifacts, newest first.

use crate::cost_store::_helpers::artifacts::{ARTIFACT_NAME, load, root};
use sqlbuild_core::cost_report::main::order_cost_runs::order_cost_runs;
use sqlbuild_core::cost_report::models::CostRun;
use std::fs;
use std::path::Path;

/// List readable artifacts; corrupt records, mismatched IDs and escaping symlinks are skipped.
pub fn list_cost_runs(project_dir: &Path) -> Vec<CostRun> {
let root = root(project_dir);
let Ok(entries) = fs::read_dir(&root) else {
return Vec::new();
};
let mut runs: Vec<CostRun> = Vec::new();
for entry in entries.flatten() {
let directory = entry.path();
let path = directory.join(ARTIFACT_NAME);
let (Ok(resolved), Ok(resolved_root)) = (path.canonicalize(), root.canonicalize()) else {
continue;
};
if !resolved.starts_with(resolved_root) || !path.is_file() {
continue;
}
let Ok(run) = load(&path) else {
continue;
};
if directory.file_name().and_then(|name| name.to_str()) == Some(run.run_id.as_str()) {
runs.push(run);
}
}
order_cost_runs(runs)
}
3 changes: 3 additions & 0 deletions crates/sqlbuild-cli/src/cost_store/main/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub mod list_cost_runs;
pub mod read_cost_run;
pub mod write_cost_run;
23 changes: 23 additions & 0 deletions crates/sqlbuild-cli/src/cost_store/main/read_cost_run.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//! Read one exact run artifact under the project execution directory.

use crate::cost_store::_helpers::artifacts::{ARTIFACT_NAME, directory, load};
use crate::cost_store::errors::CostStoreError;
use sqlbuild_core::cost_report::errors::CostArtifactError;
use sqlbuild_core::cost_report::models::CostRun;
use std::path::Path;

/// Return `None` for a missing artifact; report invalid artifacts and I/O failures distinctly.
pub fn read_cost_run(project_dir: &Path, run_id: &str) -> Result<Option<CostRun>, CostStoreError> {
let path = directory(project_dir, run_id)?.join(ARTIFACT_NAME);
if !path.is_file() {
return Ok(None);
}
let run = load(&path)?;
if run.run_id != run_id {
return Err(CostArtifactError::new(
"cost run artifact run ID does not match its directory",
)
.into());
}
Ok(Some(run))
}
19 changes: 19 additions & 0 deletions crates/sqlbuild-cli/src/cost_store/main/write_cost_run.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//! Atomically replace one native cost run artifact.

use crate::cost_store::_helpers::artifacts::{ARTIFACT_NAME, TEMPORARY_NAME, directory};
use crate::cost_store::errors::CostStoreError;
use sqlbuild_core::cost_report::main::cost_artifact_text::cost_artifact_text;
use sqlbuild_core::cost_report::models::CostRun;
use std::fs;
use std::path::{Path, PathBuf};

/// Persist the native record under its confined run ID and return the artifact path.
pub fn write_cost_run(project_dir: &Path, run: &CostRun) -> Result<PathBuf, CostStoreError> {
let directory = directory(project_dir, &run.run_id)?;
fs::create_dir_all(&directory)?;
let destination = directory.join(ARTIFACT_NAME);
let temporary = directory.join(TEMPORARY_NAME);
fs::write(&temporary, cost_artifact_text(run))?;
fs::rename(&temporary, &destination)?;
Ok(destination)
}
5 changes: 5 additions & 0 deletions crates/sqlbuild-cli/src/cost_store/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//! Native project-local cost artifacts shared by the launcher and Python API edge.

pub(crate) mod _helpers;
pub mod errors;
pub mod main;
1 change: 1 addition & 0 deletions crates/sqlbuild-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

pub mod argv;
pub mod compute_logs;
pub mod cost_store;
pub mod diff_report;
pub mod dispatch;
pub mod launcher;
Expand Down
21 changes: 20 additions & 1 deletion crates/sqlbuild-cli/src/native_commands/_helpers/decision.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ use crate::native_commands::constants::{
};
use crate::native_commands::errors::PythonNeeded;
use crate::native_commands::models::{
NativeCommand, NativeInvocation, RulesAction, RulesOptions, SkillsOptions,
CostOptions, NativeCommand, NativeInvocation, RulesAction, RulesOptions, SkillsOptions,
};
use sqlbuild_core::cost_command::constants::DESCENDING_ORDER;

/// Whether `sqb review --json` failed to parse, which Python reports as a JSON document.
pub(crate) fn review_json_failure(arguments: &[String], outcome: &ArgvOutcome) -> bool {
Expand Down Expand Up @@ -76,6 +77,24 @@ fn native_command(
force: flag(namespace, "skills_force"),
})),
Some(RULES_COMMAND) => rules_command(namespace),
Some("cost") => Ok(NativeCommand::Cost(CostOptions {
selector: text(namespace, "cost_selector").unwrap_or_else(|| "latest".to_owned()),
limit: match namespace.get("cost_limit") {
None | Some(Value::None) => None,
Some(Value::Int(value)) => {
Some(value.parse().map_err(|_| PythonNeeded::Arguments)?)
}
_ => return Err(PythonNeeded::Arguments),
},
no_limit: flag(namespace, "cost_no_limit"),
sort: text(namespace, "cost_sort"),
descending: text(namespace, "cost_order").is_none_or(|order| order == DESCENDING_ORDER),
since: text(namespace, "cost_since"),
until: text(namespace, "cost_until"),
json: flag(namespace, "json"),
json_path: text(namespace, "json_output").map(PathBuf::from),
no_color: flag(namespace, "no_color"),
})),
Some(_) => Err(PythonNeeded::Command),
}
}
Expand Down
Loading
Loading