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