add logic clients abstraction

This commit is contained in:
Priec
2026-09-05 23:34:42 +02:00
parent 6550166156
commit 4711374cb2
5 changed files with 835 additions and 2 deletions

2
client

Submodule client updated: 6b4e81b71d...89c8e22f2d

View File

@@ -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
View 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)""#
);
}
}

View 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());
}
}