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 grpc;
|
||||||
pub mod import_export;
|
pub mod import_export;
|
||||||
pub mod keybindings;
|
pub mod keybindings;
|
||||||
|
pub mod logic;
|
||||||
pub mod navigation;
|
pub mod navigation;
|
||||||
mod search;
|
mod search;
|
||||||
pub mod session;
|
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