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(format!(
220 "source entity '{source_name}' not found in namespace '{namespace}'"
221 ))
222 })?;
223
224 let target_id: i64 = conn
225 .query_row(
226 "SELECT id FROM entities WHERE name = ?1 AND namespace = ?2",
227 params![target_name_norm, namespace],
228 |r| r.get(0),
229 )
230 .map_err(|_| {
231 AppError::NotFound(format!(
232 "target entity '{target_name}' not found in namespace '{namespace}'"
233 ))
234 })?;
235
236 let original_count: i64 = conn.query_row(
238 "SELECT COUNT(*) FROM relationships
239 WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3 AND namespace = ?4",
240 params![source_id, target_id, args.effective_from(), namespace],
241 |r| r.get(0),
242 )?;
243
244 if original_count == 0 {
245 return Err(AppError::NotFound(format!(
246 "edge '{source_name}' --[{}]--> '{target_name}' not found in namespace '{namespace}'",
247 args.effective_from()
248 )));
249 }
250
251 if args.dry_run {
252 emit_response(
253 &args,
254 "dry_run",
255 original_count as usize,
256 0,
257 namespace,
258 inicio,
259 )?;
260 return Ok(());
261 }
262
263 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
264
265 let updated = tx.execute(
266 "UPDATE OR IGNORE relationships
267 SET relation = ?1
268 WHERE source_id = ?2 AND target_id = ?3 AND relation = ?4 AND namespace = ?5",
269 params![
270 args.effective_to(),
271 source_id,
272 target_id,
273 args.effective_from(),
274 namespace
275 ],
276 )?;
277
278 let deleted = tx.execute(
280 "DELETE FROM relationships
281 WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3 AND namespace = ?4",
282 params![source_id, target_id, args.effective_from(), namespace],
283 )?;
284
285 tx.commit()?;
286
287 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
288
289 let merged = (original_count as usize).saturating_sub(updated + deleted);
290 emit_response(&args, "reclassified", updated, merged, namespace, inicio)
291}
292
293fn run_batch(
298 args: ReclassifyRelationArgs,
299 inicio: std::time::Instant,
300 namespace: String,
301 conn: &mut rusqlite::Connection,
302) -> Result<(), AppError> {
303 let source_filter = args
306 .filter_source_type
307 .map(|t| format!(" AND src.type = '{}'", t.as_str()))
308 .unwrap_or_default();
309 let target_filter = args
310 .filter_target_type
311 .map(|t| format!(" AND tgt.type = '{}'", t.as_str()))
312 .unwrap_or_default();
313 let has_filters = !source_filter.is_empty() || !target_filter.is_empty();
314
315 let original_count: i64 = if has_filters {
317 conn.query_row(
318 &format!(
319 "SELECT COUNT(*) FROM relationships r
320 JOIN entities src ON src.id = r.source_id
321 JOIN entities tgt ON tgt.id = r.target_id
322 WHERE r.relation = ?1 AND r.namespace = ?2{source_filter}{target_filter}"
323 ),
324 params![args.effective_from(), namespace],
325 |r| r.get(0),
326 )?
327 } else {
328 conn.query_row(
329 "SELECT COUNT(*) FROM relationships
330 WHERE relation = ?1 AND namespace = ?2",
331 params![args.effective_from(), namespace],
332 |r| r.get(0),
333 )?
334 };
335
336 if original_count == 0 {
337 tracing::warn!(target: "reclassify_relation",
338 from_relation = %args.effective_from(),
339 namespace = %namespace,
340 "reclassify-relation batch matched zero edges — verify --from-relation value"
341 );
342 }
343
344 if args.dry_run {
345 emit_response(
346 &args,
347 "dry_run",
348 original_count as usize,
349 0,
350 namespace,
351 inicio,
352 )?;
353 return Ok(());
354 }
355
356 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
357
358 let updated = if has_filters {
359 let ids: Vec<i64> = {
361 let mut stmt = tx.prepare(&format!(
362 "SELECT r.id FROM relationships r
363 JOIN entities src ON src.id = r.source_id
364 JOIN entities tgt ON tgt.id = r.target_id
365 WHERE r.relation = ?1 AND r.namespace = ?2{source_filter}{target_filter}"
366 ))?;
367 let collected: Vec<i64> = stmt
368 .query_map(params![args.effective_from(), namespace], |r| r.get(0))?
369 .collect::<Result<Vec<_>, _>>()?;
370 collected
371 };
372
373 let mut moved: usize = 0;
374 for id in &ids {
375 let n = tx.execute(
376 "UPDATE OR IGNORE relationships
377 SET relation = ?1
378 WHERE id = ?2",
379 params![args.effective_to(), id],
380 )?;
381 moved += n;
382 }
383 moved
384 } else {
385 tx.execute(
386 "UPDATE OR IGNORE relationships
387 SET relation = ?1
388 WHERE relation = ?2 AND namespace = ?3",
389 params![args.effective_to(), args.effective_from(), namespace],
390 )?
391 };
392
393 let deleted = if has_filters {
395 tx.execute(
396 &format!(
397 "DELETE FROM relationships WHERE id IN (
398 SELECT r.id FROM relationships r
399 JOIN entities src ON src.id = r.source_id
400 JOIN entities tgt ON tgt.id = r.target_id
401 WHERE r.relation = ?1 AND r.namespace = ?2{source_filter}{target_filter}
402 )"
403 ),
404 params![args.effective_from(), namespace],
405 )?
406 } else {
407 tx.execute(
408 "DELETE FROM relationships WHERE relation = ?1 AND namespace = ?2",
409 params![args.effective_from(), namespace],
410 )?
411 };
412
413 tx.commit()?;
414
415 conn.execute_batch("ANALYZE relationships;")?;
416 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
417
418 let merged = (original_count as usize).saturating_sub(updated + deleted);
419 emit_response(&args, "reclassified", updated, merged, namespace, inicio)
420}
421
422fn emit_response(
427 args: &ReclassifyRelationArgs,
428 action: &str,
429 count: usize,
430 merged_duplicates: usize,
431 namespace: String,
432 inicio: std::time::Instant,
433) -> Result<(), AppError> {
434 let response = ReclassifyRelationResponse {
435 action: action.to_string(),
436 from_relation: args.effective_from().to_string(),
437 to_relation: args.effective_to().to_string(),
438 count,
439 merged_duplicates,
440 namespace: namespace.clone(),
441 elapsed_ms: inicio.elapsed().as_millis() as u64,
442 };
443
444 match args.format {
445 OutputFormat::Json => output::emit_json(&response)?,
446 OutputFormat::Text | OutputFormat::Markdown => {
447 output::emit_text(&format!(
448 "{action}: {count} edges '{}' → '{}' [{namespace}] (duplicates merged: {merged_duplicates})",
449 args.effective_from(), args.effective_to()
450 ));
451 }
452 }
453 Ok(())
454}
455#[cfg(test)]
456#[path = "reclassify_relation_tests.rs"]
457mod tests;