Skip to main content

sqlite_graphrag/commands/
reclassify_relation.rs

1//! Handler for the `reclassify-relation` CLI subcommand (GAP-13).
2//!
3//! Renames a relation type in the `relationships` table — either a single
4//! directed edge (`--source`, `--target`, `--from-relation`) or every edge of
5//! a given type in the namespace (`--batch`).
6//!
7//! When the rename would produce a duplicate `(source_id, target_id, relation)`
8//! triple, `UPDATE OR IGNORE` skips the conflicting row and the subsequent
9//! `DELETE` removes it; the count of such skipped rows is reported as
10//! `merged_duplicates`.
11
12use 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.")]
45/// Reclassify relation args.
46pub struct ReclassifyRelationArgs {
47    /// Source entity name (single mode). Mutually exclusive with --batch.
48    #[arg(long, conflicts_with = "batch", value_name = "ENTITY")]
49    pub source: Option<String>,
50    /// Target entity name (single mode). Mutually exclusive with --batch.
51    #[arg(long, conflicts_with = "batch", value_name = "ENTITY")]
52    pub target: Option<String>,
53    /// Current relation type to rename (normalized: hyphens become
54    /// underscores at the CLI boundary). Required in both single and batch
55    /// modes unless --literal-from is given.
56    #[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    /// v1.1.1 (P4): current relation type to rename, matched LITERALLY —
65    /// no normalization is applied, so edges stored with hyphenated values
66    /// (e.g. `applies-to`) become reachable. Mutually exclusive with
67    /// --from-relation.
68    #[arg(long, value_name = "RELATION")]
69    pub literal_from: Option<String>,
70    /// New relation type to assign (normalized: hyphens become underscores at
71    /// the CLI boundary). Required in both single and batch modes unless
72    /// --literal-to is given.
73    #[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    /// v1.1.03: novo relation value to assign, matched LITERALLY (no
81    /// normalization). When present, wins over --to-relation. Allows
82    /// migrating legacy underscore relations to canonical hyphen.
83    #[arg(long, value_name = "RELATION")]
84    pub literal_to: Option<String>,
85    /// Enable batch reclassification of all edges with --from-relation. Requires --from-relation and --to-relation.
86    #[arg(long, default_value_t = false)]
87    pub batch: bool,
88    /// Filter batch: only rename edges whose source entity has this type.
89    #[arg(long, value_enum, value_name = "TYPE", requires = "batch")]
90    pub filter_source_type: Option<EntityType>,
91    /// Filter batch: only rename edges whose target entity has this type.
92    #[arg(long, value_enum, value_name = "TYPE", requires = "batch")]
93    pub filter_target_type: Option<EntityType>,
94    /// Preview count without committing changes.
95    #[arg(long, default_value_t = false)]
96    pub dry_run: bool,
97    /// Namespace scope.
98    #[arg(long)]
99    pub namespace: Option<String>,
100    /// Output format.
101    #[arg(long, value_enum, default_value = "json")]
102    pub format: OutputFormat,
103    /// Emit machine-readable JSON on stdout.
104    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
105    pub json: bool,
106    /// Path to the SQLite database file.
107    #[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    /// Number of edges successfully renamed.
117    count: usize,
118    /// Edges that collided with an existing (source, target, to_relation) triple
119    /// and were removed rather than renamed (UPDATE OR IGNORE + DELETE pattern).
120    merged_duplicates: usize,
121    namespace: String,
122    elapsed_ms: u64,
123}
124
125impl ReclassifyRelationArgs {
126    /// v1.1.1 (P4): the relation value used in every WHERE clause.
127    ///
128    /// `--literal-from` wins and is matched VERBATIM (no normalization);
129    /// otherwise the clap-normalized `--from-relation` applies. Clap
130    /// guarantees exactly one of the two is present
131    /// (`required_unless_present` + `conflicts_with`).
132    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    /// v1.1.03: the relation value written into every UPDATE and emitted in
140    /// the response.
141    ///
142    /// `--literal-to` wins and is stored VERBATIM (no normalization), enabling
143    /// migration of legacy underscore relations to canonical hyphen (e.g.
144    /// `--literal-from applies_to --literal-to applies-to`). Otherwise the
145    /// clap-normalized `--to-relation` applies. Clap guarantees exactly one of
146    /// the two is present (`required_unless_present` on `to_relation`).
147    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
155/// Run.
156pub 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    // Emit warnings for non-canonical relation values.
164    crate::parsers::warn_if_non_canonical(args.effective_from());
165    crate::parsers::warn_if_non_canonical(args.effective_to());
166
167    // Reject same-value renames: nothing to do and would silently remove
168    // duplicates. The comparison uses the EFFECTIVE from AND to values, so
169    // migrating a literal hyphenated relation onto its normalized form (e.g.
170    // `--literal-from applies-to --to-relation applies_to`) is a VALID
171    // migration, not an equality. Likewise `--literal-from applies_to
172    // --literal-to applies-to` migrates underscore→hyphen.
173    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
189// ---------------------------------------------------------------------------
190// Single mode
191// ---------------------------------------------------------------------------
192
193fn 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    // Resolve entity IDs — fail fast if either side does not exist.
210    // Normalize names to match the normalized stored entity names.
211    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    // Verify the edge to rename exists.
238    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    // Remove rows that UPDATE OR IGNORE silently skipped due to UNIQUE collision.
280    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
294// ---------------------------------------------------------------------------
295// Batch mode
296// ---------------------------------------------------------------------------
297
298fn run_batch(
299    args: ReclassifyRelationArgs,
300    inicio: std::time::Instant,
301    namespace: String,
302    conn: &mut rusqlite::Connection,
303) -> Result<(), AppError> {
304    // Build WHERE clause extensions for optional entity-type filters.
305    // The base query joins relationships with source/target entities.
306    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    // Count edges that would be affected (used for both dry-run and confirmation).
317    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        // For filtered batch we need to collect IDs first, then update.
361        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    // Remove rows the UPDATE OR IGNORE left behind (UNIQUE collision survivors).
395    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
423// ---------------------------------------------------------------------------
424// Shared response emitter
425// ---------------------------------------------------------------------------
426
427fn 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;