use std::collections::HashSet;
use crate::app::cmd::cache::BoundedLruCache;
use crate::app::model::sql_editor::completion::{CompletionCandidate, CompletionKind};
use crate::app::policy::sql::lexer::{SqlContext, SqlLexer, TableReference, Token, TokenKind};
use crate::app::update::helpers::char_to_byte_index;
use crate::domain::{DatabaseMetadata, Table};
const COMPLETION_MAX_CANDIDATES: usize = 30;
const TABLE_CACHE_CAPACITY: usize = 500;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CompletionContext {
Keyword,
Table,
Column,
SchemaQualified(String),
AliasColumn(String),
CteOrTable,
}
pub struct PreparedCompletion {
pub(crate) tokens: Vec<Token>,
pub(crate) context: SqlContext,
pub(crate) before_cursor: String,
pub(crate) current_token: String,
pub(crate) in_string_or_comment: bool,
pub(crate) cte_names: HashSet<String>,
}
pub struct CompletionEngine {
keywords: Vec<&'static str>,
lexer: SqlLexer,
table_detail_cache: BoundedLruCache<String, Table>,
}
impl Default for CompletionEngine {
fn default() -> Self {
Self::new()
}
}
impl CompletionEngine {
pub fn new() -> Self {
Self {
keywords: vec![
"SELECT",
"FROM",
"WHERE",
"JOIN",
"LEFT",
"RIGHT",
"INNER",
"OUTER",
"CROSS",
"ON",
"AND",
"OR",
"NOT",
"IN",
"IS",
"NULL",
"TRUE",
"FALSE",
"LIKE",
"ILIKE",
"BETWEEN",
"EXISTS",
"CASE",
"WHEN",
"THEN",
"ELSE",
"END",
"AS",
"DISTINCT",
"ORDER",
"BY",
"ASC",
"DESC",
"NULLS",
"FIRST",
"LAST",
"GROUP",
"HAVING",
"LIMIT",
"OFFSET",
"UNION",
"INTERSECT",
"EXCEPT",
"ALL",
"INSERT",
"INTO",
"VALUES",
"UPDATE",
"SET",
"DELETE",
"CREATE",
"DROP",
"ALTER",
"TABLE",
"INDEX",
"VIEW",
"RETURNING",
"WITH",
"RECURSIVE",
"COALESCE",
"NULLIF",
"CAST",
"USING",
],
lexer: SqlLexer::new(),
table_detail_cache: BoundedLruCache::new(TABLE_CACHE_CAPACITY),
}
}
#[cfg(test)]
pub fn new_with_capacity(capacity: usize) -> Self {
let mut engine = Self::new();
engine.table_detail_cache = BoundedLruCache::new(capacity);
engine
}
pub fn cache_table_detail(&mut self, qualified_name: String, table: Table) {
self.table_detail_cache.insert(qualified_name, table);
}
pub fn has_cached_table(&self, qualified_name: &str) -> bool {
self.table_detail_cache.contains(qualified_name)
}
pub fn evict_tables(&mut self, tables: &[String]) {
for table in tables {
self.table_detail_cache.pop(table);
}
}
pub fn clear_table_cache(&mut self) {
self.table_detail_cache.clear();
}
pub fn resize_cache(&mut self, new_capacity: usize) {
self.table_detail_cache.resize(new_capacity);
}
pub fn table_details_iter(&self) -> impl Iterator<Item = (&String, &Table)> {
self.table_detail_cache.iter()
}
pub fn missing_tables(
&self,
content: &str,
metadata: Option<&DatabaseMetadata>,
) -> Vec<String> {
let prep = self.prepare(content, content.len());
self.missing_tables_prepared(&prep, metadata)
}
pub fn get_candidates(
&self,
content: &str,
cursor_pos: usize,
metadata: Option<&DatabaseMetadata>,
table_detail: Option<&Table>,
recent_columns: &[String],
) -> Vec<CompletionCandidate> {
let prep = self.prepare(content, cursor_pos);
self.get_candidates_inner(
content,
cursor_pos,
&prep,
metadata,
table_detail,
recent_columns,
)
}
pub fn current_token_len(&self, content: &str, cursor_pos: usize) -> usize {
let before_cursor: String = content.chars().take(cursor_pos).collect();
self.extract_current_token(&before_cursor).chars().count()
}
pub fn prepare(&self, content: &str, cursor_pos: usize) -> PreparedCompletion {
let tokens = self.lexer.tokenize(content, content.len());
let context = self.lexer.build_context(&tokens, cursor_pos);
let in_string_or_comment =
SqlLexer::is_in_string_or_comment_from_tokens(&tokens, cursor_pos);
let before_cursor: String = content.chars().take(cursor_pos).collect();
let current_token = self.extract_current_token(&before_cursor);
let cte_names: HashSet<String> = context
.ctes
.iter()
.map(|cte| cte.name.to_lowercase())
.collect();
PreparedCompletion {
tokens,
context,
before_cursor,
current_token,
in_string_or_comment,
cte_names,
}
}
pub fn missing_tables_prepared(
&self,
prep: &PreparedCompletion,
metadata: Option<&DatabaseMetadata>,
) -> Vec<String> {
const MAX_MISSING_TABLES: usize = 10;
let mut missing = Vec::new();
let mut seen = HashSet::new();
for table_ref in &prep.context.tables {
if prep.cte_names.contains(&table_ref.table.to_lowercase()) {
continue;
}
let qualified_name = self.qualified_name_from_ref(table_ref, metadata);
if seen.contains(&qualified_name) || self.table_detail_cache.contains(&qualified_name) {
continue;
}
seen.insert(qualified_name.clone());
missing.push(qualified_name);
if missing.len() >= MAX_MISSING_TABLES {
break;
}
}
missing
}
pub fn current_token_len_prepared(prep: &PreparedCompletion) -> usize {
prep.current_token.chars().count()
}
pub fn get_candidates_prepared(
&self,
content: &str,
cursor_pos: usize,
prep: &PreparedCompletion,
metadata: Option<&DatabaseMetadata>,
table_detail: Option<&Table>,
recent_columns: &[String],
) -> Vec<CompletionCandidate> {
self.get_candidates_inner(
content,
cursor_pos,
prep,
metadata,
table_detail,
recent_columns,
)
}
fn get_candidates_inner(
&self,
content: &str,
cursor_pos: usize,
prep: &PreparedCompletion,
metadata: Option<&DatabaseMetadata>,
table_detail: Option<&Table>,
recent_columns: &[String],
) -> Vec<CompletionCandidate> {
if prep.in_string_or_comment {
return vec![];
}
let byte_pos = char_to_byte_index(content, cursor_pos);
if content[..byte_pos].trim_end().ends_with(';') {
return vec![];
}
let (current_token, context) = self.analyze_with_precomputed(
&prep.before_cursor,
&prep.current_token,
&prep.context,
&prep.tokens,
cursor_pos,
);
let mut candidates = match &context {
CompletionContext::Keyword => self.keyword_candidates(¤t_token),
CompletionContext::Table => self.table_candidates(metadata, ¤t_token),
CompletionContext::Column => {
let keywords = self.primary_clause_keywords(¤t_token);
let before_token = prep
.before_cursor
.trim_end()
.strip_suffix(¤t_token)
.unwrap_or(&prep.before_cursor)
.trim_end();
let after_comma = before_token.ends_with(',');
let target_qualified = prep
.context
.target_table
.as_ref()
.map(|t| self.qualified_name_from_ref(t, metadata));
let mut columns =
self.column_candidates_with_fk(table_detail, ¤t_token, recent_columns);
if let (Some(detail), Some(target)) = (table_detail, &target_qualified)
&& detail.qualified_name() == *target
{
for col in &mut columns {
col.score += 200;
}
}
let referenced_tables: HashSet<String> = prep
.context
.tables
.iter()
.filter(|t| !prep.cte_names.contains(&t.table.to_lowercase()))
.map(|t| self.qualified_name_from_ref(t, metadata))
.collect();
let selected_qualified =
table_detail.map(crate::domain::table::Table::qualified_name);
let use_all_cache = referenced_tables.is_empty();
for (qualified_name, cached_table) in self.table_detail_cache.iter() {
if selected_qualified.as_ref() == Some(qualified_name) {
continue;
}
if !use_all_cache && !referenced_tables.contains(qualified_name) {
continue;
}
let mut cached_columns = self.column_candidates_with_fk(
Some(cached_table),
¤t_token,
recent_columns,
);
if target_qualified.as_ref() == Some(qualified_name) {
for col in &mut cached_columns {
col.score += 200;
}
}
columns.extend(cached_columns);
}
let has_prefix = current_token.len() >= 2;
if has_prefix && !columns.is_empty() {
for col in &mut columns {
if col.score >= 100 {
col.score += 250;
}
}
}
if after_comma {
for col in &mut columns {
col.score += 300;
}
}
let max_keywords = if after_comma {
3
} else if has_prefix {
5
} else {
15
}
.min(keywords.len());
let max_columns = (COMPLETION_MAX_CANDIDATES - max_keywords).min(columns.len());
let mut mixed: Vec<_> = keywords.into_iter().take(max_keywords).collect();
mixed.extend(columns.into_iter().take(max_columns));
mixed.sort_by(|a, b| match b.score.cmp(&a.score) {
std::cmp::Ordering::Equal => a.text.cmp(&b.text),
other => other,
});
mixed
}
CompletionContext::SchemaQualified(schema) => {
self.schema_qualified_candidates(metadata, schema, ¤t_token)
}
CompletionContext::AliasColumn(alias) => {
self.alias_column_candidates(alias, &prep.context, metadata, ¤t_token)
}
CompletionContext::CteOrTable => {
self.cte_or_table_candidates(&prep.context, metadata, ¤t_token)
}
};
if candidates.is_empty() && context != CompletionContext::Keyword {
return self.keyword_candidates(¤t_token);
}
let mut seen = HashSet::new();
candidates.retain(|c| seen.insert(c.text.to_uppercase()));
candidates
}
#[cfg(test)]
fn analyze_with_context(
&self,
content: &str,
cursor_pos: usize,
sql_context: &SqlContext,
tokens: &[Token],
) -> (String, CompletionContext) {
let before_cursor: String = content.chars().take(cursor_pos).collect();
let current_token = self.extract_current_token(&before_cursor);
self.analyze_with_precomputed(
&before_cursor,
¤t_token,
sql_context,
tokens,
cursor_pos,
)
}
fn analyze_with_precomputed(
&self,
before_cursor: &str,
current_token: &str,
sql_context: &SqlContext,
tokens: &[Token],
cursor_pos: usize,
) -> (String, CompletionContext) {
if let Some(alias) = self.detect_alias_prefix(before_cursor, current_token, sql_context) {
return (
current_token.to_string(),
CompletionContext::AliasColumn(alias),
);
}
if let Some(schema) = self.detect_schema_prefix(before_cursor, current_token) {
return (
current_token.to_string(),
CompletionContext::SchemaQualified(schema),
);
}
let base_context = self.detect_context_from_tokens(tokens, cursor_pos);
if base_context == CompletionContext::Table && !sql_context.ctes.is_empty() {
return (current_token.to_string(), CompletionContext::CteOrTable);
}
(current_token.to_string(), base_context)
}
fn detect_alias_prefix(
&self,
before_cursor: &str,
current_token: &str,
sql_context: &SqlContext,
) -> Option<String> {
let prefix_end = before_cursor.len().saturating_sub(current_token.len());
let prefix = &before_cursor[..prefix_end];
if prefix.ends_with('.') {
let potential_alias: String = prefix
.trim_end_matches('.')
.chars()
.rev()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect::<String>()
.chars()
.rev()
.collect();
if !potential_alias.is_empty() {
let alias_lower = potential_alias.to_lowercase();
for table_ref in &sql_context.tables {
if let Some(ref alias) = table_ref.alias
&& alias.to_lowercase() == alias_lower
{
return Some(potential_alias);
}
if table_ref.table.to_lowercase() == alias_lower {
return Some(potential_alias);
}
}
}
}
None
}
fn extract_current_token(&self, before_cursor: &str) -> String {
let start = before_cursor
.char_indices()
.rev()
.take_while(|(_, c)| c.is_alphanumeric() || *c == '_')
.last()
.map_or(before_cursor.len(), |(i, _)| i);
before_cursor[start..].to_string()
}
fn detect_schema_prefix(&self, before_cursor: &str, current_token: &str) -> Option<String> {
let prefix_end = before_cursor.len().saturating_sub(current_token.len());
let prefix = &before_cursor[..prefix_end];
if prefix.ends_with('.') {
let schema: String = prefix
.trim_end_matches('.')
.chars()
.rev()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect::<String>()
.chars()
.rev()
.collect();
if !schema.is_empty() {
return Some(schema);
}
}
None
}
fn detect_context_from_tokens(&self, tokens: &[Token], cursor_pos: usize) -> CompletionContext {
let keywords_table = ["FROM", "JOIN", "INTO", "UPDATE"];
let keywords_column = ["SELECT", "WHERE", "ON", "SET", "AND", "OR", "BY"];
let mut last_table_pos = None;
let mut last_column_pos = None;
for token in tokens {
if token.start >= cursor_pos {
break;
}
if let TokenKind::Keyword(kw) = &token.kind {
let kw_upper = kw.to_uppercase();
if keywords_table.contains(&kw_upper.as_str()) {
last_table_pos = Some(token.start);
} else if keywords_column.contains(&kw_upper.as_str()) {
last_column_pos = Some(token.start);
}
}
}
match (last_table_pos, last_column_pos) {
(Some(t), Some(c)) if t > c => CompletionContext::Table,
(Some(t), None) if t > 0 => CompletionContext::Table,
(_, Some(_)) => CompletionContext::Column,
_ => CompletionContext::Keyword,
}
}
fn keyword_candidates(&self, prefix: &str) -> Vec<CompletionCandidate> {
let prefix_upper = prefix.to_uppercase();
let mut candidates: Vec<_> = self
.keywords
.iter()
.filter(|kw| prefix.is_empty() || kw.starts_with(&prefix_upper))
.map(|kw| {
let is_prefix_match = kw.starts_with(&prefix_upper);
CompletionCandidate {
text: (*kw).to_string(),
kind: CompletionKind::Keyword,
score: if is_prefix_match { 100 } else { 10 },
}
})
.collect();
candidates.sort_by(|a, b| match b.score.cmp(&a.score) {
std::cmp::Ordering::Equal => a.text.cmp(&b.text),
other => other,
});
candidates
.into_iter()
.take(COMPLETION_MAX_CANDIDATES)
.collect()
}
fn primary_clause_keywords(&self, prefix: &str) -> Vec<CompletionCandidate> {
const PRIMARY_KEYWORDS: &[&str] = &[
"FROM",
"WHERE",
"ORDER",
"BY", "GROUP",
"HAVING",
"LIMIT",
"OFFSET",
"JOIN",
"LEFT",
"RIGHT",
"INNER",
"OUTER",
"CROSS",
"ON",
"AND",
"OR",
"AS",
"DISTINCT",
"UNION",
"EXCEPT",
"INTERSECT",
"CASE",
"WHEN",
"THEN",
"ELSE",
"END",
"IN",
"NOT",
"NULL",
"LIKE",
"BETWEEN",
"EXISTS",
"IS",
];
let prefix_upper = prefix.to_uppercase();
PRIMARY_KEYWORDS
.iter()
.filter(|kw| prefix.is_empty() || kw.starts_with(&prefix_upper))
.map(|kw| CompletionCandidate {
text: (*kw).to_string(),
kind: CompletionKind::Keyword,
score: 200, })
.collect()
}
fn table_candidates(
&self,
metadata: Option<&DatabaseMetadata>,
prefix: &str,
) -> Vec<CompletionCandidate> {
let Some(metadata) = metadata else {
return vec![];
};
let prefix_lower = prefix.to_lowercase();
let mut candidates: Vec<_> = metadata
.table_summaries
.iter()
.filter(|t| {
prefix.is_empty()
|| t.name.to_lowercase().starts_with(&prefix_lower)
|| t.qualified_name().to_lowercase().starts_with(&prefix_lower)
})
.map(|t| {
let name_lower = t.name.to_lowercase();
let is_name_prefix = name_lower.starts_with(&prefix_lower);
let is_qualified_prefix =
t.qualified_name().to_lowercase().starts_with(&prefix_lower);
let score = if is_name_prefix {
100
} else if is_qualified_prefix {
50
} else {
10
};
CompletionCandidate {
text: t.qualified_name(),
kind: CompletionKind::Table,
score,
}
})
.collect();
candidates.sort_by(|a, b| match b.score.cmp(&a.score) {
std::cmp::Ordering::Equal => a.text.cmp(&b.text),
other => other,
});
candidates
.into_iter()
.take(COMPLETION_MAX_CANDIDATES)
.collect()
}
fn column_candidates(
&self,
table_detail: Option<&Table>,
prefix: &str,
) -> Vec<CompletionCandidate> {
self.column_candidates_with_fk(table_detail, prefix, &[])
}
fn column_candidates_with_fk(
&self,
table_detail: Option<&Table>,
prefix: &str,
recent_columns: &[String],
) -> Vec<CompletionCandidate> {
let Some(table) = table_detail else {
return vec![];
};
let prefix_lower = prefix.to_lowercase();
let fk_columns: Vec<&str> = table
.foreign_keys
.iter()
.flat_map(|fk| fk.from_columns.iter().map(String::as_str))
.collect();
let mut candidates: Vec<_> = table
.columns
.iter()
.filter(|c| {
if prefix.is_empty() {
return true;
}
let name_lower = c.name.to_lowercase();
name_lower.starts_with(&prefix_lower) || name_lower.contains(&prefix_lower)
})
.map(|c| {
let name_lower = c.name.to_lowercase();
let is_prefix_match = name_lower.starts_with(&prefix_lower);
let is_contains_match = !is_prefix_match && name_lower.contains(&prefix_lower);
let mut score = if is_prefix_match {
100
} else if is_contains_match {
10
} else {
0
};
if c.is_primary_key {
score += 50;
}
if fk_columns.contains(&c.name.as_str()) {
score += 40;
}
if !c.nullable {
score += 20;
}
if recent_columns.contains(&c.name) {
score += 30;
}
CompletionCandidate {
text: c.name.clone(),
kind: CompletionKind::Column,
score,
}
})
.collect();
candidates.sort_by(|a, b| match b.score.cmp(&a.score) {
std::cmp::Ordering::Equal => a.text.cmp(&b.text),
other => other,
});
candidates
.into_iter()
.take(COMPLETION_MAX_CANDIDATES)
.collect()
}
fn schema_qualified_candidates(
&self,
metadata: Option<&DatabaseMetadata>,
schema: &str,
prefix: &str,
) -> Vec<CompletionCandidate> {
let Some(metadata) = metadata else {
return vec![];
};
let schema_lower = schema.to_lowercase();
let prefix_lower = prefix.to_lowercase();
let mut candidates: Vec<_> = metadata
.table_summaries
.iter()
.filter(|t| {
t.schema.to_lowercase() == schema_lower
&& (prefix.is_empty() || t.name.to_lowercase().starts_with(&prefix_lower))
})
.map(|t| {
let is_prefix_match = t.name.to_lowercase().starts_with(&prefix_lower);
CompletionCandidate {
text: t.name.clone(),
kind: CompletionKind::Table,
score: if is_prefix_match { 100 } else { 10 },
}
})
.collect();
candidates.sort_by(|a, b| match b.score.cmp(&a.score) {
std::cmp::Ordering::Equal => a.text.cmp(&b.text),
other => other,
});
candidates
.into_iter()
.take(COMPLETION_MAX_CANDIDATES)
.collect()
}
fn alias_column_candidates(
&self,
alias: &str,
sql_context: &SqlContext,
metadata: Option<&DatabaseMetadata>,
prefix: &str,
) -> Vec<CompletionCandidate> {
let alias_lower = alias.to_lowercase();
let table_ref = sql_context.tables.iter().find(|t| {
t.alias
.as_ref()
.is_some_and(|a| a.to_lowercase() == alias_lower)
|| t.table.to_lowercase() == alias_lower
});
let Some(table_ref) = table_ref else {
return vec![];
};
let qualified_name = self.qualified_name_from_ref(table_ref, metadata);
if let Some(table) = self.table_detail_cache.peek(&qualified_name) {
return self.column_candidates(Some(table), prefix);
}
vec![]
}
fn cte_or_table_candidates(
&self,
sql_context: &SqlContext,
metadata: Option<&DatabaseMetadata>,
prefix: &str,
) -> Vec<CompletionCandidate> {
let prefix_lower = prefix.to_lowercase();
let mut candidates = Vec::new();
for cte in &sql_context.ctes {
if prefix.is_empty() || cte.name.to_lowercase().starts_with(&prefix_lower) {
candidates.push(CompletionCandidate {
text: cte.name.clone(),
kind: CompletionKind::Table,
score: 110, });
}
}
if let Some(metadata) = metadata {
for t in &metadata.table_summaries {
if prefix.is_empty()
|| t.name.to_lowercase().starts_with(&prefix_lower)
|| t.qualified_name().to_lowercase().starts_with(&prefix_lower)
{
let is_name_prefix = t.name.to_lowercase().starts_with(&prefix_lower);
candidates.push(CompletionCandidate {
text: t.qualified_name(),
kind: CompletionKind::Table,
score: if is_name_prefix { 100 } else { 50 },
});
}
}
}
candidates.sort_by(|a, b| match b.score.cmp(&a.score) {
std::cmp::Ordering::Equal => a.text.cmp(&b.text),
other => other,
});
candidates
.into_iter()
.take(COMPLETION_MAX_CANDIDATES)
.collect()
}
fn qualified_name_from_ref(
&self,
table_ref: &TableReference,
metadata: Option<&DatabaseMetadata>,
) -> String {
if let Some(ref schema) = table_ref.schema {
format!("{}.{}", schema, table_ref.table)
} else if let Some(metadata) = metadata {
metadata
.table_summaries
.iter()
.find(|t| t.name.to_lowercase() == table_ref.table.to_lowercase())
.map_or_else(
|| table_ref.table.clone(),
crate::domain::table::TableSummary::qualified_name,
)
} else {
table_ref.table.clone()
}
}
}
#[cfg(test)]
impl CompletionEngine {
fn analyze(&self, content: &str, cursor_pos: usize) -> (String, CompletionContext) {
let tokens = self.lexer.tokenize(content, cursor_pos);
let sql_context = SqlContext::default();
self.analyze_with_context(content, cursor_pos, &sql_context, &tokens)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{Column, Table};
fn engine() -> CompletionEngine {
CompletionEngine::new()
}
fn create_table(schema: &str, name: &str, columns: &[&str]) -> Table {
Table {
schema: schema.to_string(),
name: name.to_string(),
owner: None,
columns: columns
.iter()
.enumerate()
.map(|(i, col)| Column {
name: (*col).to_string(),
data_type: "text".to_string(),
nullable: true,
default: None,
is_primary_key: false,
is_unique: false,
comment: None,
ordinal_position: (i + 1) as i32,
})
.collect(),
primary_key: None,
indexes: vec![],
foreign_keys: vec![],
rls: None,
triggers: vec![],
row_count_estimate: None,
comment: None,
}
}
fn table_with_two_columns(col1: Column, col2: Column) -> Table {
Table {
schema: "public".to_string(),
name: "test".to_string(),
owner: None,
columns: vec![col1, col2],
primary_key: None,
indexes: vec![],
foreign_keys: vec![],
rls: None,
triggers: vec![],
row_count_estimate: None,
comment: None,
}
}
mod context_detection {
use super::*;
#[test]
fn empty_input_returns_keyword_context() {
let e = engine();
let (token, ctx) = e.analyze("", 0);
assert_eq!(token, "");
assert_eq!(ctx, CompletionContext::Keyword);
}
#[test]
fn after_select_returns_column_context() {
let e = engine();
let (token, ctx) = e.analyze("SELECT ", 7);
assert_eq!(token, "");
assert_eq!(ctx, CompletionContext::Column);
}
#[test]
fn after_from_returns_table_context() {
let e = engine();
let (token, ctx) = e.analyze("SELECT * FROM ", 14);
assert_eq!(token, "");
assert_eq!(ctx, CompletionContext::Table);
}
#[test]
fn after_join_returns_table_context() {
let e = engine();
let (token, ctx) = e.analyze("SELECT * FROM users JOIN ", 25);
assert_eq!(token, "");
assert_eq!(ctx, CompletionContext::Table);
}
#[test]
fn after_where_returns_column_context() {
let e = engine();
let (token, ctx) = e.analyze("SELECT * FROM users WHERE ", 26);
assert_eq!(token, "");
assert_eq!(ctx, CompletionContext::Column);
}
#[test]
fn partial_token_is_extracted() {
let e = engine();
let (token, ctx) = e.analyze("SELECT * FROM us", 16);
assert_eq!(token, "us");
assert_eq!(ctx, CompletionContext::Table);
}
#[test]
fn schema_dot_returns_schema_qualified() {
let e = engine();
let (token, ctx) = e.analyze("SELECT * FROM public.", 21);
assert_eq!(token, "");
assert_eq!(
ctx,
CompletionContext::SchemaQualified("public".to_string())
);
}
#[test]
fn schema_dot_with_partial_table() {
let e = engine();
let (token, ctx) = e.analyze("SELECT * FROM public.us", 23);
assert_eq!(token, "us");
assert_eq!(
ctx,
CompletionContext::SchemaQualified("public".to_string())
);
}
}
mod keyword_completion {
use super::*;
#[test]
fn empty_prefix_returns_all_keywords() {
let e = engine();
let candidates = e.keyword_candidates("");
assert!(!candidates.is_empty());
assert!(candidates.iter().all(|c| c.kind == CompletionKind::Keyword));
}
#[test]
fn sel_prefix_returns_select() {
let e = engine();
let candidates = e.keyword_candidates("SEL");
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].text, "SELECT");
}
#[test]
fn case_insensitive_matching() {
let e = engine();
let candidates = e.keyword_candidates("sel");
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].text, "SELECT");
}
}
mod word_boundary {
use super::*;
#[test]
fn froma_does_not_match_from() {
let e = engine();
let (token, ctx) = e.analyze("SELECT * FROMA", 14);
assert_eq!(token, "FROMA");
assert_eq!(ctx, CompletionContext::Column);
}
#[test]
fn from_with_space_matches_from() {
let e = engine();
let (token, ctx) = e.analyze("SELECT * FROM ", 14);
assert_eq!(token, "");
assert_eq!(ctx, CompletionContext::Table);
}
#[test]
fn from_at_word_boundary_matches() {
let e = engine();
let (_token, ctx) = e.analyze("SELECT * FROM u", 15);
assert_eq!(ctx, CompletionContext::Table);
}
#[test]
fn selecta_does_not_match_select() {
let e = engine();
let (token, ctx) = e.analyze("SELECTA", 7);
assert_eq!(token, "SELECTA");
assert_eq!(ctx, CompletionContext::Keyword);
}
}
mod schema_qualified_limit {
use super::*;
use crate::domain::{DatabaseMetadata, TableSummary};
#[test]
fn schema_qualified_candidates_limited_to_max() {
let e = engine();
let mut tables = vec![];
for i in 0..35 {
tables.push(TableSummary::new(
"public".to_string(),
format!("table_{i:02}"),
Some(100),
false,
));
}
let mut metadata = DatabaseMetadata::new("test_db".to_string());
metadata.table_summaries = tables;
let candidates = e.schema_qualified_candidates(Some(&metadata), "public", "table");
assert_eq!(candidates.len(), COMPLETION_MAX_CANDIDATES);
assert!(candidates.iter().all(|c| c.kind == CompletionKind::Table));
}
#[test]
fn schema_qualified_candidates_with_empty_prefix() {
let e = engine();
let mut tables = vec![];
for i in 0..5 {
tables.push(TableSummary::new(
"myschema".to_string(),
format!("foo_{i}"),
None,
false,
));
}
let mut metadata = DatabaseMetadata::new("test_db".to_string());
metadata.table_summaries = tables;
let candidates = e.schema_qualified_candidates(Some(&metadata), "myschema", "");
assert_eq!(candidates.len(), 5);
}
}
mod prefix_match_ranking {
use super::*;
use crate::domain::{Column, DatabaseMetadata, Table, TableSummary};
#[test]
fn keyword_prefix_match_ranked_first() {
let e = engine();
let candidates = e.keyword_candidates("S");
assert!(!candidates.is_empty());
assert!(candidates.iter().all(|c| c.text.starts_with('S')));
let texts: Vec<_> = candidates.iter().map(|c| c.text.as_str()).collect();
let mut sorted = texts.clone();
sorted.sort_unstable();
assert_eq!(texts, sorted);
}
#[test]
fn table_name_prefix_ranked_over_qualified() {
let e = engine();
let mut metadata = DatabaseMetadata::new("test_db".to_string());
metadata.table_summaries = vec![
TableSummary::new("users".to_string(), "data".to_string(), None, false),
TableSummary::new("public".to_string(), "users".to_string(), None, false),
];
let candidates = e.table_candidates(Some(&metadata), "u");
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[0].text, "public.users");
}
#[test]
fn column_prefix_match_sorted_alphabetically() {
let e = engine();
let table = Table {
schema: "public".to_string(),
name: "test".to_string(),
owner: None,
columns: vec![
Column {
name: "user_name".to_string(),
data_type: "text".to_string(),
nullable: true,
default: None,
is_primary_key: false,
is_unique: false,
comment: None,
ordinal_position: 1,
},
Column {
name: "user_id".to_string(),
data_type: "int".to_string(),
nullable: false,
default: None,
is_primary_key: true,
is_unique: true,
comment: None,
ordinal_position: 2,
},
],
primary_key: Some(vec!["user_id".to_string()]),
indexes: vec![],
foreign_keys: vec![],
rls: None,
triggers: vec![],
row_count_estimate: None,
comment: None,
};
let candidates = e.column_candidates(Some(&table), "user");
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[0].text, "user_id");
assert_eq!(candidates[1].text, "user_name");
}
}
mod string_and_comment_skip {
use super::*;
#[test]
fn inside_single_quote_string_returns_empty() {
let e = engine();
let candidates = e.get_candidates("SELECT 'SEL", 11, None, None, &[]);
assert!(candidates.is_empty());
}
#[test]
fn inside_line_comment_returns_empty() {
let e = engine();
let candidates = e.get_candidates("-- SEL", 6, None, None, &[]);
assert!(candidates.is_empty());
}
#[test]
fn inside_block_comment_returns_empty() {
let e = engine();
let candidates = e.get_candidates("/* SEL", 6, None, None, &[]);
assert!(candidates.is_empty());
}
#[test]
fn inside_dollar_quote_returns_empty() {
let e = engine();
let candidates = e.get_candidates("SELECT $$SEL", 12, None, None, &[]);
assert!(candidates.is_empty());
}
#[test]
fn after_closed_string_returns_candidates() {
let e = engine();
let candidates = e.get_candidates("'value' SEL", 11, None, None, &[]);
assert!(!candidates.is_empty());
assert!(candidates.iter().any(|c| c.text == "SELECT"));
}
#[test]
fn after_closed_comment_returns_candidates() {
let e = engine();
let candidates = e.get_candidates("/* comment */ SEL", 17, None, None, &[]);
assert!(!candidates.is_empty());
assert!(candidates.iter().any(|c| c.text == "SELECT"));
}
}
mod semicolon_suppression {
use super::*;
use rstest::rstest;
#[rstest]
#[case("SELECT * FROM users;", true)] #[case("SELECT * FROM users; ", true)] #[case("SELECT * FROM t WHERE name = 'a;b';", true)] #[case("SELECT * FROM t WHERE name = 'a;b' ", false)] fn suppression_by_trailing_semicolon(#[case] content: &str, #[case] expect_empty: bool) {
let e = engine();
let cursor_pos = content.chars().count();
let candidates = e.get_candidates(content, cursor_pos, None, None, &[]);
assert_eq!(candidates.is_empty(), expect_empty);
}
#[rstest]
#[case("SELECT * FROM t WHERE name = 'あいう';", true)] #[case("SELECT '🎉';", true)] #[case("SELECT 'あいう' FROM ", false)] #[case("SELECT 1; -- 日本語コメント\nSELECT ", false)] #[case("-- 日本語コメント\nSELECT ", false)] fn multibyte_content_does_not_panic(#[case] content: &str, #[case] expect_empty: bool) {
let e = engine();
let cursor_pos = content.chars().count();
let candidates = e.get_candidates(content, cursor_pos, None, None, &[]);
assert_eq!(candidates.is_empty(), expect_empty);
}
#[test]
fn typing_after_semicolon_returns_select_candidate() {
let e = engine();
let content = "SELECT * FROM users; S";
let candidates = e.get_candidates(content, content.chars().count(), None, None, &[]);
assert!(!candidates.is_empty());
assert!(candidates.iter().any(|c| c.text == "SELECT"));
}
}
mod score_ranking {
use super::*;
#[test]
fn pk_column_returns_higher_score() {
let e = engine();
let table = table_with_two_columns(
Column {
name: "name".to_string(),
data_type: "text".to_string(),
nullable: true,
default: None,
is_primary_key: false,
is_unique: false,
comment: None,
ordinal_position: 1,
},
Column {
name: "id".to_string(),
data_type: "int".to_string(),
nullable: false,
default: None,
is_primary_key: true,
is_unique: true,
comment: None,
ordinal_position: 2,
},
);
let candidates = e.column_candidates(Some(&table), "");
assert_eq!(candidates[0].text, "id");
assert!(candidates[0].score > candidates[1].score);
}
#[test]
fn not_null_column_returns_higher_score() {
let e = engine();
let table = table_with_two_columns(
Column {
name: "optional_field".to_string(),
data_type: "text".to_string(),
nullable: true,
default: None,
is_primary_key: false,
is_unique: false,
comment: None,
ordinal_position: 1,
},
Column {
name: "required_field".to_string(),
data_type: "text".to_string(),
nullable: false,
default: None,
is_primary_key: false,
is_unique: false,
comment: None,
ordinal_position: 2,
},
);
let candidates = e.column_candidates(Some(&table), "");
assert_eq!(candidates[0].text, "required_field");
assert!(candidates[0].score > candidates[1].score);
}
}
mod alias_column_context {
use super::*;
use crate::app::policy::sql::lexer::{SqlContext, TableReference};
#[test]
fn alias_dot_returns_alias_column_context() {
let e = engine();
let sql = "SELECT u.";
let tokens = e.lexer.tokenize(sql, sql.len());
let sql_context = SqlContext {
tables: vec![TableReference {
schema: None,
table: "users".to_string(),
alias: Some("u".to_string()),
position: 0,
}],
ctes: vec![],
target_table: None,
};
let (token, ctx) = e.analyze_with_context(sql, 9, &sql_context, &tokens);
assert_eq!(token, "");
assert_eq!(ctx, CompletionContext::AliasColumn("u".to_string()));
}
#[test]
fn alias_dot_partial_column_returns_alias_column_context() {
let e = engine();
let sql = "SELECT u.na";
let tokens = e.lexer.tokenize(sql, sql.len());
let sql_context = SqlContext {
tables: vec![TableReference {
schema: None,
table: "users".to_string(),
alias: Some("u".to_string()),
position: 0,
}],
ctes: vec![],
target_table: None,
};
let (token, ctx) = e.analyze_with_context(sql, 11, &sql_context, &tokens);
assert_eq!(token, "na");
assert_eq!(ctx, CompletionContext::AliasColumn("u".to_string()));
}
#[test]
fn table_name_dot_returns_alias_column_context() {
let e = engine();
let sql = "SELECT users.";
let tokens = e.lexer.tokenize(sql, sql.len());
let sql_context = SqlContext {
tables: vec![TableReference {
schema: None,
table: "users".to_string(),
alias: None,
position: 0,
}],
ctes: vec![],
target_table: None,
};
let (token, ctx) = e.analyze_with_context(sql, 13, &sql_context, &tokens);
assert_eq!(token, "");
assert_eq!(ctx, CompletionContext::AliasColumn("users".to_string()));
}
#[test]
fn unknown_alias_dot_returns_schema_qualified() {
let e = engine();
let sql = "SELECT public.";
let tokens = e.lexer.tokenize(sql, sql.len());
let sql_context = SqlContext {
tables: vec![TableReference {
schema: None,
table: "users".to_string(),
alias: Some("u".to_string()),
position: 0,
}],
ctes: vec![],
target_table: None,
};
let (token, ctx) = e.analyze_with_context(sql, 14, &sql_context, &tokens);
assert_eq!(token, "");
assert_eq!(
ctx,
CompletionContext::SchemaQualified("public".to_string())
);
}
}
mod cte_or_table_context {
use super::*;
use crate::app::policy::sql::lexer::{CteDefinition, SqlContext};
use crate::domain::DatabaseMetadata;
#[test]
fn from_clause_with_cte_returns_cte_or_table() {
let e = engine();
let sql = "WITH active_users AS (SELECT 1) SELECT * FROM ";
let tokens = e.lexer.tokenize(sql, sql.len());
let sql_context = SqlContext {
tables: vec![],
ctes: vec![CteDefinition {
name: "active_users".to_string(),
position: 5,
}],
target_table: None,
};
let (token, ctx) = e.analyze_with_context(sql, 46, &sql_context, &tokens);
assert_eq!(token, "");
assert_eq!(ctx, CompletionContext::CteOrTable);
}
#[test]
fn cte_candidates_ranked_higher_than_tables() {
let e = engine();
let sql_context = SqlContext {
tables: vec![],
ctes: vec![CteDefinition {
name: "active_users".to_string(),
position: 5,
}],
target_table: None,
};
let mut metadata = DatabaseMetadata::new("test".to_string());
metadata.table_summaries = vec![crate::domain::TableSummary::new(
"public".to_string(),
"users".to_string(),
None,
false,
)];
let candidates = e.cte_or_table_candidates(&sql_context, Some(&metadata), "");
assert!(!candidates.is_empty());
assert_eq!(candidates[0].text, "active_users");
assert!(candidates[0].score > candidates[1].score);
}
#[test]
fn cte_prefix_filter_works() {
let e = engine();
let sql_context = SqlContext {
tables: vec![],
ctes: vec![
CteDefinition {
name: "active_users".to_string(),
position: 5,
},
CteDefinition {
name: "banned_users".to_string(),
position: 50,
},
],
target_table: None,
};
let candidates = e.cte_or_table_candidates(&sql_context, None, "act");
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].text, "active_users");
}
}
mod alias_column_completion {
use super::*;
use crate::app::policy::sql::lexer::{SqlContext, TableReference};
use crate::domain::{Column, DatabaseMetadata, Table, TableSummary};
#[test]
fn cached_table_returns_columns() {
let mut e = engine();
let table = Table {
schema: "public".to_string(),
name: "users".to_string(),
owner: None,
columns: vec![
Column {
name: "id".to_string(),
data_type: "int".to_string(),
nullable: false,
default: None,
is_primary_key: true,
is_unique: true,
comment: None,
ordinal_position: 1,
},
Column {
name: "name".to_string(),
data_type: "text".to_string(),
nullable: true,
default: None,
is_primary_key: false,
is_unique: false,
comment: None,
ordinal_position: 2,
},
],
primary_key: Some(vec!["id".to_string()]),
indexes: vec![],
foreign_keys: vec![],
rls: None,
triggers: vec![],
row_count_estimate: None,
comment: None,
};
e.cache_table_detail("public.users".to_string(), table);
let sql_context = SqlContext {
tables: vec![TableReference {
schema: Some("public".to_string()),
table: "users".to_string(),
alias: Some("u".to_string()),
position: 0,
}],
ctes: vec![],
target_table: None,
};
let mut metadata = DatabaseMetadata::new("test".to_string());
metadata.table_summaries = vec![TableSummary::new(
"public".to_string(),
"users".to_string(),
None,
false,
)];
let candidates = e.alias_column_candidates("u", &sql_context, Some(&metadata), "");
assert_eq!(candidates.len(), 2);
assert!(candidates.iter().any(|c| c.text == "id"));
assert!(candidates.iter().any(|c| c.text == "name"));
}
#[test]
fn non_cached_table_returns_empty() {
let e = engine();
let sql_context = SqlContext {
tables: vec![TableReference {
schema: None,
table: "users".to_string(),
alias: Some("u".to_string()),
position: 0,
}],
ctes: vec![],
target_table: None,
};
let candidates = e.alias_column_candidates("u", &sql_context, None, "");
assert!(candidates.is_empty());
}
#[test]
fn alias_prefix_filters_columns() {
let mut e = engine();
let table = Table {
schema: "public".to_string(),
name: "users".to_string(),
owner: None,
columns: vec![
Column {
name: "user_id".to_string(),
data_type: "int".to_string(),
nullable: false,
default: None,
is_primary_key: true,
is_unique: true,
comment: None,
ordinal_position: 1,
},
Column {
name: "username".to_string(),
data_type: "text".to_string(),
nullable: true,
default: None,
is_primary_key: false,
is_unique: false,
comment: None,
ordinal_position: 2,
},
Column {
name: "email".to_string(),
data_type: "text".to_string(),
nullable: true,
default: None,
is_primary_key: false,
is_unique: false,
comment: None,
ordinal_position: 3,
},
],
primary_key: Some(vec!["user_id".to_string()]),
indexes: vec![],
foreign_keys: vec![],
rls: None,
triggers: vec![],
row_count_estimate: None,
comment: None,
};
e.cache_table_detail("public.users".to_string(), table);
let sql_context = SqlContext {
tables: vec![TableReference {
schema: Some("public".to_string()),
table: "users".to_string(),
alias: Some("u".to_string()),
position: 0,
}],
ctes: vec![],
target_table: None,
};
let mut metadata = DatabaseMetadata::new("test".to_string());
metadata.table_summaries = vec![TableSummary::new(
"public".to_string(),
"users".to_string(),
None,
false,
)];
let candidates = e.alias_column_candidates("u", &sql_context, Some(&metadata), "user");
assert_eq!(candidates.len(), 2);
assert!(candidates.iter().any(|c| c.text == "user_id"));
assert!(candidates.iter().any(|c| c.text == "username"));
}
}
mod fk_column_scoring {
use super::*;
use crate::domain::{Column, FkAction, ForeignKey, Table};
fn create_table_with_fk() -> Table {
Table {
schema: "public".to_string(),
name: "orders".to_string(),
owner: None,
columns: vec![
Column {
name: "id".to_string(),
data_type: "int".to_string(),
nullable: false,
default: None,
is_primary_key: true,
is_unique: true,
comment: None,
ordinal_position: 1,
},
Column {
name: "user_id".to_string(),
data_type: "int".to_string(),
nullable: false,
default: None,
is_primary_key: false,
is_unique: false,
comment: None,
ordinal_position: 2,
},
Column {
name: "status".to_string(),
data_type: "text".to_string(),
nullable: true,
default: None,
is_primary_key: false,
is_unique: false,
comment: None,
ordinal_position: 3,
},
],
primary_key: Some(vec!["id".to_string()]),
foreign_keys: vec![ForeignKey {
name: "fk_orders_users".to_string(),
from_schema: "public".to_string(),
from_table: "orders".to_string(),
from_columns: vec!["user_id".to_string()],
to_schema: "public".to_string(),
to_table: "users".to_string(),
to_columns: vec!["id".to_string()],
on_delete: FkAction::NoAction,
on_update: FkAction::NoAction,
}],
indexes: vec![],
rls: None,
triggers: vec![],
row_count_estimate: None,
comment: None,
}
}
#[test]
fn fk_column_returns_higher_score() {
let e = engine();
let table = create_table_with_fk();
let candidates = e.column_candidates_with_fk(Some(&table), "", &[]);
let id_score = candidates.iter().find(|c| c.text == "id").unwrap().score;
let user_id_score = candidates
.iter()
.find(|c| c.text == "user_id")
.unwrap()
.score;
let status_score = candidates
.iter()
.find(|c| c.text == "status")
.unwrap()
.score;
assert!(id_score > user_id_score);
assert!(user_id_score > status_score);
}
#[test]
fn fk_column_with_prefix_match_returns_boosted_score() {
let e = engine();
let table = create_table_with_fk();
let candidates = e.column_candidates_with_fk(Some(&table), "user", &[]);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].text, "user_id");
assert_eq!(candidates[0].score, 160);
}
}
mod contains_match {
use super::*;
use crate::domain::{Column, Table};
#[test]
fn finds_contains_matches() {
let e = engine();
let table = Table {
schema: "public".to_string(),
name: "test".to_string(),
owner: None,
columns: vec![
Column {
name: "user_id".to_string(),
data_type: "int".to_string(),
nullable: true,
default: None,
is_primary_key: false,
is_unique: false,
comment: None,
ordinal_position: 1,
},
Column {
name: "created_at".to_string(),
data_type: "timestamp".to_string(),
nullable: true,
default: None,
is_primary_key: false,
is_unique: false,
comment: None,
ordinal_position: 2,
},
],
primary_key: None,
indexes: vec![],
foreign_keys: vec![],
rls: None,
triggers: vec![],
row_count_estimate: None,
comment: None,
};
let candidates = e.column_candidates_with_fk(Some(&table), "id", &[]);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].text, "user_id");
}
#[test]
fn prefix_match_ranked_higher_than_contains() {
let e = engine();
let table = Table {
schema: "public".to_string(),
name: "test".to_string(),
owner: None,
columns: vec![
Column {
name: "id".to_string(),
data_type: "int".to_string(),
nullable: true,
default: None,
is_primary_key: false,
is_unique: false,
comment: None,
ordinal_position: 1,
},
Column {
name: "user_id".to_string(),
data_type: "int".to_string(),
nullable: true,
default: None,
is_primary_key: false,
is_unique: false,
comment: None,
ordinal_position: 2,
},
],
primary_key: None,
indexes: vec![],
foreign_keys: vec![],
rls: None,
triggers: vec![],
row_count_estimate: None,
comment: None,
};
let candidates = e.column_candidates_with_fk(Some(&table), "id", &[]);
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[0].text, "id");
assert_eq!(candidates[1].text, "user_id");
assert!(candidates[0].score > candidates[1].score);
}
}
mod recent_columns_scoring {
use super::*;
use crate::domain::{Column, Table};
#[test]
fn recent_column_returns_boosted_score() {
let e = engine();
let table = Table {
schema: "public".to_string(),
name: "test".to_string(),
owner: None,
columns: vec![
Column {
name: "name".to_string(),
data_type: "text".to_string(),
nullable: true,
default: None,
is_primary_key: false,
is_unique: false,
comment: None,
ordinal_position: 1,
},
Column {
name: "email".to_string(),
data_type: "text".to_string(),
nullable: true,
default: None,
is_primary_key: false,
is_unique: false,
comment: None,
ordinal_position: 2,
},
],
primary_key: None,
indexes: vec![],
foreign_keys: vec![],
rls: None,
triggers: vec![],
row_count_estimate: None,
comment: None,
};
let recent = vec!["email".to_string()];
let candidates = e.column_candidates_with_fk(Some(&table), "", &recent);
let email_score = candidates.iter().find(|c| c.text == "email").unwrap().score;
let name_score = candidates.iter().find(|c| c.text == "name").unwrap().score;
assert!(email_score > name_score);
assert_eq!(email_score - name_score, 30);
}
}
mod regression_tests {
use super::*;
#[test]
fn select_xxx_f_returns_from_keyword() {
let e = engine();
let candidates = e.get_candidates("SELECT xxx F", 12, None, None, &[]);
assert!(!candidates.is_empty());
assert!(candidates.iter().any(|c| c.text == "FROM"));
}
#[test]
fn keyword_in_string_does_not_affect_context() {
let e = engine();
let candidates = e.get_candidates("SELECT 'FROM' ", 14, None, None, &[]);
assert!(!candidates.is_empty());
assert!(candidates.iter().any(|c| c.kind == CompletionKind::Keyword));
}
#[test]
fn keyword_in_comment_does_not_affect_context() {
let e = engine();
let candidates = e.get_candidates("SELECT -- FROM\n", 15, None, None, &[]);
assert!(!candidates.is_empty());
assert!(candidates.iter().any(|c| c.kind == CompletionKind::Keyword));
}
}
mod missing_tables {
use super::*;
use crate::domain::{Column, DatabaseMetadata, Table, TableSummary};
#[test]
fn empty_sql_returns_empty() {
let e = engine();
let missing = e.missing_tables("", None);
assert!(missing.is_empty());
}
#[test]
fn simple_from_returns_table() {
let e = engine();
let mut metadata = DatabaseMetadata::new("test".to_string());
metadata.table_summaries = vec![TableSummary::new(
"public".to_string(),
"users".to_string(),
None,
false,
)];
let missing = e.missing_tables("SELECT * FROM users", Some(&metadata));
assert_eq!(missing.len(), 1);
assert_eq!(missing[0], "public.users");
}
#[test]
fn schema_qualified_table_returns_qualified_name() {
let e = engine();
let missing = e.missing_tables("SELECT * FROM public.orders", None);
assert_eq!(missing.len(), 1);
assert_eq!(missing[0], "public.orders");
}
#[test]
fn multiple_tables_returns_all() {
let e = engine();
let mut metadata = DatabaseMetadata::new("test".to_string());
metadata.table_summaries = vec![
TableSummary::new("public".to_string(), "users".to_string(), None, false),
TableSummary::new("public".to_string(), "orders".to_string(), None, false),
];
let missing = e.missing_tables(
"SELECT * FROM users u JOIN orders o ON u.id = o.user_id",
Some(&metadata),
);
assert_eq!(missing.len(), 2);
assert!(missing.contains(&"public.users".to_string()));
assert!(missing.contains(&"public.orders".to_string()));
}
#[test]
fn cached_tables_are_excluded() {
let mut e = engine();
let table = Table {
schema: "public".to_string(),
name: "users".to_string(),
owner: None,
columns: vec![Column {
name: "id".to_string(),
data_type: "int".to_string(),
nullable: false,
default: None,
is_primary_key: true,
is_unique: true,
comment: None,
ordinal_position: 1,
}],
primary_key: None,
indexes: vec![],
foreign_keys: vec![],
rls: None,
triggers: vec![],
row_count_estimate: None,
comment: None,
};
e.cache_table_detail("public.users".to_string(), table);
let mut metadata = DatabaseMetadata::new("test".to_string());
metadata.table_summaries = vec![
TableSummary::new("public".to_string(), "users".to_string(), None, false),
TableSummary::new("public".to_string(), "orders".to_string(), None, false),
];
let missing = e.missing_tables(
"SELECT * FROM users u JOIN orders o ON u.id = o.user_id",
Some(&metadata),
);
assert_eq!(missing.len(), 1);
assert_eq!(missing[0], "public.orders");
}
#[test]
fn cte_tables_are_excluded() {
let e = engine();
let mut metadata = DatabaseMetadata::new("test".to_string());
metadata.table_summaries = vec![TableSummary::new(
"public".to_string(),
"users".to_string(),
None,
false,
)];
let missing = e.missing_tables(
"WITH recent AS (SELECT * FROM users) SELECT * FROM recent",
Some(&metadata),
);
assert_eq!(missing.len(), 1);
assert_eq!(missing[0], "public.users");
}
#[test]
fn duplicate_tables_are_deduplicated() {
let e = engine();
let mut metadata = DatabaseMetadata::new("test".to_string());
metadata.table_summaries = vec![TableSummary::new(
"public".to_string(),
"users".to_string(),
None,
false,
)];
let missing = e.missing_tables(
"SELECT * FROM users u1 JOIN users u2 ON u1.id = u2.id",
Some(&metadata),
);
assert_eq!(missing.len(), 1);
assert_eq!(missing[0], "public.users");
}
#[test]
fn max_limit_is_respected() {
let e = engine();
let joins = (1..15)
.map(|i| format!("JOIN public.table_{i} t{i} ON t0.id = t{i}.id"))
.collect::<Vec<_>>()
.join(" ");
let sql = format!("SELECT * FROM public.table_0 t0 {joins}");
let missing = e.missing_tables(&sql, None);
assert_eq!(missing.len(), 10);
}
#[test]
fn has_cached_table_returns_true_for_cached() {
let mut e = engine();
let table = Table {
schema: "public".to_string(),
name: "users".to_string(),
owner: None,
columns: vec![],
primary_key: None,
indexes: vec![],
foreign_keys: vec![],
rls: None,
triggers: vec![],
row_count_estimate: None,
comment: None,
};
e.cache_table_detail("public.users".to_string(), table);
assert!(e.has_cached_table("public.users"));
assert!(!e.has_cached_table("public.orders"));
}
fn make_table(schema: &str, name: &str) -> Table {
Table {
schema: schema.to_string(),
name: name.to_string(),
owner: None,
columns: vec![],
primary_key: None,
indexes: vec![],
foreign_keys: vec![],
rls: None,
triggers: vec![],
row_count_estimate: None,
comment: None,
}
}
#[test]
fn evict_tables_removes_specified_entries() {
let mut e = engine();
e.cache_table_detail("public.users".to_string(), make_table("public", "users"));
e.cache_table_detail("public.orders".to_string(), make_table("public", "orders"));
e.cache_table_detail("public.items".to_string(), make_table("public", "items"));
e.evict_tables(&["public.users".to_string(), "public.orders".to_string()]);
assert!(!e.has_cached_table("public.users"));
assert!(!e.has_cached_table("public.orders"));
assert!(e.has_cached_table("public.items"));
}
#[test]
fn evict_tables_ignores_missing_keys() {
let mut e = engine();
e.cache_table_detail("public.users".to_string(), make_table("public", "users"));
e.evict_tables(&["public.nonexistent".to_string()]);
assert!(e.has_cached_table("public.users"));
}
}
mod integration_tests {
use super::*;
use crate::domain::{Column, DatabaseMetadata, Table, TableSummary};
fn create_users_table() -> Table {
Table {
schema: "public".to_string(),
name: "users".to_string(),
owner: None,
columns: vec![
Column {
name: "id".to_string(),
data_type: "int".to_string(),
nullable: false,
default: None,
is_primary_key: true,
is_unique: true,
comment: None,
ordinal_position: 1,
},
Column {
name: "name".to_string(),
data_type: "text".to_string(),
nullable: true,
default: None,
is_primary_key: false,
is_unique: false,
comment: None,
ordinal_position: 2,
},
Column {
name: "email".to_string(),
data_type: "text".to_string(),
nullable: false,
default: None,
is_primary_key: false,
is_unique: true,
comment: None,
ordinal_position: 3,
},
],
primary_key: Some(vec!["id".to_string()]),
indexes: vec![],
foreign_keys: vec![],
rls: None,
triggers: vec![],
row_count_estimate: None,
comment: None,
}
}
#[test]
fn get_candidates_with_table_detail_returns_columns() {
let e = engine();
let table = create_users_table();
let candidates = e.get_candidates("SELECT ", 7, None, Some(&table), &[]);
assert!(!candidates.is_empty());
assert!(
candidates
.iter()
.any(|c| c.text == "id" && c.kind == CompletionKind::Column)
);
assert!(
candidates
.iter()
.any(|c| c.text == "name" && c.kind == CompletionKind::Column)
);
assert!(
candidates
.iter()
.any(|c| c.text == "email" && c.kind == CompletionKind::Column)
);
}
#[test]
fn get_candidates_with_cached_table_returns_alias_columns() {
let mut e = engine();
let table = create_users_table();
e.cache_table_detail("public.users".to_string(), table);
let mut metadata = DatabaseMetadata::new("test".to_string());
metadata.table_summaries = vec![TableSummary::new(
"public".to_string(),
"users".to_string(),
None,
false,
)];
let candidates = e.get_candidates(
"SELECT u. FROM public.users u",
9,
Some(&metadata),
None,
&[],
);
assert!(!candidates.is_empty());
assert!(candidates.iter().any(|c| c.text == "id"));
assert!(candidates.iter().any(|c| c.text == "name"));
assert!(candidates.iter().any(|c| c.text == "email"));
}
#[test]
fn select_clause_with_table_detail_shows_column_candidates() {
let e = engine();
let table = create_users_table();
let candidates = e.get_candidates("SELECT n", 8, None, Some(&table), &[]);
assert!(
candidates
.iter()
.any(|c| c.text == "name" && c.kind == CompletionKind::Column)
);
}
#[test]
fn where_clause_with_table_detail_shows_column_candidates() {
let e = engine();
let table = create_users_table();
let candidates =
e.get_candidates("SELECT * FROM users WHERE ", 26, None, Some(&table), &[]);
assert!(!candidates.is_empty());
assert!(
candidates
.iter()
.any(|c| c.text == "id" && c.kind == CompletionKind::Column)
);
}
#[test]
fn alias_completion_without_cache_falls_back_to_keywords() {
let e = engine();
let mut metadata = DatabaseMetadata::new("test".to_string());
metadata.table_summaries = vec![TableSummary::new(
"public".to_string(),
"users".to_string(),
None,
false,
)];
let candidates = e.get_candidates(
"SELECT u. FROM public.users u",
9,
Some(&metadata),
None,
&[],
);
assert!(candidates.iter().any(|c| c.kind == CompletionKind::Keyword));
}
#[test]
fn from_keyword_appears_even_with_column_candidates() {
let e = engine();
let table = create_users_table();
let candidates = e.get_candidates("SELECT xxx F", 12, None, Some(&table), &[]);
assert!(
candidates.iter().any(|c| c.text == "FROM"),
"FROM keyword should appear in candidates"
);
let from_candidate = candidates.iter().find(|c| c.text == "FROM").unwrap();
assert_eq!(from_candidate.score, 200, "FROM should have score 200");
}
#[test]
fn column_context_mixes_keywords_and_columns() {
let e = engine();
let table = create_users_table();
let candidates = e.get_candidates("SELECT ", 7, None, Some(&table), &[]);
assert!(
candidates.iter().any(|c| c.kind == CompletionKind::Keyword),
"Should include keywords"
);
assert!(
candidates.iter().any(|c| c.kind == CompletionKind::Column),
"Should include columns"
);
let first_keyword_idx = candidates
.iter()
.position(|c| c.kind == CompletionKind::Keyword);
let first_column_idx = candidates
.iter()
.position(|c| c.kind == CompletionKind::Column);
assert!(
first_keyword_idx < first_column_idx,
"Keywords should appear before columns"
);
}
#[test]
fn order_by_keywords_appear_together() {
let e = engine();
let table = create_users_table();
let candidates =
e.get_candidates("SELECT * FROM t ORDER ", 22, None, Some(&table), &[]);
assert!(
candidates.iter().any(|c| c.text == "BY"),
"BY keyword should appear after ORDER"
);
}
#[test]
fn duplicate_text_is_deduplicated() {
let e = engine();
let table = Table {
schema: "public".to_string(),
name: "test".to_string(),
owner: None,
columns: vec![Column {
name: "and".to_string(), data_type: "text".to_string(),
nullable: true,
default: None,
is_primary_key: false,
is_unique: false,
comment: None,
ordinal_position: 1,
}],
primary_key: None,
indexes: vec![],
foreign_keys: vec![],
rls: None,
triggers: vec![],
row_count_estimate: None,
comment: None,
};
let candidates = e.get_candidates("SELECT ", 7, None, Some(&table), &[]);
let and_count = candidates
.iter()
.filter(|c| c.text.to_uppercase() == "AND")
.count();
assert_eq!(and_count, 1, "AND should appear only once (deduplicated)");
}
#[test]
fn empty_prefix_shows_keywords_first() {
let e = engine();
let table = create_users_table();
let candidates = e.get_candidates("SELECT ", 7, None, Some(&table), &[]);
assert_eq!(
candidates[0].kind,
CompletionKind::Keyword,
"With empty prefix, keywords should come first"
);
}
#[test]
fn non_empty_prefix_shows_columns_first() {
let e = engine();
let table = create_users_table();
let candidates = e.get_candidates("SELECT na", 9, None, Some(&table), &[]);
assert_eq!(candidates[0].text, "name");
assert_eq!(
candidates[0].kind,
CompletionKind::Column,
"With prefix, matching columns should come first"
);
}
#[test]
fn single_char_prefix_keeps_keywords_first() {
let e = engine();
let table = create_users_table();
let candidates = e.get_candidates("SELECT n", 8, None, Some(&table), &[]);
assert!(candidates.iter().any(|c| c.text == "name"));
assert!(candidates.iter().any(|c| c.text == "NOT"));
assert_eq!(candidates[0].kind, CompletionKind::Keyword);
}
#[test]
fn two_char_prefix_boosts_columns() {
let e = engine();
let table = create_users_table();
let candidates = e.get_candidates("SELECT na", 9, None, Some(&table), &[]);
assert_eq!(candidates[0].text, "name");
assert_eq!(candidates[0].kind, CompletionKind::Column);
}
}
mod target_table_boost {
use super::*;
use crate::domain::{DatabaseMetadata, TableSummary};
#[test]
fn update_target_columns_get_boost() {
let mut e = engine();
let users = create_table("public", "users", &["id", "name", "email"]);
let orders = create_table("public", "orders", &["id", "user_id", "total"]);
e.cache_table_detail("public.users".to_string(), users.clone());
e.cache_table_detail("public.orders".to_string(), orders);
let mut metadata = DatabaseMetadata::new("test".to_string());
metadata.table_summaries = vec![
TableSummary::new("public".to_string(), "users".to_string(), None, false),
TableSummary::new("public".to_string(), "orders".to_string(), None, false),
];
let candidates = e.get_candidates(
"UPDATE users SET name = (SELECT user_id FROM orders) WHERE ",
59,
Some(&metadata),
Some(&users),
&[],
);
let users_name = candidates.iter().find(|c| c.text == "name");
let orders_user_id = candidates.iter().find(|c| c.text == "user_id");
assert!(users_name.is_some(), "users.name should be in candidates");
assert!(
orders_user_id.is_some(),
"orders.user_id should be in candidates"
);
assert!(
users_name.unwrap().score > orders_user_id.unwrap().score,
"Target table column should be prioritized"
);
}
#[test]
fn select_has_no_target_boost() {
let mut e = engine();
let users = create_table("public", "users", &["id", "name"]);
e.cache_table_detail("public.users".to_string(), users.clone());
let mut metadata = DatabaseMetadata::new("test".to_string());
metadata.table_summaries = vec![TableSummary::new(
"public".to_string(),
"users".to_string(),
None,
false,
)];
let candidates = e.get_candidates("SELECT ", 7, Some(&metadata), Some(&users), &[]);
let name_candidate = candidates.iter().find(|c| c.text == "name");
assert!(name_candidate.is_some());
assert!(name_candidate.unwrap().score < 200);
}
}
mod all_cache_columns {
use super::*;
use crate::domain::{DatabaseMetadata, TableSummary};
#[test]
fn no_from_with_2char_prefix_returns_all_cached_columns() {
let mut e = engine();
let users = create_table("public", "users", &["id", "name", "email"]);
let orders = create_table("public", "orders", &["id", "user_id", "total"]);
e.cache_table_detail("public.users".to_string(), users);
e.cache_table_detail("public.orders".to_string(), orders);
let metadata = DatabaseMetadata::new("test".to_string());
let candidates = e.get_candidates("SELECT na", 9, Some(&metadata), None, &[]);
let name_candidate = candidates.iter().find(|c| c.text == "name");
assert!(name_candidate.is_some());
}
#[test]
fn no_from_with_empty_prefix_returns_all_cached_columns() {
let mut e = engine();
let users = create_table("public", "users", &["id", "name"]);
let orders = create_table("public", "orders", &["order_id", "user_id"]);
e.cache_table_detail("public.users".to_string(), users);
e.cache_table_detail("public.orders".to_string(), orders);
let metadata = DatabaseMetadata::new("test".to_string());
let candidates = e.get_candidates("SELECT ", 7, Some(&metadata), None, &[]);
let column_count = candidates
.iter()
.filter(|c| c.kind == CompletionKind::Column)
.count();
assert!(column_count > 0);
}
#[test]
fn from_clause_present_returns_only_referenced_table_columns() {
let mut e = engine();
let users = create_table("public", "users", &["id", "name"]);
let orders = create_table("public", "orders", &["order_id", "user_id"]);
e.cache_table_detail("public.users".to_string(), users);
e.cache_table_detail("public.orders".to_string(), orders);
let mut metadata = DatabaseMetadata::new("test".to_string());
metadata.table_summaries = vec![
TableSummary::new("public".to_string(), "users".to_string(), None, false),
TableSummary::new("public".to_string(), "orders".to_string(), None, false),
];
let candidates =
e.get_candidates("SELECT na FROM users", 9, Some(&metadata), None, &[]);
let name = candidates.iter().find(|c| c.text == "name");
let user_id = candidates.iter().find(|c| c.text == "user_id");
assert!(name.is_some());
assert!(user_id.is_none());
}
}
mod lru_cache_behavior {
use super::*;
use crate::domain::TableSummary;
#[test]
fn evicted_table_appears_in_missing_tables() {
let mut e = CompletionEngine::new_with_capacity(2);
let t1 = create_table("public", "t1", &["id"]);
let t2 = create_table("public", "t2", &["id"]);
let t3 = create_table("public", "t3", &["id"]);
e.cache_table_detail("public.t1".to_string(), t1);
e.cache_table_detail("public.t2".to_string(), t2);
e.cache_table_detail("public.t3".to_string(), t3);
assert!(!e.has_cached_table("public.t1"));
assert!(e.has_cached_table("public.t2"));
assert!(e.has_cached_table("public.t3"));
let mut metadata = DatabaseMetadata::new("test".to_string());
metadata.table_summaries = vec![
TableSummary::new("public".to_string(), "t1".to_string(), None, false),
TableSummary::new("public".to_string(), "t2".to_string(), None, false),
TableSummary::new("public".to_string(), "t3".to_string(), None, false),
];
let missing = e.missing_tables("SELECT * FROM t1", Some(&metadata));
assert_eq!(missing, vec!["public.t1".to_string()]);
}
#[test]
fn cached_table_not_in_missing_tables() {
let mut e = CompletionEngine::new_with_capacity(2);
let t1 = create_table("public", "t1", &["id"]);
e.cache_table_detail("public.t1".to_string(), t1);
let mut metadata = DatabaseMetadata::new("test".to_string());
metadata.table_summaries = vec![TableSummary::new(
"public".to_string(),
"t1".to_string(),
None,
false,
)];
let missing = e.missing_tables("SELECT * FROM t1", Some(&metadata));
assert!(missing.is_empty());
}
#[test]
fn table_details_iter_returns_all_cached() {
let mut e = CompletionEngine::new_with_capacity(3);
let t1 = create_table("public", "t1", &["id"]);
let t2 = create_table("public", "t2", &["id"]);
e.cache_table_detail("public.t1".to_string(), t1);
e.cache_table_detail("public.t2".to_string(), t2);
let names: Vec<_> = e.table_details_iter().map(|(k, _)| k.clone()).collect();
assert_eq!(names.len(), 2);
assert!(names.contains(&"public.t1".to_string()));
assert!(names.contains(&"public.t2".to_string()));
}
#[test]
fn clear_removes_all_cached_tables() {
let mut e = CompletionEngine::new_with_capacity(3);
let t1 = create_table("public", "t1", &["id"]);
let t2 = create_table("public", "t2", &["id"]);
e.cache_table_detail("public.t1".to_string(), t1);
e.cache_table_detail("public.t2".to_string(), t2);
assert!(e.has_cached_table("public.t1"));
assert!(e.has_cached_table("public.t2"));
e.clear_table_cache();
assert!(!e.has_cached_table("public.t1"));
assert!(!e.has_cached_table("public.t2"));
assert_eq!(e.table_details_iter().count(), 0);
}
#[test]
fn lru_eviction_order_is_fifo_without_access() {
let mut e = CompletionEngine::new_with_capacity(2);
e.cache_table_detail(
"public.t1".to_string(),
create_table("public", "t1", &["id"]),
);
e.cache_table_detail(
"public.t2".to_string(),
create_table("public", "t2", &["id"]),
);
e.cache_table_detail(
"public.t3".to_string(),
create_table("public", "t3", &["id"]),
);
assert!(!e.has_cached_table("public.t1")); assert!(e.has_cached_table("public.t2"));
assert!(e.has_cached_table("public.t3"));
}
}
mod prepared_equivalence {
use super::*;
fn meta_with_tables(tables: &[(&str, &str, &[&str])]) -> DatabaseMetadata {
use crate::domain::TableSummary;
let mut meta = DatabaseMetadata::new("test".to_string());
meta.table_summaries = tables
.iter()
.map(|(schema, name, _cols)| {
TableSummary::new(schema.to_string(), name.to_string(), None, false)
})
.collect();
meta
}
#[test]
fn missing_tables_equivalence() {
let e = engine();
let meta = meta_with_tables(&[("public", "users", &["id", "name"])]);
let cases = [
("SELECT * FROM users", 20),
(
"SELECT * FROM users u JOIN orders o ON u.id = o.user_id",
55,
),
("WITH cte AS (SELECT 1) SELECT * FROM cte", 41),
("", 0),
];
for (sql, _) in &cases {
let old = e.missing_tables(sql, Some(&meta));
let prep = e.prepare(sql, sql.len());
let new = e.missing_tables_prepared(&prep, Some(&meta));
assert_eq!(old, new, "mismatch for: {sql}");
}
}
#[test]
fn get_candidates_equivalence() {
let mut e = engine();
e.cache_table_detail(
"public.users".to_string(),
create_table("public", "users", &["id", "name", "email"]),
);
let meta = meta_with_tables(&[("public", "users", &["id", "name", "email"])]);
let table = create_table("public", "users", &["id", "name", "email"]);
let recent: Vec<String> = vec![];
let cases = [
("SELECT ", 7),
("SELECT * FROM ", 14),
("SELECT n", 8),
("SELECT * FROM users WHERE ", 26),
("UPDATE users SET ", 17),
];
for (sql, cursor) in &cases {
let old = e.get_candidates(sql, *cursor, Some(&meta), Some(&table), &recent);
let prep = e.prepare(sql, *cursor);
let new = e.get_candidates_prepared(
sql,
*cursor,
&prep,
Some(&meta),
Some(&table),
&recent,
);
assert_eq!(old, new, "candidates mismatch for: {sql} at {cursor}");
}
}
#[test]
fn current_token_len_equivalence() {
let e = engine();
let cases = [
("SELECT abc", 10),
("SELECT ", 7),
("", 0),
("SELECT あいう", 10), ];
for (sql, cursor) in &cases {
let old = e.current_token_len(sql, *cursor);
let prep = e.prepare(sql, *cursor);
let new = CompletionEngine::current_token_len_prepared(&prep);
assert_eq!(old, new, "token_len mismatch for: {sql} at {cursor}");
}
}
#[test]
fn is_in_string_or_comment_from_tokens_edges() {
let lexer = SqlLexer::new();
let cases = [
("SELECT 'hello'", 10, true), ("SELECT 'hello'", 14, true), ("SELECT 'hello'", 7, false), ("SELECT 'hello' ", 15, false), ("SELECT -- comment", 12, true), ("SELECT -- comment", 7, false), ("SELECT /* block */", 11, true), ("SELECT $$dollar$$", 10, true), ("SELECT 'unclosed", 12, true), ("", 0, false), ];
for (sql, cursor, expected) in &cases {
let old = lexer.is_in_string_or_comment(sql, *cursor);
let tokens = lexer.tokenize(sql, sql.len());
let new = SqlLexer::is_in_string_or_comment_from_tokens(&tokens, *cursor);
assert_eq!(
*expected, new,
"from_tokens mismatch for: {sql} at {cursor}"
);
assert_eq!(
old, new,
"old vs from_tokens mismatch for: {sql} at {cursor}"
);
}
}
#[test]
fn target_table_priority_in_prepared() {
let mut e = engine();
e.cache_table_detail(
"public.users".to_string(),
create_table("public", "users", &["id", "name"]),
);
let meta = meta_with_tables(&[("public", "users", &["id", "name"])]);
let table = create_table("public", "users", &["id", "name"]);
let recent: Vec<String> = vec![];
let sql = "UPDATE users SET ";
let cursor = sql.len();
let old = e.get_candidates(sql, cursor, Some(&meta), Some(&table), &recent);
let prep = e.prepare(sql, cursor);
let new =
e.get_candidates_prepared(sql, cursor, &prep, Some(&meta), Some(&table), &recent);
assert_eq!(old, new, "UPDATE target_table priority mismatch");
let sql = "DELETE FROM users WHERE ";
let cursor = sql.len();
let old = e.get_candidates(sql, cursor, Some(&meta), Some(&table), &recent);
let prep = e.prepare(sql, cursor);
let new =
e.get_candidates_prepared(sql, cursor, &prep, Some(&meta), Some(&table), &recent);
assert_eq!(old, new, "DELETE target_table priority mismatch");
}
}
}