Skip to content
Open
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e985426
feat(snowflake): import Cortex Analyst measures and enrichment keys
nicosuave Jun 13, 2026
9124e4b
Auto-update JSON schema
github-actions[bot] Jun 13, 2026
b7f0127
Fix Snowflake top-level metric qualification and round-trip export
nicosuave Jun 14, 2026
6a14c81
Skip unrepresentable Snowflake metrics and wire Cortex fields into na…
nicosuave Jun 14, 2026
b19d987
Coerce Snowflake sample_values to str and export graph-level metric s…
nicosuave Jun 14, 2026
f718051
Preserve graph-level metadata across native export round-trips
nicosuave Jun 14, 2026
6640a93
Preserve Snowflake relationship names and metric using_relationships
nicosuave Jun 14, 2026
2284417
Match owned Snowflake metrics by identity so same-named top-level met…
nicosuave Jun 14, 2026
55c8517
Accept Snowflake enrichment fields in the Rust native schema
nicosuave Jun 14, 2026
642cde3
Merge remote-tracking branch 'origin/main' into wf-snowflake-200
nicosuave Jun 14, 2026
a322a39
Merge Snowflake graph metadata during directory loading
nicosuave Jun 15, 2026
b68e9e2
Round-trip Snowflake non-additive metrics, private access, multi-file…
nicosuave Jun 15, 2026
71d5216
Keep Snowflake metric expressions resolvable across contexts
nicosuave Jun 15, 2026
7cf5af0
Defer Snowflake top-level metrics until all tables are parsed
nicosuave Jun 15, 2026
da115d3
Resolve Snowflake cross-file top-level metrics in the CLI loader
nicosuave Jun 15, 2026
87ea415
Detect metric-only Snowflake Cortex files in the directory loader
nicosuave Jun 15, 2026
c684a99
Allow tableless metrics in split Snowflake Cortex metrics files
nicosuave Jun 15, 2026
d20f85d
Route tableless Snowflake view-metric sidecars with top-level sections
nicosuave Jun 15, 2026
5ef649c
Route instruction-only Snowflake Cortex sidecars
nicosuave Jun 15, 2026
fca4603
Avoid pending-metric name collisions and merge non-OSI root metadata …
nicosuave Jun 15, 2026
0ffb3a8
Route and defer relationship-only Snowflake Cortex sidecars
nicosuave Jun 15, 2026
ef6e760
Prefer explicit Snowflake joins and route metric-key sidecars
nicosuave Jun 15, 2026
4db2970
Merge origin/main into update-snowflake-adapter
nicosuave Jun 15, 2026
7cb7c30
Merge origin/main into update-snowflake-adapter
nicosuave Jun 15, 2026
892f84e
Keep distinct same-target Snowflake relationships in directory loader
nicosuave Jun 15, 2026
976b382
Route named tableless Cortex view-metric sidecars to Snowflake
nicosuave Jun 15, 2026
3d47659
Fix Snowflake access override and SQL metadata frontmatter
nicosuave Jun 15, 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
111 changes: 84 additions & 27 deletions sidemantic-rs/src/config/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@ pub fn load_from_directory_with_metadata(dir: impl AsRef<Path>) -> Result<Loaded
all_top_level_metrics.extend(top_level_metrics);
all_top_level_parameters.extend(top_level_parameters);
all_graph_metrics.extend(graph_metrics);
merge_osi_metadata(&mut merged_graph_metadata, graph_metadata);
merge_graph_metadata(&mut merged_graph_metadata, graph_metadata);
}
Some("sql") => {
let content = fs::read_to_string(&path).map_err(|e| {
Expand Down Expand Up @@ -590,39 +590,37 @@ pub fn load_from_directory_with_metadata(dir: impl AsRef<Path>) -> Result<Loaded

/// Merge an OSI `{ "osi": { ... } }` metadata payload into the accumulator,
/// concatenating `semantic_models` and keeping the first `version`/`ontology`.
fn merge_osi_metadata(acc: &mut Option<serde_json::Value>, incoming: Option<serde_json::Value>) {
fn merge_graph_metadata(acc: &mut Option<serde_json::Value>, incoming: Option<serde_json::Value>) {
let Some(incoming) = incoming else {
return;
};
let Some(incoming_osi) = incoming.get("osi").and_then(|v| v.as_object()).cloned() else {
return;
};

let acc_value =
acc.get_or_insert_with(|| serde_json::json!({ "osi": { "semantic_models": [] } }));
let Some(acc_osi) = acc_value
.as_object_mut()
.and_then(|m| m.get_mut("osi"))
.and_then(serde_json::Value::as_object_mut)
else {
return;
};

if let Some(serde_json::Value::Array(incoming_models)) = incoming_osi.get("semantic_models") {
let entry = acc_osi
.entry("semantic_models")
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
if let serde_json::Value::Array(acc_models) = entry {
acc_models.extend(incoming_models.iter().cloned());
}
match acc {
Some(existing) => deep_merge_json(existing, incoming),
None => *acc = Some(incoming),
}
}

for key in ["version", "ontology"] {
if !acc_osi.contains_key(key) {
if let Some(value) = incoming_osi.get(key) {
acc_osi.insert(key.to_string(), value.clone());
/// Recursively merge `incoming` into `target`: objects merge, arrays append, and
/// scalars keep the existing (first-wins) value. This preserves OSI accumulation
/// (semantic_models arrays append, version/ontology keep first) while also merging
/// non-OSI payloads such as `metadata.snowflake` from Python `export-native` files.
fn deep_merge_json(target: &mut serde_json::Value, incoming: serde_json::Value) {
match (target, incoming) {
(serde_json::Value::Object(target_map), serde_json::Value::Object(incoming_map)) => {
for (key, value) in incoming_map {
match target_map.get_mut(&key) {
Some(existing) => deep_merge_json(existing, value),
None => {
target_map.insert(key, value);
}
}
}
}
(serde_json::Value::Array(target_arr), serde_json::Value::Array(incoming_arr)) => {
target_arr.extend(incoming_arr);
}
// Scalars (or type mismatches): keep the existing value.
_ => {}
}
}

Expand Down Expand Up @@ -1875,6 +1873,65 @@ models:
assert!(orders.get_metric("net_revenue").is_some());
}

#[test]
fn test_load_from_directory_merges_non_osi_root_metadata() {
let dir = std::env::temp_dir().join(format!(
"sidemantic-rs-loader-metadata-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&dir).unwrap();
// Python `export-native` writes root `metadata.snowflake` (no `osi` key).
fs::write(
dir.join("a.yml"),
r#"
models:
- name: orders
table: orders
primary_key: order_id
metadata:
snowflake:
custom_instructions: Prefer revenue.
verified_queries:
- name: q1
"#,
)
.unwrap();
fs::write(
dir.join("b.yml"),
r#"
models:
- name: customers
table: customers
primary_key: id
metadata:
snowflake:
verified_queries:
- name: q2
"#,
)
.unwrap();

let loaded = load_from_directory_with_metadata(&dir).unwrap();
fs::remove_dir_all(&dir).unwrap();

let metadata = loaded.graph.metadata().expect("graph metadata preserved");
let snowflake = &metadata["snowflake"];
assert_eq!(snowflake["custom_instructions"], "Prefer revenue.");
// verified_queries from both files accumulate.
let names: Vec<&str> = snowflake["verified_queries"]
.as_array()
.unwrap()
.iter()
.map(|entry| entry["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"q1"));
assert!(names.contains(&"q2"));
}

#[test]
fn test_walkdir_returns_deterministic_lexical_order() {
let dir = std::env::temp_dir().join(format!(
Expand Down
70 changes: 69 additions & 1 deletion sidemantic-rs/src/config/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ pub struct SidemanticConfig {
pub sql_metrics: Option<String>,
#[serde(default)]
pub sql_segments: Option<String>,
/// Graph-level metadata payload (round-trips format-specific state such as OSI).
/// Graph-level metadata payload (round-trips format-specific state such as OSI,
/// and Snowflake Cortex top-level sections from the Python native export).
#[serde(default)]
pub metadata: Option<serde_json::Value>,
Comment thread
nicosuave marked this conversation as resolved.
}
Expand Down Expand Up @@ -132,6 +133,15 @@ pub struct DimensionConfig {
pub metadata: Option<serde_json::Value>,
#[serde(default)]
pub meta: Option<serde_json::Value>,
/// Alternative names (e.g. Snowflake Cortex Analyst, Cube).
#[serde(default)]
pub synonyms: Option<Vec<String>>,
/// Representative sample values for this dimension.
#[serde(default)]
pub sample_values: Option<Vec<String>>,
/// Linked Cortex Search service name (Snowflake Cortex Analyst).
#[serde(default)]
pub cortex_search_service_name: Option<String>,
pub format: Option<String>,
pub value_format_name: Option<String>,
pub parent: Option<String>,
Expand Down Expand Up @@ -190,6 +200,9 @@ pub struct MetricConfig {
pub metadata: Option<serde_json::Value>,
#[serde(default)]
pub meta: Option<serde_json::Value>,
/// Alternative names (e.g. Snowflake Cortex Analyst, Cube).
#[serde(default)]
pub synonyms: Option<Vec<String>>,
#[serde(default = "default_public")]
pub public: bool,
}
Expand Down Expand Up @@ -1492,6 +1505,61 @@ models:
);
}

#[test]
fn test_native_contract_accepts_snowflake_enrichment_fields() {
// Native YAML produced by Python `export-native` after a Snowflake import
// carries root `metadata`, dimension synonyms/sample_values/cortex search,
// and metric synonyms. The Rust native loader must accept (not reject) it.
let yaml = r#"
metadata:
snowflake:
verified_queries:
- name: total revenue
custom_instructions: Prefer revenue.
models:
- name: orders
table: orders
dimensions:
- name: status
type: categorical
synonyms: [state]
sample_values: ["1001", "1002"]
cortex_search_service_name: status_search
metrics:
- name: revenue
agg: sum
sql: amount
synonyms: [total revenue]
"#;

let config: SidemanticConfig = serde_yaml::from_str(yaml).unwrap();

assert_eq!(
config.metadata.as_ref().unwrap()["snowflake"]["custom_instructions"],
"Prefer revenue."
);

let dim = &config.models[0].dimensions[0];
assert_eq!(dim.synonyms.as_deref(), Some(&["state".to_string()][..]));
assert_eq!(
dim.sample_values.as_deref(),
Some(&["1001".to_string(), "1002".to_string()][..])
);
assert_eq!(
dim.cortex_search_service_name.as_deref(),
Some("status_search")
);

let metric = &config.models[0].metrics[0];
assert_eq!(
metric.synonyms.as_deref(),
Some(&["total revenue".to_string()][..])
);

// The config must still convert into the internal model without error.
config.into_parts().unwrap();
}

#[test]
fn test_parse_many_to_many_relationship_fields() {
let yaml = r#"
Expand Down
Loading
Loading