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.target.as_deref().ok_or_else(|| {
205        AppError::Validation(crate::i18n::validation::target_required_single_mode())
206    })?;
207
208    // Resolve entity IDs — fail fast if either side does not exist.
209    // Normalize names to match the normalized stored entity names.
210    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    // Verify the edge to rename exists.
237    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    // Remove rows that UPDATE OR IGNORE silently skipped due to UNIQUE collision.
279    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
293// ---------------------------------------------------------------------------
294// Batch mode
295// ---------------------------------------------------------------------------
296
297fn run_batch(
298    args: ReclassifyRelationArgs,
299    inicio: std::time::Instant,
300    namespace: String,
301    conn: &mut rusqlite::Connection,
302) -> Result<(), AppError> {
303    // Build WHERE clause extensions for optional entity-type filters.
304    // The base query joins relationships with source/target entities.
305    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    // Count edges that would be affected (used for both dry-run and confirmation).
316    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        // For filtered batch we need to collect IDs first, then update.
360        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    // Remove rows the UPDATE OR IGNORE left behind (UNIQUE collision survivors).
394    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
422// ---------------------------------------------------------------------------
423// Shared response emitter
424// ---------------------------------------------------------------------------
425
426fn 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;