diff --git a/client b/client index 6b4e81b7..89c8e22f 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 6b4e81b71dea97abe4aec3b91264841087f23a1a +Subproject commit 89c8e22f2d816c014d7b93eb66f00a511f59230d diff --git a/client-gui2 b/client-gui2 index 93969c7e..dd9e3ca5 160000 --- a/client-gui2 +++ b/client-gui2 @@ -1 +1 @@ -Subproject commit 93969c7e0ee30e97d4dbf8c79c630ba76c5d8e0a +Subproject commit dd9e3ca54014c90c69f42c1bd2531cf34e4cc7ad diff --git a/komp-app/src/lib.rs b/komp-app/src/lib.rs index 4f794357..247f7b24 100644 --- a/komp-app/src/lib.rs +++ b/komp-app/src/lib.rs @@ -9,6 +9,7 @@ pub mod csv; pub mod grpc; pub mod import_export; pub mod keybindings; +pub mod logic; pub mod navigation; mod search; pub mod session; diff --git a/komp-app/src/logic/mod.rs b/komp-app/src/logic/mod.rs new file mode 100644 index 00000000..ea71581e --- /dev/null +++ b/komp-app/src/logic/mod.rs @@ -0,0 +1,265 @@ +//! Steel reference shorthand and completion rules shared by the frontends. + +mod references; +pub use references::{ReferenceContext, SuggestionItem, SuggestionQuery}; + +pub fn expand_script_reference_shorthand(script: &str, current_table: &str) -> String { + let chars = script.chars().collect::>(); + let mut output = String::with_capacity(script.len()); + let mut idx = 0; + let mut in_string = false; + let mut escaped = false; + + while idx < chars.len() { + let ch = chars[idx]; + + if in_string { + output.push(ch); + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_string = false; + } + idx += 1; + continue; + } + + if ch == '"' { + in_string = true; + output.push(ch); + idx += 1; + continue; + } + + if ch != '@' { + output.push(ch); + idx += 1; + continue; + } + + if chars + .get(idx + 1) + .is_some_and(|next| !next.is_alphanumeric() && *next != '_') + { + output.push(ch); + idx += 1; + continue; + } + + let prev = idx.checked_sub(1).and_then(|pos| chars.get(pos)).copied(); + if prev.is_some_and(|prev| prev.is_alphanumeric() || prev == '_' || prev == '.') { + output.push(ch); + idx += 1; + continue; + } + + let start = idx + 1; + let mut end = start; + while end < chars.len() && (chars[end].is_alphanumeric() || chars[end] == '_') { + end += 1; + } + + if end == start { + output.push(ch); + idx += 1; + continue; + } + + let first = chars[start..end].iter().collect::(); + if first == "sql" { + output.push('@'); + idx += 1; + continue; + } + + if end < chars.len() && chars[end] == '(' { + let argument_start = end + 1; + let mut closing = argument_start; + let mut depth = 1_usize; + let mut argument_in_string = false; + let mut argument_escaped = false; + while closing < chars.len() { + let argument_ch = chars[closing]; + if argument_in_string { + if argument_escaped { + argument_escaped = false; + } else if argument_ch == '\\' { + argument_escaped = true; + } else if argument_ch == '"' { + argument_in_string = false; + } + } else if argument_ch == '"' { + argument_in_string = true; + } else if argument_ch == '(' { + depth += 1; + } else if argument_ch == ')' { + depth -= 1; + if depth == 0 { + break; + } + } + closing += 1; + } + if closing < chars.len() && depth == 0 { + let argument = chars[argument_start..closing].iter().collect::(); + if matches!(first.as_str(), "add" | "sub") { + if let Some((reference, amount)) = argument.trim().split_once(char::is_whitespace) + { + if let Some((table, column)) = reference.split_once('.') { + let valid_identifier = |value: &str| { + !value.is_empty() + && value.chars().all(|ch| ch.is_alphanumeric() || ch == '_') + }; + if valid_identifier(table) + && valid_identifier(column) + && !amount.trim().is_empty() + { + let function = if first == "add" { + "quantity-add" + } else { + "quantity-subtract" + }; + let expanded_amount = + expand_script_reference_shorthand(amount.trim(), current_table); + output.push_str(&format!( + r#"({function} "{table}" "{column}" {expanded_amount})"# + )); + idx = closing + 1; + continue; + } + } + } + } + let parts = argument.split_whitespace().collect::>(); + let [reference, "via", anchor] = parts.as_slice() else { + output.push(ch); + idx += 1; + continue; + }; + let reference = *reference; + let anchor = *anchor; + let valid_identifier = |value: &str| { + !value.is_empty() && value.chars().all(|ch| ch.is_alphanumeric() || ch == '_') + }; + if valid_identifier(anchor) { + match first.as_str() { + "count_rows" | "exists" if valid_identifier(reference) => { + let function = if first == "count_rows" { + "steel_related_count_rows" + } else { + "steel_related_exists" + }; + output.push_str(&format!(r#"({function} "{reference}" "{anchor}")"#)); + idx = closing + 1; + continue; + } + "sum" | "min" | "max" | "count" | "count_distinct" | "any" | "all" => { + if let Some((table, column)) = reference.split_once('.') { + if valid_identifier(table) && valid_identifier(column) { + output.push_str(&format!( + r#"(steel_related_aggregate "{}" "{}" "{}" "{}")"#, + first, table, column, anchor + )); + idx = closing + 1; + continue; + } + } + } + _ => {} + } + } + } + } + let cursor = end; + + if cursor < chars.len() && chars[cursor] == '.' { + let second_start = cursor + 1; + let mut second_end = second_start; + while second_end < chars.len() + && (chars[second_end].is_alphanumeric() || chars[second_end] == '_') + { + second_end += 1; + } + + if second_end > second_start { + let column = chars[second_start..second_end].iter().collect::(); + output.push_str(&format!(r#"(steel_get_column "{}" "{}")"#, first, column)); + idx = second_end; + continue; + } + } + + output.push_str(&format!( + r#"(steel_get_column "{}" "{}")"#, + current_table, first + )); + idx = cursor; + } + + output +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn expands_same_table_reference_shorthand() { + assert_eq!( + expand_script_reference_shorthand("(+ @amount @tax)", "invoice"), + r#"(+ (steel_get_column "invoice" "amount") (steel_get_column "invoice" "tax"))"# + ); + } + + #[test] + fn expands_cross_table_reference_shorthand() { + assert_eq!( + expand_script_reference_shorthand("(+ @invoice.amount @department.bonus)", "invoice"), + r#"(+ (steel_get_column "invoice" "amount") (steel_get_column "department" "bonus"))"# + ); + } + + #[test] + fn expands_related_aggregate_shorthand() { + assert_eq!( + expand_script_reference_shorthand( + "(+ @sum(c.amount via m) @count_distinct(c.code via m))", + "b", + ), + r#"(+ (steel_related_aggregate "sum" "c" "amount" "m") (steel_related_aggregate "count_distinct" "c" "code" "m"))"# + ); + assert_eq!( + expand_script_reference_shorthand("(if @exists(c via m) @count_rows(c via m) 0)", "b"), + r#"(if (steel_related_exists "c" "m") (steel_related_count_rows "c" "m") 0)"# + ); + assert_eq!( + expand_script_reference_shorthand("@sum(c.amount)", "b"), + "@sum(c.amount)" + ); + } + + #[test] + fn expands_quantity_ledger_effect_shorthand() { + assert_eq!( + expand_script_reference_shorthand( + "@add(product.stock @quantity)", + "delivery" + ), + r#"(quantity-add "product" "stock" (steel_get_column "delivery" "quantity"))"# + ); + assert_eq!( + expand_script_reference_shorthand("@sub(product.stock 5)", "delivery"), + r#"(quantity-subtract "product" "stock" 5)"# + ); + } + + #[test] + fn does_not_expand_aggregate_text_inside_strings() { + assert_eq!( + expand_script_reference_shorthand(r#""@sum(c.amount)""#, "b"), + r#""@sum(c.amount)""# + ); + } + +} diff --git a/komp-app/src/logic/references.rs b/komp-app/src/logic/references.rs new file mode 100644 index 00000000..951e6388 --- /dev/null +++ b/komp-app/src/logic/references.rs @@ -0,0 +1,567 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Deserialize)] +pub struct ReferenceContext { + pub current_table_name: Option, + pub linked_table_names: Vec, + pub available_table_names: Vec, + pub table_relationships: HashMap>, + pub table_columns_by_table: HashMap>, + pub table_column_types_by_table: HashMap>, + #[serde(skip)] + pub awaiting_column_autocomplete: RefCell>, +} + +#[derive(Debug, Serialize)] +pub struct SuggestionItem { + pub display_text: String, + pub value_to_store: String, +} + +impl SuggestionItem { + fn new(display_text: String, value_to_store: String) -> Self { + Self { display_text, value_to_store } + } +} + +#[derive(Debug, Serialize)] +pub struct SuggestionQuery { + pub query: String, + pub replace_range: Option<(usize, usize)>, +} + +impl SuggestionQuery { + fn with_replace_range(query: String, range: (usize, usize)) -> Self { + Self { query, replace_range: Some(range) } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ScriptSuggestionKind { + AggregateTable { + operation: String, + }, + AggregateColumn { + operation: String, + table_name: String, + }, + Anchor { + target_table: String, + }, + RootReference, + ColumnName { + table_name: String, + reference_prefix: String, + }, +} + +impl ReferenceContext { + pub fn awaiting_column_autocomplete(&self) -> Option { + self.awaiting_column_autocomplete.borrow().clone() + } + + pub fn clear_awaiting_column_autocomplete(&self) { + *self.awaiting_column_autocomplete.borrow_mut() = None; + } + + fn char_to_byte_idx(text: &str, char_idx: usize) -> usize { + text.char_indices() + .nth(char_idx) + .map(|(idx, _)| idx) + .unwrap_or(text.len()) + } + + fn token_replace_range(text: &str, cursor: usize) -> (usize, usize) { + let chars = text.chars().collect::>(); + let mut start = cursor.min(chars.len()); + let mut end = cursor.min(chars.len()); + + while start > 0 && Self::is_reference_char(chars[start - 1]) { + start -= 1; + } + + while end < chars.len() && Self::is_reference_char(chars[end]) { + end += 1; + } + + (start, end) + } + + fn is_reference_char(ch: char) -> bool { + ch.is_alphanumeric() || ch == '_' || ch == '@' || ch == '.' || ch == '[' || ch == ']' + } + + fn suggestion_matches(suggestion: &SuggestionItem, filter_text: &str) -> bool { + if filter_text.is_empty() { + return true; + } + + let needle = filter_text.to_lowercase(); + suggestion.display_text.to_lowercase().contains(&needle) + || suggestion.value_to_store.to_lowercase().contains(&needle) + } + + fn detect_script_suggestion_kind(query: &str) -> Option { + if let Some(rest) = query.strip_prefix("aggregate-table:") { + let (operation, _) = rest.split_once(':')?; + return Some(ScriptSuggestionKind::AggregateTable { + operation: operation.to_string(), + }); + } + if let Some(rest) = query.strip_prefix("aggregate-column:") { + let mut parts = rest.splitn(3, ':'); + return Some(ScriptSuggestionKind::AggregateColumn { + operation: parts.next()?.to_string(), + table_name: parts.next()?.to_string(), + }); + } + if let Some(rest) = query.strip_prefix("via:") { + let (target_table, _) = rest.split_once(':')?; + return Some(ScriptSuggestionKind::Anchor { + target_table: target_table.to_string(), + }); + } + if !query.starts_with('@') { + return None; + } + + if let Some(reference) = query.strip_prefix('@') { + if let Some((raw_table_name, _column_prefix)) = reference.split_once('.') { + let table_name = raw_table_name + .split_once('[') + .map(|(name, _)| name) + .unwrap_or(raw_table_name); + if !table_name.is_empty() { + return Some(ScriptSuggestionKind::ColumnName { + table_name: table_name.to_string(), + reference_prefix: format!("@{}.", raw_table_name), + }); + } + } + } + + Some(ScriptSuggestionKind::RootReference) + } + + fn build_root_reference_suggestions(&self, filter_text: &str) -> Vec { + let mut suggestions = Vec::new(); + + for operation in ["add", "sub"] { + let value = format!("@{}(", operation); + suggestions.push(SuggestionItem::new( + format!("Quantity ledger operation: {}", value), + value, + )); + } + + for operation in [ + "sum", + "min", + "max", + "count", + "count_distinct", + "count_rows", + "exists", + "any", + "all", + ] { + let value = format!("@{}(", operation); + suggestions.push(SuggestionItem::new( + format!("Aggregate function: {}", value), + value, + )); + } + + for linked_table in &self.linked_table_names { + let prefix = format!("@{}.", linked_table); + suggestions.push(SuggestionItem::new( + format!("Linked table: {}", prefix), + prefix, + )); + } + + if let Some(current_table) = self.current_table_name.as_deref() { + if let Some(columns) = self.table_columns_by_table.get(current_table) { + suggestions.extend(columns.iter().map(|column| { + let value = format!("@{}", column); + SuggestionItem::new(format!("Same table column: {}", value), value) + })); + } + } + + suggestions + .into_iter() + .filter(|suggestion| Self::suggestion_matches(suggestion, filter_text)) + .collect() + } + + fn build_aggregate_table_suggestions( + &self, + operation: &str, + filter_text: &str, + ) -> Vec { + let row_operation = matches!(operation, "count_rows" | "exists"); + self.available_table_names + .iter() + .map(|table| { + let value = if row_operation { + format!("{} via ", table) + } else { + format!("{}.", table) + }; + SuggestionItem::new(format!("Aggregate table: {}", table), value) + }) + .filter(|suggestion| Self::suggestion_matches(suggestion, filter_text)) + .collect() + } + + fn aggregate_accepts_type(operation: &str, data_type: &str) -> bool { + let normalized = data_type.to_ascii_lowercase(); + match operation { + "sum" | "min" | "max" => { + normalized == "int" + || normalized == "integer" + || normalized == "money" + || normalized.starts_with("numeric") + } + "any" | "all" => normalized == "bool" || normalized == "boolean", + "count" | "count_distinct" => true, + _ => false, + } + } + + fn build_aggregate_column_suggestions( + &self, + operation: &str, + table_name: &str, + filter_text: &str, + ) -> Vec { + let Some(columns) = self.table_columns_by_table.get(table_name) else { + *self.awaiting_column_autocomplete.borrow_mut() = Some(table_name.to_string()); + return Vec::new(); + }; + self.clear_awaiting_column_autocomplete(); + columns + .iter() + .filter(|column| { + self.table_column_types_by_table + .get(table_name) + .and_then(|types| types.get(*column)) + .is_some_and(|data_type| Self::aggregate_accepts_type(operation, data_type)) + }) + .map(|column| { + SuggestionItem::new( + format!("Aggregate column: {}.{}", table_name, column), + format!("{} via ", column), + ) + }) + .filter(|suggestion| Self::suggestion_matches(suggestion, filter_text)) + .collect() + } + + fn graph(&self) -> HashMap> { + let mut graph = HashMap::>::new(); + for (source, targets) in &self.table_relationships { + graph.entry(source.clone()).or_default(); + for target in targets { + if !graph.entry(source.clone()).or_default().contains(target) { + graph + .entry(source.clone()) + .or_default() + .push(target.clone()); + } + if !graph.entry(target.clone()).or_default().contains(source) { + graph + .entry(target.clone()) + .or_default() + .push(source.clone()); + } + } + } + graph + } + + fn shortest_path_stats( + graph: &HashMap>, + start: &str, + end: &str, + ) -> Option<(usize, usize)> { + if start == end { + return Some((0, 1)); + } + let mut queue = std::collections::VecDeque::from([start.to_string()]); + let mut distance = HashMap::from([(start.to_string(), 0usize)]); + let mut paths = HashMap::from([(start.to_string(), 1usize)]); + while let Some(table) = queue.pop_front() { + let current_distance = distance[&table]; + for next in graph.get(&table).into_iter().flatten() { + match distance.get(next).copied() { + None => { + distance.insert(next.clone(), current_distance + 1); + paths.insert(next.clone(), paths[&table]); + queue.push_back(next.clone()); + } + Some(known) if known == current_distance + 1 => { + let count = paths[next].saturating_add(paths[&table]).min(2); + paths.insert(next.clone(), count); + } + _ => {} + } + } + } + Some((*distance.get(end)?, *paths.get(end)?)) + } + + fn build_anchor_suggestions( + &self, + target_table: &str, + filter_text: &str, + ) -> Vec { + let Some(source_table) = self.current_table_name.as_deref() else { + return Vec::new(); + }; + let graph = self.graph(); + let mut candidates = self.available_table_names.clone(); + if !candidates.iter().any(|table| table == source_table) { + candidates.push(source_table.to_string()); + } + let mut ranked = candidates + .into_iter() + .filter_map(|anchor| { + let (source_distance, source_paths) = + Self::shortest_path_stats(&graph, source_table, &anchor)?; + let (target_distance, target_paths) = + Self::shortest_path_stats(&graph, &anchor, target_table)?; + if source_paths != 1 || target_paths != 1 { + return None; + } + let degree = graph.get(&anchor).map_or(0, Vec::len); + Some(( + source_distance + target_distance, + std::cmp::Reverse(degree), + anchor, + )) + }) + .collect::>(); + ranked.sort(); + ranked + .into_iter() + .map(|(distance, std::cmp::Reverse(degree), anchor)| { + SuggestionItem::new( + format!( + "Path anchor: {} (route {}, connections {})", + anchor, distance, degree + ), + format!("{})", anchor), + ) + }) + .filter(|suggestion| Self::suggestion_matches(suggestion, filter_text)) + .collect() + } + + fn build_column_name_suggestions( + &self, + table_name: &str, + reference_prefix: &str, + filter_text: &str, + ) -> Vec { + let Some(columns) = self.table_columns_by_table.get(table_name) else { + *self.awaiting_column_autocomplete.borrow_mut() = Some(table_name.to_string()); + return Vec::new(); + }; + + self.clear_awaiting_column_autocomplete(); + + columns + .iter() + .map(|column| { + let base = format!("{}{}", reference_prefix, column); + SuggestionItem::new(format!("Reference: {}", base), base) + }) + .filter(|suggestion| Self::suggestion_matches(suggestion, filter_text)) + .collect() + } + /// Query and replacement offsets are Unicode character counts within one editor line. + pub fn suggestion_query(&self, line: &str, cursor_char: usize) -> Option { + let cursor_byte = Self::char_to_byte_idx(line, cursor_char); + let before_cursor = &line[..cursor_byte]; + if let Some(aggregate_byte) = before_cursor.rfind('@') { + let aggregate = &before_cursor[aggregate_byte + 1..]; + if let Some((operation, argument)) = aggregate.split_once('(') { + let supported = matches!( + operation, + "sum" + | "min" + | "max" + | "count" + | "count_distinct" + | "count_rows" + | "exists" + | "any" + | "all" + ); + if supported && !argument.contains(')') { + let argument_byte = aggregate_byte + 1 + operation.len() + 1; + let argument_start = line[..argument_byte].chars().count(); + if let Some((reference, anchor_filter)) = argument.split_once(" via ") { + let target_table = + reference.split_once('.').map_or(reference, |pair| pair.0); + let anchor_byte = argument_byte + reference.len() + " via ".len(); + let anchor_start = line[..anchor_byte].chars().count(); + let mut replace_end = cursor_char; + if line[cursor_byte..].starts_with(')') { + replace_end += 1; + } + return Some(SuggestionQuery::with_replace_range( + format!("via:{}:{}", target_table, anchor_filter), + (anchor_start, replace_end), + )); + } + if matches!(operation, "count_rows" | "exists") { + return Some(SuggestionQuery::with_replace_range( + format!("aggregate-table:{}:{}", operation, argument), + (argument_start, cursor_char), + )); + } + if let Some((table_name, column_filter)) = argument.split_once('.') { + let column_byte = argument_byte + table_name.len() + 1; + let column_start = line[..column_byte].chars().count(); + return Some(SuggestionQuery::with_replace_range( + format!( + "aggregate-column:{}:{}:{}", + operation, table_name, column_filter + ), + (column_start, cursor_char), + )); + } + return Some(SuggestionQuery::with_replace_range( + format!("aggregate-table:{}:{}", operation, argument), + (argument_start, cursor_char), + )); + } + } + } + let replace_range = Self::token_replace_range(line, cursor_char); + let start_byte = Self::char_to_byte_idx(line, replace_range.0); + let end_byte = Self::char_to_byte_idx(line, replace_range.1); + let query = line[start_byte..end_byte].to_string(); + + if query.starts_with('@') { + Some(SuggestionQuery::with_replace_range(query, replace_range)) + } else { + None + } + } + + pub fn suggestions(&self, query: &str) -> Vec { + self.clear_awaiting_column_autocomplete(); + + match Self::detect_script_suggestion_kind(query) { + Some(ScriptSuggestionKind::AggregateTable { operation }) => { + let filter = query.rsplit_once(':').map_or("", |pair| pair.1); + self.build_aggregate_table_suggestions(&operation, filter) + } + Some(ScriptSuggestionKind::AggregateColumn { + operation, + table_name, + }) => { + let filter = query.rsplit_once(':').map_or("", |pair| pair.1); + self.build_aggregate_column_suggestions(&operation, &table_name, filter) + } + Some(ScriptSuggestionKind::Anchor { target_table }) => { + let filter = query.rsplit_once(':').map_or("", |pair| pair.1); + self.build_anchor_suggestions(&target_table, filter) + } + Some(ScriptSuggestionKind::RootReference) => { + self.build_root_reference_suggestions(query) + } + Some(ScriptSuggestionKind::ColumnName { + table_name, + reference_prefix, + }) => self.build_column_name_suggestions(&table_name, &reference_prefix, query), + None => Vec::new(), + } + } + +} + +#[cfg(test)] +mod tests { + use super::*; + + fn context() -> ReferenceContext { + ReferenceContext { + current_table_name: Some("invoice".into()), + linked_table_names: vec!["department".into()], + available_table_names: vec!["department".into()], + table_relationships: HashMap::from([("invoice".into(), vec!["department".into()])]), + table_columns_by_table: HashMap::from([ + ("invoice".into(), vec!["amount".into()]), + ("department".into(), vec!["bonus".into(), "name".into(), "active".into()]), + ]), + table_column_types_by_table: HashMap::from([("department".into(), HashMap::from([ + ("bonus".into(), "numeric".into()), ("name".into(), "text".into()), ("active".into(), "boolean".into()), + ]))]), + ..Default::default() + } + } + + fn values(context: &ReferenceContext, source: &str) -> Vec { + let query = context.suggestion_query(source, source.chars().count()).unwrap(); + context.suggestions(&query.query).into_iter().map(|item| item.value_to_store).collect() + } + + #[test] + fn root_and_linked_references_share_the_client_syntax() { + let context = context(); + let root = values(&context, "@"); + for expected in ["@amount", "@department.", "@add(", "@sub(", "@sum("] { + assert!(root.iter().any(|value| value == expected)); + } + assert_eq!(values(&context, "@department.bo"), vec!["@department.bonus"]); + assert!(context.suggestion_query("(+ amount", 9).is_none()); + } + + #[test] + fn aggregates_filter_types_and_rank_connected_anchors() { + let context = context(); + assert_eq!(values(&context, "@sum("), vec!["department."]); + assert_eq!(values(&context, "@sum(department."), vec!["bonus via "]); + assert_eq!(values(&context, "@any(department."), vec!["active via "]); + assert_eq!(values(&context, "@sum(department.bonus via "), vec!["department)", "invoice)"]); + assert_eq!(values(&context, "@count_rows("), vec!["department via "]); + } + + #[test] + fn replacement_ranges_use_characters_and_consume_existing_anchor_close() { + let context = context(); + let source = "😀 @department.bo"; + let query = context.suggestion_query(source, source.chars().count()).unwrap(); + assert_eq!(query.replace_range, Some((2, source.chars().count()))); + let source = "@sum(department.bonus via dep)"; + let query = context.suggestion_query(source, source.chars().count() - 1).unwrap(); + assert_eq!(query.replace_range.unwrap().1, source.chars().count()); + } + + #[test] + fn missing_column_metadata_requests_loading() { + let context = context(); + assert!(values(&context, "@unknown.").is_empty()); + assert_eq!(context.awaiting_column_autocomplete().as_deref(), Some("unknown")); + values(&context, "@"); + assert!(context.awaiting_column_autocomplete().is_none()); + } + + #[test] + fn ambiguous_shortest_paths_are_not_suggested_as_anchors() { + let mut context = context(); + context.table_relationships = HashMap::from([ + ("invoice".into(), vec!["left".into(), "right".into()]), + ("left".into(), vec!["department".into()]), + ("right".into(), vec!["department".into()]), + ]); + assert!(values(&context, "@sum(department.bonus via ").is_empty()); + } +}