Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
af67550
refactor: port migration compatibility and matching decisions to Rust
kvlonge Oct 14, 2026
4c31a8a
chore: update Python boundary after migration decision port
kvlonge Oct 14, 2026
7035171
refactor: resolve planner references and sources natively
kvlonge Oct 14, 2026
95cc0f8
chore: update Python boundary after reference resolution port
kvlonge Oct 14, 2026
ac7c350
refactor: simplify fixture null projections natively
kvlonge Oct 14, 2026
7dc5c95
chore: update Python boundary after fixture simplification port
kvlonge Oct 14, 2026
8451d05
refactor: host SQL resolution orchestration in native bindings
kvlonge Oct 14, 2026
0b5fd02
chore: update Python boundary after resolution orchestration port
kvlonge Oct 14, 2026
7996ca5
refactor: host fixture formatting orchestration in Rust
kvlonge Oct 14, 2026
2885af6
chore: update Python boundary after fixture formatting port
kvlonge Oct 14, 2026
a990e98
refactor: host column migration planning in Rust
kvlonge Oct 14, 2026
54156c6
chore: update Python boundary after column migration port
kvlonge Oct 14, 2026
80ddb1f
refactor: resolve planner cursor windows natively
kvlonge Oct 14, 2026
65d5a8f
chore: update Python boundary after cursor resolution port
kvlonge Oct 14, 2026
777fcd6
refactor: move fixture alignment and rendering into native core
kvlonge Oct 14, 2026
e4342a8
chore: update Python boundary after fixture alignment port
kvlonge Oct 14, 2026
b0d0cf8
refactor: move cursor safety policies into native planner
kvlonge Oct 14, 2026
55c7df6
chore: update Python boundary after cursor safety port
kvlonge Oct 14, 2026
da55784
refactor: host model migration orchestration in native planner
kvlonge Oct 14, 2026
02fbd8f
chore: update Python boundary after model migration port
kvlonge Oct 14, 2026
4907772
refactor: move fixture schema and type planning into native code
kvlonge Oct 14, 2026
9a64326
chore: update Python boundary after fixture type planning port
kvlonge Oct 14, 2026
4623db2
refactor: move scenario graph and relation planning to Rust
kvlonge Oct 14, 2026
331948d
chore: update Python boundary after scenario planning port
kvlonge Oct 14, 2026
490654a
refactor: host migration planning boundaries natively
kvlonge Oct 14, 2026
09b19cd
chore: update Python boundary after migration glue port
kvlonge Oct 14, 2026
958ca5e
refactor: host migration discovery orchestration natively
kvlonge Oct 14, 2026
901acd0
chore: update Python boundary after migration discovery port
kvlonge Oct 14, 2026
b218435
fix: scope fixture type conversion to selected models
kvlonge Oct 14, 2026
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
1 change: 1 addition & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions crates/sqlbuild-analysis/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ pub mod lineage_command;
pub mod lineage_graph;
pub mod manifest;
pub mod planner_changes;
pub mod planner_fixtures;
pub mod planner_identity;
pub mod planner_migrations;
pub mod planner_scenario;
pub mod planner_scope;
pub mod planner_selection;
pub mod planner_strategy;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub(crate) mod schema_order;
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//! Fixture alignment phases.

use crate::planner_fixtures::models::AlignmentInput;

pub(crate) fn supplied_in_schema_order(input: &AlignmentInput<'_>) -> bool {
input
.supplied
.iter()
.map(|column| &column.folded_name)
.eq(input
.metadata
.iter()
.take(input.supplied.len())
.map(|column| &column.folded_name))
}
3 changes: 3 additions & 0 deletions crates/sqlbuild-analysis/src/planner_fixtures/constants.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
//! Empty fixture marker pattern.

pub(crate) const EMPTY_PATTERN: &str = r"(?i)^[\s\x1c-\x1f]*SELECT[\s\x1c-\x1f]+\*[\s\x1c-\x1f]+FROM[\s\x1c-\x1f]+__empty_fixture[\s\x1c-\x1f]*\([\s\x1c-\x1f]*\)[\s\x1c-\x1f]*$";
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//! Align supplied columns to their authoritative schema without rendering adapter SQL.

use std::collections::{BTreeSet, HashMap, HashSet};

use crate::planner_fixtures::_helpers::schema_order::supplied_in_schema_order;
use crate::planner_fixtures::models::{Alignment, AlignmentInput, RequiredColumn};

/// Resolve missing columns, diagnostics and star-prefix ordering for one fixture.
pub fn align_columns(label: &str, input: AlignmentInput<'_>) -> Alignment {
let missing = missing_columns(&input);
if missing.is_empty() && !input.typed_nulls {
return Alignment::default();
}
let metadata: HashMap<&str, usize> = input
.metadata
.iter()
.enumerate()
.map(|(index, column)| (column.folded_name.as_str(), index))
.collect();
let mut unknown: Vec<&str> = Vec::new();
let mut non_nullable: Vec<&str> = Vec::new();
let mut readers: BTreeSet<&str> = BTreeSet::new();
let mut completed: Vec<usize> = Vec::new();
for required in missing {
match metadata.get(required.folded_name.as_str()).copied() {
Some(index) if input.metadata[index].sql_type.is_some() => {
if input.metadata[index].nullable == Some(false) {
non_nullable.push(required.name.as_str());
} else {
completed.push(index);
}
}
_ => {
unknown.push(required.name.as_str());
readers.extend(required.readers.iter().map(String::as_str));
}
}
}
let diagnostics = missing_diagnostics(label, &unknown, &non_nullable, readers);
if !diagnostics.is_empty() {
return Alignment {
diagnostics,
completed: None,
};
}
if input.star && !supplied_in_schema_order(&input) {
return Alignment {
diagnostics: vec![format!(
"{label} supplies columns out of authoritative order for a SELECT * closure; provide the leading relation columns in schema order"
)],
completed: None,
};
}
completed.sort_unstable();
Alignment {
diagnostics,
completed: Some(completed),
}
}

fn missing_columns<'a>(input: &AlignmentInput<'a>) -> Vec<&'a RequiredColumn> {
let supplied: HashSet<&str> = input
.supplied
.iter()
.map(|column| column.folded_name.as_str())
.collect();
let mut missing: Vec<_> = input
.required
.iter()
.filter(|column| !supplied.contains(column.folded_name.as_str()))
.collect();
missing.sort_by(|left, right| left.name.cmp(&right.name));
missing
}

fn missing_diagnostics(
label: &str,
unknown: &[&str],
non_nullable: &[&str],
readers: BTreeSet<&str>,
) -> Vec<String> {
let mut diagnostics: Vec<String> = Vec::new();
if !unknown.is_empty() {
diagnostics.push(format!(
"{label} is missing required columns: {}; provide explicit values because their types are not authoritative (read by: {})",
unknown.join(", "), readers.into_iter().collect::<Vec<_>>().join(", ")
));
}
if !non_nullable.is_empty() {
diagnostics.push(format!(
"{label} must provide required non-nullable columns: {}",
non_nullable.join(", ")
));
}
diagnostics
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//! Authoritative schema requirements for empty fixtures.

use crate::planner_fixtures::models::FixtureColumn;

/// Return the first schema failure before rendering any empty-fixture column.
pub fn empty_fixture_error(authoritative: bool, columns: &[FixtureColumn]) -> Option<String> {
if !authoritative {
return Some("uses __empty_fixture() but its column schema is not authoritative".into());
}
let unknown: Vec<_> = columns
.iter()
.filter(|column| column.sql_type.is_none())
.map(|column| column.name.as_str())
.collect();
if !unknown.is_empty() {
return Some(format!(
"uses __empty_fixture() but columns have unknown types: {}",
unknown.join(", ")
));
}
if columns.is_empty() {
return Some("uses __empty_fixture() but its schema has no columns".into());
}
None
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//! Exact empty-fixture marker recognition.

use crate::planner_fixtures::constants::EMPTY_PATTERN;
use regex::Regex;
use std::sync::OnceLock;

/// Whether SQL consists solely of the empty-fixture marker.
pub fn empty_fixture_marker(sql: &str) -> Result<bool, regex::Error> {
static PATTERN: OnceLock<Result<Regex, regex::Error>> = OnceLock::new();
PATTERN
.get_or_init(|| Regex::new(EMPTY_PATTERN))
.as_ref()
.map(|pattern| pattern.is_match(sql))
.map_err(Clone::clone)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//! Derive fixture schema facts from relation metadata.

use crate::planner_fixtures::models::{FixtureColumn, FixtureSchema};

/// Collect authoritative folded names and last-declared known column types.
pub fn fixture_schema(columns: &[FixtureColumn], authoritative: bool) -> FixtureSchema {
let mut expected_types: Vec<(String, String)> = Vec::new();
for column in columns {
if let Some(sql_type) = &column.sql_type {
expected_types.push((column.folded_name.clone(), sql_type.clone()));
}
}
FixtureSchema {
authoritative_names: authoritative.then(|| {
columns
.iter()
.map(|column| column.folded_name.clone())
.collect()
}),
expected_types,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//! Merge inferred model columns with declared schema metadata.

use std::collections::HashMap;

use crate::planner_fixtures::models::FixtureColumn;

/// Merge declarations over inferred columns while preserving inferred order.
pub fn merge_metadata(
inferred: Vec<FixtureColumn>,
declared: Vec<FixtureColumn>,
) -> Vec<FixtureColumn> {
let mut order: Vec<String> = Vec::new();
let mut columns: HashMap<String, FixtureColumn> = HashMap::new();
for column in inferred {
order.push(column.folded_name.clone());
columns.insert(column.folded_name.clone(), column);
}
for mut column in declared {
if let Some(inferred) = columns.get(&column.folded_name) {
if column.sql_type.as_ref().is_none_or(String::is_empty) {
column.sql_type.clone_from(&inferred.sql_type);
}
if column.nullable.is_none() {
column.nullable = inferred.nullable;
}
} else {
order.push(column.folded_name.clone());
}
columns.insert(column.folded_name.clone(), column);
}
order
.into_iter()
.filter_map(|name| columns.get(&name).cloned())
.collect()
}
10 changes: 10 additions & 0 deletions crates/sqlbuild-analysis/src/planner_fixtures/main/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
pub mod align_columns;
pub mod empty_fixture_error;
pub mod empty_fixture_marker;
pub mod fixture_schema;
pub mod merge_metadata;
pub mod propagate_types;
pub mod render_completed;
pub mod render_empty;
pub mod requires_analysis;
pub mod unknown_columns;
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//! Ordered direct-lineage fixture type propagation.

use std::collections::BTreeSet;

use crate::planner_fixtures::models::{FixtureTypeModel, FixtureTypeRead, FixtureTypeState};

/// Infer a missing output type only when known upstream types agree exactly.
pub fn propagate_types(
mut state: FixtureTypeState,
models: &[FixtureTypeModel],
) -> FixtureTypeState {
for model in models {
for projection in &model.projections {
let upstream_types: BTreeSet<String> = projection
.upstream
.iter()
.filter_map(|read| state.known.get(&read.key)?.get(&read.column).cloned())
.collect();
if upstream_types.len() != 1 {
continue;
}
let Some(sql_type) = upstream_types.into_iter().next() else {
continue;
};
let model_types = state.known.entry(model.key.clone()).or_default();
if !model_types.contains_key(&projection.output_column) {
model_types.insert(projection.output_column.clone(), sql_type.clone());
state.additions.push((
FixtureTypeRead {
key: model.key.clone(),
column: projection.output_column.clone(),
},
sql_type,
));
}
}
}
state
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//! Adapter-independent completion SQL with identifier callbacks.

use crate::planner_fixtures::models::FixtureSqlInput;

/// Render completion SQL while preserving identifier callback order.
pub fn render_completed<E>(
input: FixtureSqlInput<'_>,
mut render: impl FnMut(&str, bool) -> Result<String, E>,
) -> Result<String, E> {
let alias = render("__sqlbuild_partial_fixture", false)?;
let mut projections: Vec<String> = Vec::new();
if input.typed_nulls {
for column in input.supplied {
let name = if column.quoted {
render(&column.name, true)?
} else {
column.name.clone()
};
let source = format!("{alias}.{name}");
projections.push(match &column.expected_type {
Some(sql_type) => format!("CAST({source} AS {sql_type}) AS {name}"),
None => source,
});
}
} else {
projections.push(format!("{alias}.*"));
}
for column in input.completed {
let name = render(&column.name, false)?;
projections.push(format!(
"CAST(NULL AS {}) AS {name}",
column.sql_type.as_deref().unwrap_or("None")
));
}
Ok(format!(
"SELECT\n {}\nFROM (\n{}\n) AS {alias}",
projections.join(",\n "),
input.sql
))
}
22 changes: 22 additions & 0 deletions crates/sqlbuild-analysis/src/planner_fixtures/main/render_empty.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//! Adapter-independent empty-fixture projection SQL.

use crate::planner_fixtures::models::FixtureColumn;

/// Render the validated authoritative empty-fixture schema.
pub fn render_empty<E>(
columns: &[FixtureColumn],
mut render: impl FnMut(&str) -> Result<String, E>,
) -> Result<String, E> {
let mut projections: Vec<String> = Vec::new();
for column in columns {
let name = render(&column.name)?;
projections.push(format!(
"CAST(NULL AS {}) AS {name}",
column.sql_type.as_deref().unwrap_or("None")
));
}
Ok(format!(
"SELECT\n {}\nWHERE FALSE",
projections.join(",\n ")
))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//! Select incomplete or open fixture schemas for read analysis.

use std::collections::BTreeSet;

use crate::planner_fixtures::models::FixtureColumn;

/// Require analysis unless every authoritative name is supplied.
pub fn requires_analysis(
supplied: &[FixtureColumn],
authoritative: Option<&BTreeSet<String>>,
) -> bool {
authoritative.is_none_or(|expected| {
let supplied_names: BTreeSet<_> =
supplied.iter().map(|column| &column.folded_name).collect();
expected.iter().any(|name| !supplied_names.contains(name))
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//! Diagnose supplied names outside a closed relation schema.

use std::collections::BTreeSet;

use crate::planner_fixtures::models::FixtureColumn;

/// Report sorted unknown names while preserving duplicate supplied names.
pub fn unknown_columns(
label: &str,
supplied: &[FixtureColumn],
authoritative: Option<&BTreeSet<String>>,
) -> Option<String> {
let expected = authoritative?;
let mut unknown: Vec<_> = supplied
.iter()
.filter(|column| !expected.contains(&column.folded_name))
.map(|column| column.name.as_str())
.collect();
unknown.sort_unstable();
(!unknown.is_empty())
.then(|| format!("{label} supplies unknown columns: {}", unknown.join(", ")))
}
Loading
Loading