1use crate::entity_type::normalize_entity_type;
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_name = "TYPE", requires = "batch")]
92 pub filter_source_type: Option<String>,
93 #[arg(long, value_name = "TYPE", requires = "batch")]
97 pub filter_target_type: Option<String>,
98 #[arg(long, default_value_t = false)]
100 pub dry_run: bool,
101 #[arg(long)]
103 pub namespace: Option<String>,
104 #[arg(long, value_enum, default_value = "json")]
106 pub format: OutputFormat,
107 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
109 pub json: bool,
110 #[arg(long)]
112 pub db: Option<String>,
113}
114
115#[derive(Serialize)]
116struct ReclassifyRelationResponse {
117 action: String,
118 from_relation: String,
119 to_relation: String,
120 count: usize,
122 merged_duplicates: usize,
125 namespace: String,
126 elapsed_ms: u64,
127}
128
129impl ReclassifyRelationArgs {
130 fn effective_from(&self) -> &str {
137 self.literal_from
138 .as_deref()
139 .or(self.from_relation.as_deref())
140 .unwrap_or_default()
141 }
142
143 fn effective_to(&self) -> &str {
152 self.literal_to
153 .as_deref()
154 .or(self.to_relation.as_deref())
155 .unwrap_or_default()
156 }
157}
158
159pub fn run(args: ReclassifyRelationArgs) -> Result<(), AppError> {
161 let started = std::time::Instant::now();
162 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
163 let paths = AppPaths::resolve(args.db.as_deref())?;
164
165 crate::storage::connection::ensure_db_ready(&paths)?;
166
167 crate::parsers::warn_if_non_canonical(args.effective_from());
169 crate::parsers::warn_if_non_canonical(args.effective_to());
170
171 if args.effective_from() == args.effective_to() {
178 return Err(AppError::Validation(
179 "--from-relation/--literal-from and --to-relation/--literal-to must be different"
180 .to_string(),
181 ));
182 }
183
184 let mut conn = open_rw(&paths.db)?;
185
186 if args.batch {
187 run_batch(args, started, namespace, &mut conn)
188 } else {
189 run_single(args, started, namespace, &mut conn)
190 }
191}
192
193fn run_single(
198 args: ReclassifyRelationArgs,
199 started: std::time::Instant,
200 namespace: String,
201 conn: &mut rusqlite::Connection,
202) -> Result<(), AppError> {
203 let source_name = args.source.as_deref().ok_or_else(|| {
204 AppError::Validation(
205 "--source is required in single mode (omit --batch for single-edge rename)".to_string(),
206 )
207 })?;
208 let target_name = args.target.as_deref().ok_or_else(|| {
209 AppError::Validation(crate::i18n::validation::target_required_single_mode())
210 })?;
211
212 let source_name_norm = crate::parsers::normalize_entity_name(source_name);
215 let target_name_norm = crate::parsers::normalize_entity_name(target_name);
216 let source_id: i64 = conn
217 .query_row(
218 "SELECT id FROM entities WHERE name = ?1 AND namespace = ?2",
219 params![source_name_norm, namespace],
220 |r| r.get(0),
221 )
222 .map_err(|_| {
223 AppError::NotFound(
224 crate::i18n::validation::source_entity_not_found_in_namespace(
225 source_name,
226 &namespace,
227 ),
228 )
229 })?;
230
231 let target_id: i64 = conn
232 .query_row(
233 "SELECT id FROM entities WHERE name = ?1 AND namespace = ?2",
234 params![target_name_norm, namespace],
235 |r| r.get(0),
236 )
237 .map_err(|_| {
238 AppError::NotFound(
239 crate::i18n::validation::target_entity_not_found_in_namespace(
240 target_name,
241 &namespace,
242 ),
243 )
244 })?;
245
246 let original_count: i64 = conn.query_row(
248 "SELECT COUNT(*) FROM relationships
249 WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3 AND namespace = ?4",
250 params![source_id, target_id, args.effective_from(), namespace],
251 |r| r.get(0),
252 )?;
253
254 if original_count == 0 {
255 return Err(AppError::NotFound(
256 crate::i18n::validation::edge_not_found_in_namespace(
257 source_name,
258 args.effective_from(),
259 target_name,
260 &namespace,
261 ),
262 ));
263 }
264
265 if args.dry_run {
266 emit_response(
267 &args,
268 "dry_run",
269 original_count as usize,
270 0,
271 namespace,
272 started,
273 )?;
274 return Ok(());
275 }
276
277 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
278
279 let updated = tx.execute(
280 "UPDATE OR IGNORE relationships
281 SET relation = ?1
282 WHERE source_id = ?2 AND target_id = ?3 AND relation = ?4 AND namespace = ?5",
283 params![
284 args.effective_to(),
285 source_id,
286 target_id,
287 args.effective_from(),
288 namespace
289 ],
290 )?;
291
292 let deleted = tx.execute(
294 "DELETE FROM relationships
295 WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3 AND namespace = ?4",
296 params![source_id, target_id, args.effective_from(), namespace],
297 )?;
298
299 tx.commit()?;
300
301 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
302
303 let merged = (original_count as usize).saturating_sub(updated + deleted);
304 emit_response(&args, "reclassified", updated, merged, namespace, started)
305}
306
307fn type_filter_clause(alias: &str, value: Option<&str>) -> Result<String, AppError> {
319 match value {
320 None => Ok(String::new()),
321 Some(raw) => {
322 let normalized = normalize_entity_type(raw)?;
323 let escaped = normalized.replace('\'', "''");
324 Ok(format!(" AND {alias}.type = '{escaped}'"))
325 }
326 }
327}
328
329fn run_batch(
330 args: ReclassifyRelationArgs,
331 started: std::time::Instant,
332 namespace: String,
333 conn: &mut rusqlite::Connection,
334) -> Result<(), AppError> {
335 let source_filter = type_filter_clause("src", args.filter_source_type.as_deref())?;
338 let target_filter = type_filter_clause("tgt", args.filter_target_type.as_deref())?;
339 let has_filters = !source_filter.is_empty() || !target_filter.is_empty();
340
341 let original_count: i64 = if has_filters {
343 conn.query_row(
344 &format!(
345 "SELECT COUNT(*) FROM relationships r
346 JOIN entities src ON src.id = r.source_id
347 JOIN entities tgt ON tgt.id = r.target_id
348 WHERE r.relation = ?1 AND r.namespace = ?2{source_filter}{target_filter}"
349 ),
350 params![args.effective_from(), namespace],
351 |r| r.get(0),
352 )?
353 } else {
354 conn.query_row(
355 "SELECT COUNT(*) FROM relationships
356 WHERE relation = ?1 AND namespace = ?2",
357 params![args.effective_from(), namespace],
358 |r| r.get(0),
359 )?
360 };
361
362 if original_count == 0 {
363 tracing::warn!(target: "reclassify_relation",
364 from_relation = %args.effective_from(),
365 namespace = %namespace,
366 "reclassify-relation batch matched zero edges — verify --from-relation value"
367 );
368 }
369
370 if args.dry_run {
371 emit_response(
372 &args,
373 "dry_run",
374 original_count as usize,
375 0,
376 namespace,
377 started,
378 )?;
379 return Ok(());
380 }
381
382 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
383
384 let updated = if has_filters {
385 let ids: Vec<i64> = {
387 let mut stmt = tx.prepare(&format!(
388 "SELECT r.id FROM relationships r
389 JOIN entities src ON src.id = r.source_id
390 JOIN entities tgt ON tgt.id = r.target_id
391 WHERE r.relation = ?1 AND r.namespace = ?2{source_filter}{target_filter}"
392 ))?;
393 let collected: Vec<i64> = stmt
394 .query_map(params![args.effective_from(), namespace], |r| r.get(0))?
395 .collect::<Result<Vec<_>, _>>()?;
396 collected
397 };
398
399 let mut moved: usize = 0;
400 for id in &ids {
401 let n = tx.execute(
402 "UPDATE OR IGNORE relationships
403 SET relation = ?1
404 WHERE id = ?2",
405 params![args.effective_to(), id],
406 )?;
407 moved += n;
408 }
409 moved
410 } else {
411 tx.execute(
412 "UPDATE OR IGNORE relationships
413 SET relation = ?1
414 WHERE relation = ?2 AND namespace = ?3",
415 params![args.effective_to(), args.effective_from(), namespace],
416 )?
417 };
418
419 let deleted = if has_filters {
421 tx.execute(
422 &format!(
423 "DELETE FROM relationships WHERE id IN (
424 SELECT r.id FROM relationships r
425 JOIN entities src ON src.id = r.source_id
426 JOIN entities tgt ON tgt.id = r.target_id
427 WHERE r.relation = ?1 AND r.namespace = ?2{source_filter}{target_filter}
428 )"
429 ),
430 params![args.effective_from(), namespace],
431 )?
432 } else {
433 tx.execute(
434 "DELETE FROM relationships WHERE relation = ?1 AND namespace = ?2",
435 params![args.effective_from(), namespace],
436 )?
437 };
438
439 tx.commit()?;
440
441 conn.execute_batch("ANALYZE relationships;")?;
442 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
443
444 let merged = (original_count as usize).saturating_sub(updated + deleted);
445 emit_response(&args, "reclassified", updated, merged, namespace, started)
446}
447
448fn emit_response(
453 args: &ReclassifyRelationArgs,
454 action: &str,
455 count: usize,
456 merged_duplicates: usize,
457 namespace: String,
458 started: std::time::Instant,
459) -> Result<(), AppError> {
460 let response = ReclassifyRelationResponse {
461 action: action.to_string(),
462 from_relation: args.effective_from().to_string(),
463 to_relation: args.effective_to().to_string(),
464 count,
465 merged_duplicates,
466 namespace: namespace.clone(),
467 elapsed_ms: started.elapsed().as_millis() as u64,
468 };
469
470 match args.format {
471 OutputFormat::Json => output::emit_json(&response)?,
472 OutputFormat::Text | OutputFormat::Markdown => {
473 output::emit_text(&format!(
474 "{action}: {count} edges '{}' → '{}' [{namespace}] (duplicates merged: {merged_duplicates})",
475 args.effective_from(), args.effective_to()
476 ));
477 }
478 }
479 Ok(())
480}
481#[cfg(test)]
482#[path = "reclassify_relation_tests.rs"]
483mod tests;