sqlite_graphrag/commands/
prune_ner.rs1use crate::errors::AppError;
8use crate::output::{self, OutputFormat};
9use crate::paths::AppPaths;
10use crate::storage::connection::open_rw;
11use serde::Serialize;
12
13#[derive(clap::Args)]
14#[command(after_long_help = "EXAMPLES:\n \
15 # Preview bindings that would be removed for a single entity\n \
16 sqlite-graphrag prune-ner --entity jwt-token --dry-run\n\n \
17 # Remove all NER bindings for a single entity\n \
18 sqlite-graphrag prune-ner --entity jwt-token --yes\n\n \
19 # Remove ALL NER bindings in the current namespace\n \
20 sqlite-graphrag prune-ner --all --yes\n\n \
21NOTE:\n \
22 This command deletes rows from memory_entities (the link table between\n \
23 memories and extracted entities). The entities and memories themselves\n \
24 are not deleted. Use cleanup-orphans afterwards to remove entity nodes\n \
25 that have no remaining links.")]
26pub struct PruneNerArgs {
28 #[arg(long, conflicts_with = "all", value_name = "NAME")]
31 pub entity: Option<String>,
32
33 #[arg(long, conflicts_with = "entity", default_value_t = false)]
35 pub all: bool,
36
37 #[arg(long)]
39 pub namespace: Option<String>,
40
41 #[arg(long, default_value_t = false)]
43 pub dry_run: bool,
44
45 #[arg(long, default_value_t = false)]
47 pub yes: bool,
48
49 #[arg(long, value_enum, default_value = "json")]
51 pub format: OutputFormat,
52
53 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
55 pub json: bool,
56
57 #[arg(long)]
59 pub db: Option<String>,
60}
61
62#[derive(Serialize)]
63struct PruneNerResponse {
64 action: String,
65 bindings_removed: usize,
66 namespace: String,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 entity: Option<String>,
70 elapsed_ms: u64,
72}
73
74pub fn run(args: PruneNerArgs) -> Result<(), AppError> {
76 let inicio = std::time::Instant::now();
77
78 if args.entity.is_none() && !args.all {
79 return Err(AppError::Validation(
80 "either --entity <NAME> or --all must be specified".to_string(),
81 ));
82 }
83
84 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
85 let paths = AppPaths::resolve(args.db.as_deref())?;
86
87 crate::storage::connection::ensure_db_ready(&paths)?;
88
89 let mut conn = open_rw(&paths.db)?;
90
91 let count: usize = if let Some(ref entity_name) = args.entity {
93 conn.query_row(
94 "SELECT COUNT(*) FROM memory_entities me
95 JOIN entities e ON e.id = me.entity_id
96 WHERE e.name = ?1 AND e.namespace = ?2",
97 rusqlite::params![entity_name, namespace],
98 |r| r.get::<_, i64>(0).map(|v| v as usize),
99 )?
100 } else {
101 conn.query_row(
102 "SELECT COUNT(*) FROM memory_entities me
103 JOIN entities e ON e.id = me.entity_id
104 WHERE e.namespace = ?1",
105 rusqlite::params![namespace],
106 |r| r.get::<_, i64>(0).map(|v| v as usize),
107 )?
108 };
109
110 if args.dry_run {
111 let response = PruneNerResponse {
112 action: "dry_run".to_string(),
113 bindings_removed: count,
114 namespace: namespace.clone(),
115 entity: args.entity.clone(),
116 elapsed_ms: inicio.elapsed().as_millis() as u64,
117 };
118
119 match args.format {
120 OutputFormat::Json => output::emit_json(&response)?,
121 OutputFormat::Text | OutputFormat::Markdown => {
122 output::emit_text(&format!(
123 "dry_run: {count} NER bindings would be removed [{namespace}]"
124 ));
125 }
126 }
127
128 return Ok(());
129 }
130
131 if !args.yes {
132 let response = PruneNerResponse {
133 action: "aborted".to_string(),
134 bindings_removed: count,
135 namespace: namespace.clone(),
136 entity: args.entity.clone(),
137 elapsed_ms: inicio.elapsed().as_millis() as u64,
138 };
139
140 match args.format {
141 OutputFormat::Json => output::emit_json(&response)?,
142 OutputFormat::Text | OutputFormat::Markdown => {
143 output::emit_text(&format!(
144 "aborted: {count} NER bindings would be removed; pass --yes to confirm [{namespace}]"
145 ));
146 }
147 }
148
149 return Ok(());
150 }
151
152 let removed: usize = if let Some(ref entity_name) = args.entity {
154 let entity_name = crate::parsers::normalize_entity_name(entity_name);
156 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
157 let n = tx.execute(
158 "DELETE FROM memory_entities WHERE entity_id IN (
159 SELECT id FROM entities WHERE name = ?1 AND namespace = ?2
160 )",
161 rusqlite::params![entity_name, namespace],
162 )?;
163 tx.commit()?;
164 n
165 } else {
166 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
167 let n = tx.execute(
168 "DELETE FROM memory_entities WHERE entity_id IN (
169 SELECT id FROM entities WHERE namespace = ?1
170 )",
171 rusqlite::params![namespace],
172 )?;
173 tx.commit()?;
174 n
175 };
176
177 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
178
179 tracing::info!(target: "prune_ner",
180 removed = removed,
181 namespace = %namespace,
182 entity = ?args.entity,
183 "NER bindings pruned"
184 );
185
186 let response = PruneNerResponse {
187 action: "pruned".to_string(),
188 bindings_removed: removed,
189 namespace: namespace.clone(),
190 entity: args.entity.clone(),
191 elapsed_ms: inicio.elapsed().as_millis() as u64,
192 };
193
194 match args.format {
195 OutputFormat::Json => output::emit_json(&response)?,
196 OutputFormat::Text | OutputFormat::Markdown => {
197 output::emit_text(&format!(
198 "pruned: {removed} NER bindings removed [{namespace}]"
199 ));
200 }
201 }
202
203 Ok(())
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn prune_ner_response_dry_run_serializes_correctly() {
212 let resp = PruneNerResponse {
213 action: "dry_run".to_string(),
214 bindings_removed: 42,
215 namespace: "global".to_string(),
216 entity: Some("jwt-token".to_string()),
217 elapsed_ms: 5,
218 };
219 let json = serde_json::to_value(&resp).expect("serialization failed");
220 assert_eq!(json["action"], "dry_run");
221 assert_eq!(json["bindings_removed"], 42);
222 assert_eq!(json["entity"], "jwt-token");
223 assert_eq!(json["namespace"], "global");
224 }
225
226 #[test]
227 fn prune_ner_response_pruned_all_omits_entity() {
228 let resp = PruneNerResponse {
229 action: "pruned".to_string(),
230 bindings_removed: 200,
231 namespace: "project-x".to_string(),
232 entity: None,
233 elapsed_ms: 15,
234 };
235 let json = serde_json::to_value(&resp).expect("serialization failed");
236 assert_eq!(json["action"], "pruned");
237 assert_eq!(json["bindings_removed"], 200);
238 assert!(
239 json.get("entity").is_none(),
240 "entity must be omitted when None"
241 );
242 }
243
244 #[test]
245 fn prune_ner_response_aborted_includes_count() {
246 let resp = PruneNerResponse {
247 action: "aborted".to_string(),
248 bindings_removed: 10,
249 namespace: "global".to_string(),
250 entity: None,
251 elapsed_ms: 1,
252 };
253 let json = serde_json::to_value(&resp).expect("serialization failed");
254 assert_eq!(json["action"], "aborted");
255 assert_eq!(json["bindings_removed"], 10);
256 assert!(json["elapsed_ms"].is_number());
257 }
258
259 #[test]
260 fn prune_ner_response_zero_bindings() {
261 let resp = PruneNerResponse {
262 action: "pruned".to_string(),
263 bindings_removed: 0,
264 namespace: "global".to_string(),
265 entity: Some("nonexistent".to_string()),
266 elapsed_ms: 2,
267 };
268 let json = serde_json::to_value(&resp).expect("serialization failed");
269 assert_eq!(json["bindings_removed"], 0);
270 }
271}