1use crate::entity_type::EntityType;
13use crate::errors::AppError;
14use crate::output::{self, OutputFormat};
15use crate::paths::AppPaths;
16use crate::storage::connection::open_rw;
17use rusqlite::params;
18use serde::Serialize;
19
20#[derive(clap::Args)]
21#[command(after_long_help = "EXAMPLES:\n \
22 # Rename a single edge from 'mentions' to 'related'\n \
23 sqlite-graphrag reclassify-relation --source tokio --target axum \\\n \
24 --from-relation mentions --to-relation related\n\n \
25 # Rename every 'mentions' edge in the namespace to 'related'\n \
26 sqlite-graphrag reclassify-relation \\\n \
27 --from-relation mentions --to-relation related --batch\n\n \
28 # Dry-run to preview what would change\n \
29 sqlite-graphrag reclassify-relation \\\n \
30 --from-relation mentions --to-relation related --batch --dry-run\n\n \
31 # Batch rename only edges whose source is a 'tool' entity\n \
32 sqlite-graphrag reclassify-relation \\\n \
33 --from-relation uses --to-relation depends_on --batch \\\n \
34 --filter-source-type tool\n\n \
35 # Migrate edges stored with a LITERAL hyphenated relation (P4):\n \
36 # --from-relation normalizes 'applies-to' to 'applies_to' and never\n \
37 # matches the raw stored value; --literal-from matches it verbatim.\n \
38 sqlite-graphrag reclassify-relation \\\n \
39 --literal-from applies-to --to-relation applies_to --batch\n\n\
40NOTE:\n \
41 Single mode requires --source, --target and --from-relation (or --literal-from).\n \
42 Batch mode requires --from-relation (or --literal-from), --to-relation and --batch.\n \
43 --from-relation and --literal-from are mutually exclusive; exactly one is required.\n \
44 --filter-source-type and --filter-target-type are only effective in batch mode.")]
45pub struct ReclassifyRelationArgs {
47 #[arg(long, conflicts_with = "batch", value_name = "ENTITY")]
49 pub source: Option<String>,
50 #[arg(long, conflicts_with = "batch", value_name = "ENTITY")]
52 pub target: Option<String>,
53 #[arg(
57 long,
58 value_parser = crate::parsers::parse_relation,
59 value_name = "RELATION",
60 required_unless_present = "literal_from",
61 conflicts_with = "literal_from"
62 )]
63 pub from_relation: Option<String>,
64 #[arg(long, value_name = "RELATION")]
69 pub literal_from: Option<String>,
70 #[arg(
74 long,
75 value_parser = crate::parsers::parse_relation,
76 value_name = "RELATION",
77 required_unless_present = "literal_to"
78 )]
79 pub to_relation: Option<String>,
80 #[arg(long, value_name = "RELATION")]
84 pub literal_to: Option<String>,
85 #[arg(long, default_value_t = false)]
87 pub batch: bool,
88 #[arg(long, value_enum, value_name = "TYPE", requires = "batch")]
90 pub filter_source_type: Option<EntityType>,
91 #[arg(long, value_enum, value_name = "TYPE", requires = "batch")]
93 pub filter_target_type: Option<EntityType>,
94 #[arg(long, default_value_t = false)]
96 pub dry_run: bool,
97 #[arg(long)]
99 pub namespace: Option<String>,
100 #[arg(long, value_enum, default_value = "json")]
102 pub format: OutputFormat,
103 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
105 pub json: bool,
106 #[arg(long)]
108 pub db: Option<String>,
109}
110
111#[derive(Serialize)]
112struct ReclassifyRelationResponse {
113 action: String,
114 from_relation: String,
115 to_relation: String,
116 count: usize,
118 merged_duplicates: usize,
121 namespace: String,
122 elapsed_ms: u64,
123}
124
125impl ReclassifyRelationArgs {
126 fn effective_from(&self) -> &str {
133 self.literal_from
134 .as_deref()
135 .or(self.from_relation.as_deref())
136 .unwrap_or_default()
137 }
138
139 fn effective_to(&self) -> &str {
148 self.literal_to
149 .as_deref()
150 .or(self.to_relation.as_deref())
151 .unwrap_or_default()
152 }
153}
154
155pub fn run(args: ReclassifyRelationArgs) -> Result<(), AppError> {
157 let inicio = std::time::Instant::now();
158 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
159 let paths = AppPaths::resolve(args.db.as_deref())?;
160
161 crate::storage::connection::ensure_db_ready(&paths)?;
162
163 crate::parsers::warn_if_non_canonical(args.effective_from());
165 crate::parsers::warn_if_non_canonical(args.effective_to());
166
167 if args.effective_from() == args.effective_to() {
174 return Err(AppError::Validation(
175 "--from-relation/--literal-from and --to-relation/--literal-to must be different"
176 .to_string(),
177 ));
178 }
179
180 let mut conn = open_rw(&paths.db)?;
181
182 if args.batch {
183 run_batch(args, inicio, namespace, &mut conn)
184 } else {
185 run_single(args, inicio, namespace, &mut conn)
186 }
187}
188
189fn run_single(
194 args: ReclassifyRelationArgs,
195 inicio: std::time::Instant,
196 namespace: String,
197 conn: &mut rusqlite::Connection,
198) -> Result<(), AppError> {
199 let source_name = args.source.as_deref().ok_or_else(|| {
200 AppError::Validation(
201 "--source is required in single mode (omit --batch for single-edge rename)".to_string(),
202 )
203 })?;
204 let target_name = args.target.as_deref().ok_or_else(|| {
205 AppError::Validation(crate::i18n::validation::target_required_single_mode())
206 })?;
207
208 let source_name_norm = crate::parsers::normalize_entity_name(source_name);
211 let target_name_norm = crate::parsers::normalize_entity_name(target_name);
212 let source_id: i64 = conn
213 .query_row(
214 "SELECT id FROM entities WHERE name = ?1 AND namespace = ?2",
215 params![source_name_norm, namespace],
216 |r| r.get(0),
217 )
218 .map_err(|_| {
219 AppError::NotFound(
220 crate::i18n::validation::source_entity_not_found_in_namespace(
221 source_name,
222 &namespace,
223 ),
224 )
225 })?;
226
227 let target_id: i64 = conn
228 .query_row(
229 "SELECT id FROM entities WHERE name = ?1 AND namespace = ?2",
230 params![target_name_norm, namespace],
231 |r| r.get(0),
232 )
233 .map_err(|_| {
234 AppError::NotFound(
235 crate::i18n::validation::target_entity_not_found_in_namespace(
236 target_name,
237 &namespace,
238 ),
239 )
240 })?;
241
242 let original_count: i64 = conn.query_row(
244 "SELECT COUNT(*) FROM relationships
245 WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3 AND namespace = ?4",
246 params![source_id, target_id, args.effective_from(), namespace],
247 |r| r.get(0),
248 )?;
249
250 if original_count == 0 {
251 return Err(AppError::NotFound(
252 crate::i18n::validation::edge_not_found_in_namespace(
253 source_name,
254 args.effective_from(),
255 target_name,
256 &namespace,
257 ),
258 ));
259 }
260
261 if args.dry_run {
262 emit_response(
263 &args,
264 "dry_run",
265 original_count as usize,
266 0,
267 namespace,
268 inicio,
269 )?;
270 return Ok(());
271 }
272
273 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
274
275 let updated = tx.execute(
276 "UPDATE OR IGNORE relationships
277 SET relation = ?1
278 WHERE source_id = ?2 AND target_id = ?3 AND relation = ?4 AND namespace = ?5",
279 params![
280 args.effective_to(),
281 source_id,
282 target_id,
283 args.effective_from(),
284 namespace
285 ],
286 )?;
287
288 let deleted = tx.execute(
290 "DELETE FROM relationships
291 WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3 AND namespace = ?4",
292 params![source_id, target_id, args.effective_from(), namespace],
293 )?;
294
295 tx.commit()?;
296
297 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
298
299 let merged = (original_count as usize).saturating_sub(updated + deleted);
300 emit_response(&args, "reclassified", updated, merged, namespace, inicio)
301}
302
303fn run_batch(
308 args: ReclassifyRelationArgs,
309 inicio: std::time::Instant,
310 namespace: String,
311 conn: &mut rusqlite::Connection,
312) -> Result<(), AppError> {
313 let source_filter = args
316 .filter_source_type
317 .map(|t| format!(" AND src.type = '{}'", t.as_str()))
318 .unwrap_or_default();
319 let target_filter = args
320 .filter_target_type
321 .map(|t| format!(" AND tgt.type = '{}'", t.as_str()))
322 .unwrap_or_default();
323 let has_filters = !source_filter.is_empty() || !target_filter.is_empty();
324
325 let original_count: i64 = if has_filters {
327 conn.query_row(
328 &format!(
329 "SELECT COUNT(*) FROM relationships r
330 JOIN entities src ON src.id = r.source_id
331 JOIN entities tgt ON tgt.id = r.target_id
332 WHERE r.relation = ?1 AND r.namespace = ?2{source_filter}{target_filter}"
333 ),
334 params![args.effective_from(), namespace],
335 |r| r.get(0),
336 )?
337 } else {
338 conn.query_row(
339 "SELECT COUNT(*) FROM relationships
340 WHERE relation = ?1 AND namespace = ?2",
341 params![args.effective_from(), namespace],
342 |r| r.get(0),
343 )?
344 };
345
346 if original_count == 0 {
347 tracing::warn!(target: "reclassify_relation",
348 from_relation = %args.effective_from(),
349 namespace = %namespace,
350 "reclassify-relation batch matched zero edges — verify --from-relation value"
351 );
352 }
353
354 if args.dry_run {
355 emit_response(
356 &args,
357 "dry_run",
358 original_count as usize,
359 0,
360 namespace,
361 inicio,
362 )?;
363 return Ok(());
364 }
365
366 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
367
368 let updated = if has_filters {
369 let ids: Vec<i64> = {
371 let mut stmt = tx.prepare(&format!(
372 "SELECT r.id FROM relationships r
373 JOIN entities src ON src.id = r.source_id
374 JOIN entities tgt ON tgt.id = r.target_id
375 WHERE r.relation = ?1 AND r.namespace = ?2{source_filter}{target_filter}"
376 ))?;
377 let collected: Vec<i64> = stmt
378 .query_map(params![args.effective_from(), namespace], |r| r.get(0))?
379 .collect::<Result<Vec<_>, _>>()?;
380 collected
381 };
382
383 let mut moved: usize = 0;
384 for id in &ids {
385 let n = tx.execute(
386 "UPDATE OR IGNORE relationships
387 SET relation = ?1
388 WHERE id = ?2",
389 params![args.effective_to(), id],
390 )?;
391 moved += n;
392 }
393 moved
394 } else {
395 tx.execute(
396 "UPDATE OR IGNORE relationships
397 SET relation = ?1
398 WHERE relation = ?2 AND namespace = ?3",
399 params![args.effective_to(), args.effective_from(), namespace],
400 )?
401 };
402
403 let deleted = if has_filters {
405 tx.execute(
406 &format!(
407 "DELETE FROM relationships WHERE id IN (
408 SELECT r.id FROM relationships r
409 JOIN entities src ON src.id = r.source_id
410 JOIN entities tgt ON tgt.id = r.target_id
411 WHERE r.relation = ?1 AND r.namespace = ?2{source_filter}{target_filter}
412 )"
413 ),
414 params![args.effective_from(), namespace],
415 )?
416 } else {
417 tx.execute(
418 "DELETE FROM relationships WHERE relation = ?1 AND namespace = ?2",
419 params![args.effective_from(), namespace],
420 )?
421 };
422
423 tx.commit()?;
424
425 conn.execute_batch("ANALYZE relationships;")?;
426 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
427
428 let merged = (original_count as usize).saturating_sub(updated + deleted);
429 emit_response(&args, "reclassified", updated, merged, namespace, inicio)
430}
431
432fn emit_response(
437 args: &ReclassifyRelationArgs,
438 action: &str,
439 count: usize,
440 merged_duplicates: usize,
441 namespace: String,
442 inicio: std::time::Instant,
443) -> Result<(), AppError> {
444 let response = ReclassifyRelationResponse {
445 action: action.to_string(),
446 from_relation: args.effective_from().to_string(),
447 to_relation: args.effective_to().to_string(),
448 count,
449 merged_duplicates,
450 namespace: namespace.clone(),
451 elapsed_ms: inicio.elapsed().as_millis() as u64,
452 };
453
454 match args.format {
455 OutputFormat::Json => output::emit_json(&response)?,
456 OutputFormat::Text | OutputFormat::Markdown => {
457 output::emit_text(&format!(
458 "{action}: {count} edges '{}' → '{}' [{namespace}] (duplicates merged: {merged_duplicates})",
459 args.effective_from(), args.effective_to()
460 ));
461 }
462 }
463 Ok(())
464}
465#[cfg(test)]
466#[path = "reclassify_relation_tests.rs"]
467mod tests;