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