Skip to content

Commit c98a67c

Browse files
committed
feat(lsp): add code action to invalidate the schema cache
1 parent 82d5b38 commit c98a67c

File tree

8 files changed

+222
-3
lines changed

8 files changed

+222
-3
lines changed

crates/pgls_lsp/src/handlers/code_actions.rs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,21 @@ pub fn get_actions(
5353
.map(|reason| CodeActionDisabled { reason }),
5454
..Default::default()
5555
}),
56+
CommandActionCategory::InvalidateSchemaCache => Some(CodeAction {
57+
title: title.clone(),
58+
kind: Some(lsp_types::CodeActionKind::EMPTY),
59+
command: Some({
60+
Command {
61+
title: title.clone(),
62+
command: command_id,
63+
arguments: None,
64+
}
65+
}),
66+
disabled: action
67+
.disabled_reason
68+
.map(|reason| CodeActionDisabled { reason }),
69+
..Default::default()
70+
}),
5671
}
5772
}
5873

@@ -68,7 +83,8 @@ pub fn get_actions(
6883

6984
pub fn command_id(command: &CommandActionCategory) -> String {
7085
match command {
71-
CommandActionCategory::ExecuteStatement(_) => "pgt.executeStatement".into(),
86+
CommandActionCategory::ExecuteStatement(_) => "pgls.executeStatement".into(),
87+
CommandActionCategory::InvalidateSchemaCache => "pgls.invalidateSchemaCache".into(),
7288
}
7389
}
7490

@@ -80,7 +96,7 @@ pub async fn execute_command(
8096
let command = params.command;
8197

8298
match command.as_str() {
83-
"pgt.executeStatement" => {
99+
"pgls.executeStatement" => {
84100
let statement_id = serde_json::from_value::<pgls_workspace::workspace::StatementId>(
85101
params.arguments[0].clone(),
86102
)?;
@@ -105,7 +121,16 @@ pub async fn execute_command(
105121

106122
Ok(None)
107123
}
124+
"pgls.invalidateSchemaCache" => {
125+
session.workspace.invalidate_schema_cache(true)?;
108126

127+
session
128+
.client
129+
.show_message(MessageType::INFO, "Schema cache invalidated")
130+
.await;
131+
132+
Ok(None)
133+
}
109134
any => Err(anyhow!(format!("Unknown command: {}", any))),
110135
}
111136
}

crates/pgls_lsp/src/server.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,7 @@ impl ServerFactory {
461461
workspace_method!(builder, get_completions);
462462
workspace_method!(builder, register_project_folder);
463463
workspace_method!(builder, unregister_project_folder);
464+
workspace_method!(builder, invalidate_schema_cache);
464465

465466
let (service, socket) = builder.finish();
466467
ServerConnection { socket, service }

crates/pgls_lsp/tests/server.rs

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -916,7 +916,7 @@ async fn test_execute_statement(test_db: PgPool) -> Result<()> {
916916
.find_map(|action_or_cmd| match action_or_cmd {
917917
lsp::CodeActionOrCommand::CodeAction(code_action) => {
918918
let command = code_action.command.as_ref();
919-
if command.is_some_and(|cmd| &cmd.command == "pgt.executeStatement") {
919+
if command.is_some_and(|cmd| &cmd.command == "pgls.executeStatement") {
920920
let command = command.unwrap();
921921
let arguments = command.arguments.as_ref().unwrap().clone();
922922
Some((command.command.clone(), arguments))
@@ -952,6 +952,160 @@ async fn test_execute_statement(test_db: PgPool) -> Result<()> {
952952
Ok(())
953953
}
954954

955+
#[sqlx::test(migrator = "pgls_test_utils::MIGRATIONS")]
956+
async fn test_invalidate_schema_cache(test_db: PgPool) -> Result<()> {
957+
let factory = ServerFactory::default();
958+
let mut fs = MemoryFileSystem::default();
959+
960+
let database = test_db
961+
.connect_options()
962+
.get_database()
963+
.unwrap()
964+
.to_string();
965+
let host = test_db.connect_options().get_host().to_string();
966+
967+
// Setup: Create a table with only id column (no name column yet)
968+
let setup = r#"
969+
create table public.users (
970+
id serial primary key
971+
);
972+
"#;
973+
974+
test_db
975+
.execute(setup)
976+
.await
977+
.expect("Failed to setup test database");
978+
979+
let mut conf = PartialConfiguration::init();
980+
conf.merge_with(PartialConfiguration {
981+
db: Some(PartialDatabaseConfiguration {
982+
database: Some(database),
983+
host: Some(host),
984+
..Default::default()
985+
}),
986+
..Default::default()
987+
});
988+
989+
fs.insert(
990+
url!("postgres-language-server.jsonc")
991+
.to_file_path()
992+
.unwrap(),
993+
serde_json::to_string_pretty(&conf).unwrap(),
994+
);
995+
996+
let (service, client) = factory
997+
.create_with_fs(None, DynRef::Owned(Box::new(fs)))
998+
.into_inner();
999+
1000+
let (stream, sink) = client.split();
1001+
let mut server = Server::new(service);
1002+
1003+
let (sender, mut receiver) = channel(CHANNEL_BUFFER_SIZE);
1004+
let reader = tokio::spawn(client_handler(stream, sink, sender));
1005+
1006+
server.initialize().await?;
1007+
server.initialized().await?;
1008+
1009+
server.load_configuration().await?;
1010+
1011+
// Open a document that references a non-existent 'name' column
1012+
let doc_content = "select name from public.users;";
1013+
server.open_document(doc_content).await?;
1014+
1015+
// Wait for typecheck diagnostics showing column doesn't exist
1016+
let got_error = tokio::time::timeout(Duration::from_secs(5), async {
1017+
loop {
1018+
match receiver.next().await {
1019+
Some(ServerNotification::PublishDiagnostics(msg)) => {
1020+
if msg
1021+
.diagnostics
1022+
.iter()
1023+
.any(|d| d.message.contains("column \"name\" does not exist"))
1024+
{
1025+
return true;
1026+
}
1027+
}
1028+
_ => continue,
1029+
}
1030+
}
1031+
})
1032+
.await
1033+
.is_ok();
1034+
1035+
assert!(
1036+
got_error,
1037+
"Expected typecheck error for non-existent column 'name'"
1038+
);
1039+
1040+
// Add the missing column to the database
1041+
let alter_table = r#"
1042+
alter table public.users
1043+
add column name text;
1044+
"#;
1045+
1046+
test_db
1047+
.execute(alter_table)
1048+
.await
1049+
.expect("Failed to add column to table");
1050+
1051+
// Invalidate the schema cache (all = false for current connection only)
1052+
server
1053+
.request::<bool, ()>("pgt/invalidate_schema_cache", "_invalidate_cache", false)
1054+
.await?;
1055+
1056+
// Change the document slightly to trigger re-analysis
1057+
server
1058+
.change_document(
1059+
1,
1060+
vec![TextDocumentContentChangeEvent {
1061+
range: Some(Range {
1062+
start: Position {
1063+
line: 0,
1064+
character: 30,
1065+
},
1066+
end: Position {
1067+
line: 0,
1068+
character: 30,
1069+
},
1070+
}),
1071+
range_length: Some(0),
1072+
text: " ".to_string(),
1073+
}],
1074+
)
1075+
.await?;
1076+
1077+
// Wait for diagnostics to clear (no typecheck error anymore)
1078+
let error_cleared = tokio::time::timeout(Duration::from_secs(5), async {
1079+
loop {
1080+
match receiver.next().await {
1081+
Some(ServerNotification::PublishDiagnostics(msg)) => {
1082+
// Check that there's no typecheck error for the column
1083+
let has_column_error = msg
1084+
.diagnostics
1085+
.iter()
1086+
.any(|d| d.message.contains("column \"name\" does not exist"));
1087+
if !has_column_error {
1088+
return true;
1089+
}
1090+
}
1091+
_ => continue,
1092+
}
1093+
}
1094+
})
1095+
.await
1096+
.is_ok();
1097+
1098+
assert!(
1099+
error_cleared,
1100+
"Expected typecheck error to be cleared after schema cache invalidation"
1101+
);
1102+
1103+
server.shutdown().await?;
1104+
reader.abort();
1105+
1106+
Ok(())
1107+
}
1108+
9551109
#[sqlx::test(migrator = "pgls_test_utils::MIGRATIONS")]
9561110
async fn test_issue_281(test_db: PgPool) -> Result<()> {
9571111
let factory = ServerFactory::default();

crates/pgls_workspace/src/features/code_actions.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ pub struct CommandAction {
4848
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
4949
pub enum CommandActionCategory {
5050
ExecuteStatement(StatementId),
51+
InvalidateSchemaCache,
5152
}
5253

5354
#[derive(Debug, serde::Serialize, serde::Deserialize)]

crates/pgls_workspace/src/workspace.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,14 @@ pub trait Workspace: Send + Sync + RefUnwindSafe {
158158
&self,
159159
params: ExecuteStatementParams,
160160
) -> Result<ExecuteStatementResult, WorkspaceError>;
161+
162+
/// Invalidate the schema cache.
163+
///
164+
/// # Arguments
165+
/// * `all` - If true, clears all cached schemas. If false, clears only the current connection's cache.
166+
///
167+
/// The schema will be reloaded lazily on the next operation that requires it.
168+
fn invalidate_schema_cache(&self, all: bool) -> Result<(), WorkspaceError>;
161169
}
162170

163171
/// Convenience function for constructing a server instance of [Workspace]

crates/pgls_workspace/src/workspace/client.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,8 @@ where
168168
) -> Result<crate::features::on_hover::OnHoverResult, WorkspaceError> {
169169
self.request("pgt/on_hover", params)
170170
}
171+
172+
fn invalidate_schema_cache(&self, all: bool) -> Result<(), WorkspaceError> {
173+
self.request("pgt/invalidate_schema_cache", all)
174+
}
171175
}

crates/pgls_workspace/src/workspace/server.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,19 @@ impl Workspace for WorkspaceServer {
424424
})
425425
}
426426

427+
fn invalidate_schema_cache(&self, all: bool) -> Result<(), WorkspaceError> {
428+
if all {
429+
self.schema_cache.clear_all();
430+
} else {
431+
// Only clear current connection if one exists
432+
if let Some(pool) = self.get_current_connection() {
433+
self.schema_cache.clear(&pool);
434+
}
435+
// If no connection, nothing to clear - just return Ok
436+
}
437+
Ok(())
438+
}
439+
427440
#[ignored_path(path=&params.path)]
428441
fn pull_diagnostics(
429442
&self,

crates/pgls_workspace/src/workspace/server/schema_cache_manager.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,17 @@ impl SchemaCacheManager {
4646
schemas.insert(key, schema_cache.clone());
4747
Ok(schema_cache)
4848
}
49+
50+
/// Clear the schema cache for a specific connection
51+
pub fn clear(&self, pool: &PgPool) {
52+
let key: ConnectionKey = pool.into();
53+
let mut schemas = self.schemas.write().unwrap();
54+
schemas.remove(&key);
55+
}
56+
57+
/// Clear all schema caches
58+
pub fn clear_all(&self) {
59+
let mut schemas = self.schemas.write().unwrap();
60+
schemas.clear();
61+
}
4962
}

0 commit comments

Comments
 (0)