fix(sqlserver): restore original temp table names in completion

This commit is contained in:
zipg authored and GitHub committed 2026-09-21 17:23:42 +08:00
1 parent 3a44507ad2
commit 7b407ccbfc
1 file changed
+156 -5
+156 -5
View File
@@ -2373,11 +2373,74 @@ pub async fn completion_assistant_search(
}
})
.collect::<Vec<_>>();
Ok(crate::types::CompletionAssistantResponse {
incomplete: candidates.len() >= limit,
candidates,
fallback_used: false,
})
// Dedup only shrinks the list, so completeness must be judged on the raw
// candidate count: a result that hit the server-side TOP (limit) must stay
// incomplete even if the translation below collapses duplicates.
let incomplete = candidates.len() >= limit;
let candidates = normalize_sqlserver_completion_candidates(candidates);
Ok(crate::types::CompletionAssistantResponse { incomplete, candidates, fallback_used: false })
}
/// Rewrites the raw catalog names of a completion response into names the user
/// can actually write, and drops the duplicates that translation can create.
fn normalize_sqlserver_completion_candidates(
candidates: Vec<crate::types::CompletionAssistantCandidate>,
) -> Vec<crate::types::CompletionAssistantCandidate> {
let mut seen = std::collections::HashSet::new();
candidates
.into_iter()
.map(|mut candidate| {
candidate.name = sqlserver_original_temp_table_name(&candidate.name).to_string();
candidate
})
// One internal temp table per module can share the same visible name, so
// the translated candidates must not repeat it.
.filter(|candidate| {
// The schema carries the namespace for table/view/routine candidates
// (their parent fields are empty); leaving it out would collapse
// same-named objects from different schemas into one candidate.
seen.insert((
format!("{:?}", candidate.kind),
candidate.schema.clone(),
candidate.name.clone(),
candidate.parent_schema.clone(),
candidate.parent_name.clone(),
))
})
.collect()
}
/// SQL Server stores a temp table that is created inside a module (stored
/// procedure, trigger, function) under a mangled name: the original name is
/// padded with underscores to 116 characters and a 12-character hex stamp is
/// appended, so `#orders` becomes `#orders___...___0000000BFC4EE` (128
/// characters). Only the original name can be referenced from SQL, so
/// completion has to translate the internal name back instead of offering a
/// name the user cannot type or reuse (#9854). Temp table names are limited to
/// 116 characters, so a 128-character `#`-prefixed name is always SQL Server's
/// internal form; `##` global temp tables keep their name unchanged.
fn sqlserver_original_temp_table_name(name: &str) -> &str {
const INTERNAL_TEMP_TABLE_NAME_CHARS: usize = 128;
const TEMP_TABLE_NAME_MAX_CHARS: usize = 116;
if !name.starts_with('#') || name.starts_with("##") {
return name;
}
let chars = name.char_indices().collect::<Vec<_>>();
if chars.len() != INTERNAL_TEMP_TABLE_NAME_CHARS {
return name;
}
let (padded_end, _) = chars[TEMP_TABLE_NAME_MAX_CHARS];
let stamp = &name[padded_end..];
if !stamp.chars().all(|ch| ch.is_ascii_hexdigit()) {
return name;
}
let original = name[..padded_end].trim_end_matches('_');
if original.len() > 1 {
original
} else {
name
}
}
fn sqlserver_completion_candidate_kind(object_type: &str) -> crate::types::CompletionAssistantCandidateKind {
@@ -5072,6 +5135,94 @@ mod tests {
assert!(sql.contains("CAST(NULL AS NVARCHAR(MAX)) AS object_comment"));
}
#[test]
fn sqlserver_completion_translates_mangled_temp_table_names() {
let mangled = |original: &str, stamp: &str| {
let padded = 116usize.saturating_sub(original.chars().count());
format!("{original}{}{stamp}", "_".repeat(padded))
};
let orders = mangled("#orders", "000000BFC4EE");
assert_eq!(orders.chars().count(), 128);
assert_eq!(super::sqlserver_original_temp_table_name(&orders), "#orders");
let unicode_orders = mangled("#订单明细", "00000000000A");
assert_eq!(unicode_orders.chars().count(), 128);
assert_eq!(super::sqlserver_original_temp_table_name(&unicode_orders), "#订单明细");
// Global temp tables keep their name, so the translation must not touch them.
assert_eq!(super::sqlserver_original_temp_table_name("##orders"), "##orders");
// Ordinary temp tables and regular objects are returned unchanged.
assert_eq!(super::sqlserver_original_temp_table_name("#orders"), "#orders");
assert_eq!(super::sqlserver_original_temp_table_name("dbo.orders"), "dbo.orders");
// A 128-character name without the trailing hex stamp is not SQL Server's
// internal temp table form and must survive untouched.
let padded_without_stamp = format!("#{}{}", "o".repeat(115), "_".repeat(12));
assert_eq!(padded_without_stamp.chars().count(), 128);
assert_eq!(super::sqlserver_original_temp_table_name(&padded_without_stamp), padded_without_stamp);
let non_temp = mangled("orders", "000000BFC4EE");
assert_eq!(non_temp.chars().count(), 128);
assert_eq!(super::sqlserver_original_temp_table_name(&non_temp), non_temp);
}
#[test]
fn sqlserver_completion_normalizes_temp_table_candidates() {
let mangled = |original: &str, stamp: &str| {
let padded = 116usize.saturating_sub(original.chars().count());
format!("{original}{}{stamp}", "_".repeat(padded))
};
let candidate = |name: &str| crate::types::CompletionAssistantCandidate {
name: name.to_string(),
kind: crate::types::CompletionAssistantCandidateKind::Table,
database: Some("master".to_string()),
schema: Some("dbo".to_string()),
parent_schema: None,
parent_name: None,
comment: None,
data_type: None,
signature: None,
};
// Two modules creating the same temp table produce two internal names that
// must collapse into the one name the user can reference.
let normalized = super::normalize_sqlserver_completion_candidates(vec![
candidate(&mangled("#ypsl_py", "00000000000D")),
candidate(&mangled("#ypsl_py", "000000BFC4EE")),
candidate("#ypsl_py_extra"),
candidate("dbo.orders"),
]);
assert_eq!(
normalized.iter().map(|candidate| candidate.name.as_str()).collect::<Vec<_>>(),
vec!["#ypsl_py", "#ypsl_py_extra", "dbo.orders"]
);
}
#[test]
fn sqlserver_completion_keeps_same_named_tables_from_different_schemas() {
let candidate = |schema: &str, name: &str| crate::types::CompletionAssistantCandidate {
name: name.to_string(),
kind: crate::types::CompletionAssistantCandidateKind::Table,
database: Some("master".to_string()),
schema: Some(schema.to_string()),
parent_schema: None,
parent_name: None,
comment: None,
data_type: None,
signature: None,
};
let normalized = super::normalize_sqlserver_completion_candidates(vec![
candidate("dbo", "orders"),
candidate("sales", "orders"),
candidate("dbo", "orders"),
]);
assert_eq!(normalized.len(), 2);
assert_eq!(normalized[0].schema.as_deref(), Some("dbo"));
assert_eq!(normalized[1].schema.as_deref(), Some("sales"));
}
#[test]
fn sqlserver_completion_assistant_generates_scoped_search_masks() {
assert_eq!(super::completion_like_pattern("Temp", Some(&CompletionAssistantMatchMode::Prefix)), "Temp%");