add logic clients abstraction
This commit is contained in:
2
client
2
client
Submodule client updated: 6b4e81b71d...89c8e22f2d
Submodule client-gui2 updated: 93969c7e0e...dd9e3ca540
@@ -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;
|
||||
|
||||
265
komp-app/src/logic/mod.rs
Normal file
265
komp-app/src/logic/mod.rs
Normal file
@@ -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::<Vec<_>>();
|
||||
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::<String>();
|
||||
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::<String>();
|
||||
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::<Vec<_>>();
|
||||
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::<String>();
|
||||
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)""#
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
567
komp-app/src/logic/references.rs
Normal file
567
komp-app/src/logic/references.rs
Normal file
@@ -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<String>,
|
||||
pub linked_table_names: Vec<String>,
|
||||
pub available_table_names: Vec<String>,
|
||||
pub table_relationships: HashMap<String, Vec<String>>,
|
||||
pub table_columns_by_table: HashMap<String, Vec<String>>,
|
||||
pub table_column_types_by_table: HashMap<String, HashMap<String, String>>,
|
||||
#[serde(skip)]
|
||||
pub awaiting_column_autocomplete: RefCell<Option<String>>,
|
||||
}
|
||||
|
||||
#[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<String> {
|
||||
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::<Vec<_>>();
|
||||
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<ScriptSuggestionKind> {
|
||||
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<SuggestionItem> {
|
||||
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<SuggestionItem> {
|
||||
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<SuggestionItem> {
|
||||
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<String, Vec<String>> {
|
||||
let mut graph = HashMap::<String, Vec<String>>::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<String, Vec<String>>,
|
||||
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<SuggestionItem> {
|
||||
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::<Vec<_>>();
|
||||
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<SuggestionItem> {
|
||||
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<SuggestionQuery> {
|
||||
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<SuggestionItem> {
|
||||
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<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user