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::normalize_entity_type;
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    /// Any label is accepted (v1.2.8); a label no entity carries simply
90    /// matches nothing.
91    #[arg(long, value_name = "TYPE", requires = "batch")]
92    pub filter_source_type: Option<String>,
93    /// Filter batch: only rename edges whose target entity has this type.
94    /// Any label is accepted (v1.2.8); a label no entity carries simply
95    /// matches nothing.
96    #[arg(long, value_name = "TYPE", requires = "batch")]
97    pub filter_target_type: Option<String>,
98    /// Preview count without committing changes.
99    #[arg(long, default_value_t = false)]
100    pub dry_run: bool,
101    /// Namespace scope.
102    #[arg(long)]
103    pub namespace: Option<String>,
104    /// Output format.
105    #[arg(long, value_enum, default_value = "json")]
106    pub format: OutputFormat,
107    /// Emit machine-readable JSON on stdout.
108    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
109    pub json: bool,
110    /// Path to the SQLite database file.
111    #[arg(long)]
112    pub db: Option<String>,
113}
114
115#[derive(Serialize)]
116struct ReclassifyRelationResponse {
117    action: String,
118    from_relation: String,
119    to_relation: String,
120    /// Number of edges successfully renamed.
121    count: usize,
122    /// Edges that collided with an existing (source, target, to_relation) triple
123    /// and were removed rather than renamed (UPDATE OR IGNORE + DELETE pattern).
124    merged_duplicates: usize,
125    namespace: String,
126    elapsed_ms: u64,
127}
128
129impl ReclassifyRelationArgs {
130    /// v1.1.1 (P4): the relation value used in every WHERE clause.
131    ///
132    /// `--literal-from` wins and is matched VERBATIM (no normalization);
133    /// otherwise the clap-normalized `--from-relation` applies. Clap
134    /// guarantees exactly one of the two is present
135    /// (`required_unless_present` + `conflicts_with`).
136    fn effective_from(&self) -> &str {
137        self.literal_from
138            .as_deref()
139            .or(self.from_relation.as_deref())
140            .unwrap_or_default()
141    }
142
143    /// v1.1.03: the relation value written into every UPDATE and emitted in
144    /// the response.
145    ///
146    /// `--literal-to` wins and is stored VERBATIM (no normalization), enabling
147    /// migration of legacy underscore relations to canonical hyphen (e.g.
148    /// `--literal-from applies_to --literal-to applies-to`). Otherwise the
149    /// clap-normalized `--to-relation` applies. Clap guarantees exactly one of
150    /// the two is present (`required_unless_present` on `to_relation`).
151    fn effective_to(&self) -> &str {
152        self.literal_to
153            .as_deref()
154            .or(self.to_relation.as_deref())
155            .unwrap_or_default()
156    }
157}
158
159/// Run.
160pub fn run(args: ReclassifyRelationArgs) -> Result<(), AppError> {
161    let started = std::time::Instant::now();
162    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
163    let paths = AppPaths::resolve(args.db.as_deref())?;
164
165    crate::storage::connection::ensure_db_ready(&paths)?;
166
167    // Emit warnings for non-canonical relation values.
168    crate::parsers::warn_if_non_canonical(args.effective_from());
169    crate::parsers::warn_if_non_canonical(args.effective_to());
170
171    // Reject same-value renames: nothing to do and would silently remove
172    // duplicates. The comparison uses the EFFECTIVE from AND to values, so
173    // migrating a literal hyphenated relation onto its normalized form (e.g.
174    // `--literal-from applies-to --to-relation applies_to`) is a VALID
175    // migration, not an equality. Likewise `--literal-from applies_to
176    // --literal-to applies-to` migrates underscore→hyphen.
177    if args.effective_from() == args.effective_to() {
178        return Err(AppError::Validation(
179            "--from-relation/--literal-from and --to-relation/--literal-to must be different"
180                .to_string(),
181        ));
182    }
183
184    let mut conn = open_rw(&paths.db)?;
185
186    if args.batch {
187        run_batch(args, started, namespace, &mut conn)
188    } else {
189        run_single(args, started, namespace, &mut conn)
190    }
191}
192
193// ---------------------------------------------------------------------------
194// Single mode
195// ---------------------------------------------------------------------------
196
197fn run_single(
198    args: ReclassifyRelationArgs,
199    started: std::time::Instant,
200    namespace: String,
201    conn: &mut rusqlite::Connection,
202) -> Result<(), AppError> {
203    let source_name = args.source.as_deref().ok_or_else(|| {
204        AppError::Validation(
205            "--source is required in single mode (omit --batch for single-edge rename)".to_string(),
206        )
207    })?;
208    let target_name = args.target.as_deref().ok_or_else(|| {
209        AppError::Validation(crate::i18n::validation::target_required_single_mode())
210    })?;
211
212    // Resolve entity IDs — fail fast if either side does not exist.
213    // Normalize names to match the normalized stored entity names.
214    let source_name_norm = crate::parsers::normalize_entity_name(source_name);
215    let target_name_norm = crate::parsers::normalize_entity_name(target_name);
216    let source_id: i64 = conn
217        .query_row(
218            "SELECT id FROM entities WHERE name = ?1 AND namespace = ?2",
219            params![source_name_norm, namespace],
220            |r| r.get(0),
221        )
222        .map_err(|_| {
223            AppError::NotFound(
224                crate::i18n::validation::source_entity_not_found_in_namespace(
225                    source_name,
226                    &namespace,
227                ),
228            )
229        })?;
230
231    let target_id: i64 = conn
232        .query_row(
233            "SELECT id FROM entities WHERE name = ?1 AND namespace = ?2",
234            params![target_name_norm, namespace],
235            |r| r.get(0),
236        )
237        .map_err(|_| {
238            AppError::NotFound(
239                crate::i18n::validation::target_entity_not_found_in_namespace(
240                    target_name,
241                    &namespace,
242                ),
243            )
244        })?;
245
246    // Verify the edge to rename exists.
247    let original_count: i64 = conn.query_row(
248        "SELECT COUNT(*) FROM relationships
249         WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3 AND namespace = ?4",
250        params![source_id, target_id, args.effective_from(), namespace],
251        |r| r.get(0),
252    )?;
253
254    if original_count == 0 {
255        return Err(AppError::NotFound(
256            crate::i18n::validation::edge_not_found_in_namespace(
257                source_name,
258                args.effective_from(),
259                target_name,
260                &namespace,
261            ),
262        ));
263    }
264
265    if args.dry_run {
266        emit_response(
267            &args,
268            "dry_run",
269            original_count as usize,
270            0,
271            namespace,
272            started,
273        )?;
274        return Ok(());
275    }
276
277    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
278
279    let updated = tx.execute(
280        "UPDATE OR IGNORE relationships
281         SET relation = ?1
282         WHERE source_id = ?2 AND target_id = ?3 AND relation = ?4 AND namespace = ?5",
283        params![
284            args.effective_to(),
285            source_id,
286            target_id,
287            args.effective_from(),
288            namespace
289        ],
290    )?;
291
292    // Remove rows that UPDATE OR IGNORE silently skipped due to UNIQUE collision.
293    let deleted = tx.execute(
294        "DELETE FROM relationships
295         WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3 AND namespace = ?4",
296        params![source_id, target_id, args.effective_from(), namespace],
297    )?;
298
299    tx.commit()?;
300
301    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
302
303    let merged = (original_count as usize).saturating_sub(updated + deleted);
304    emit_response(&args, "reclassified", updated, merged, namespace, started)
305}
306
307// ---------------------------------------------------------------------------
308// Batch mode
309// ---------------------------------------------------------------------------
310
311/// Builds the ` AND <alias>.type = '<label>'` fragment for an optional filter.
312///
313/// v1.2.8: the entity-type vocabulary is open, so this value is now free text
314/// rather than an enum variant. It is normalised for shape (which refuses line
315/// breaks and overlong labels) and single quotes are doubled before the label
316/// reaches the SQL string, because these three batch queries interpolate the
317/// fragment instead of binding it.
318fn type_filter_clause(alias: &str, value: Option<&str>) -> Result<String, AppError> {
319    match value {
320        None => Ok(String::new()),
321        Some(raw) => {
322            let normalized = normalize_entity_type(raw)?;
323            let escaped = normalized.replace('\'', "''");
324            Ok(format!(" AND {alias}.type = '{escaped}'"))
325        }
326    }
327}
328
329fn run_batch(
330    args: ReclassifyRelationArgs,
331    started: std::time::Instant,
332    namespace: String,
333    conn: &mut rusqlite::Connection,
334) -> Result<(), AppError> {
335    // Build WHERE clause extensions for optional entity-type filters.
336    // The base query joins relationships with source/target entities.
337    let source_filter = type_filter_clause("src", args.filter_source_type.as_deref())?;
338    let target_filter = type_filter_clause("tgt", args.filter_target_type.as_deref())?;
339    let has_filters = !source_filter.is_empty() || !target_filter.is_empty();
340
341    // Count edges that would be affected (used for both dry-run and confirmation).
342    let original_count: i64 = if has_filters {
343        conn.query_row(
344            &format!(
345                "SELECT COUNT(*) FROM relationships r
346                 JOIN entities src ON src.id = r.source_id
347                 JOIN entities tgt ON tgt.id = r.target_id
348                 WHERE r.relation = ?1 AND r.namespace = ?2{source_filter}{target_filter}"
349            ),
350            params![args.effective_from(), namespace],
351            |r| r.get(0),
352        )?
353    } else {
354        conn.query_row(
355            "SELECT COUNT(*) FROM relationships
356             WHERE relation = ?1 AND namespace = ?2",
357            params![args.effective_from(), namespace],
358            |r| r.get(0),
359        )?
360    };
361
362    if original_count == 0 {
363        tracing::warn!(target: "reclassify_relation",
364            from_relation = %args.effective_from(),
365            namespace = %namespace,
366            "reclassify-relation batch matched zero edges — verify --from-relation value"
367        );
368    }
369
370    if args.dry_run {
371        emit_response(
372            &args,
373            "dry_run",
374            original_count as usize,
375            0,
376            namespace,
377            started,
378        )?;
379        return Ok(());
380    }
381
382    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
383
384    let updated = if has_filters {
385        // For filtered batch we need to collect IDs first, then update.
386        let ids: Vec<i64> = {
387            let mut stmt = tx.prepare(&format!(
388                "SELECT r.id FROM relationships r
389                 JOIN entities src ON src.id = r.source_id
390                 JOIN entities tgt ON tgt.id = r.target_id
391                 WHERE r.relation = ?1 AND r.namespace = ?2{source_filter}{target_filter}"
392            ))?;
393            let collected: Vec<i64> = stmt
394                .query_map(params![args.effective_from(), namespace], |r| r.get(0))?
395                .collect::<Result<Vec<_>, _>>()?;
396            collected
397        };
398
399        let mut moved: usize = 0;
400        for id in &ids {
401            let n = tx.execute(
402                "UPDATE OR IGNORE relationships
403                 SET relation = ?1
404                 WHERE id = ?2",
405                params![args.effective_to(), id],
406            )?;
407            moved += n;
408        }
409        moved
410    } else {
411        tx.execute(
412            "UPDATE OR IGNORE relationships
413             SET relation = ?1
414             WHERE relation = ?2 AND namespace = ?3",
415            params![args.effective_to(), args.effective_from(), namespace],
416        )?
417    };
418
419    // Remove rows the UPDATE OR IGNORE left behind (UNIQUE collision survivors).
420    let deleted = if has_filters {
421        tx.execute(
422            &format!(
423                "DELETE FROM relationships WHERE id IN (
424                     SELECT r.id FROM relationships r
425                     JOIN entities src ON src.id = r.source_id
426                     JOIN entities tgt ON tgt.id = r.target_id
427                     WHERE r.relation = ?1 AND r.namespace = ?2{source_filter}{target_filter}
428                 )"
429            ),
430            params![args.effective_from(), namespace],
431        )?
432    } else {
433        tx.execute(
434            "DELETE FROM relationships WHERE relation = ?1 AND namespace = ?2",
435            params![args.effective_from(), namespace],
436        )?
437    };
438
439    tx.commit()?;
440
441    conn.execute_batch("ANALYZE relationships;")?;
442    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
443
444    let merged = (original_count as usize).saturating_sub(updated + deleted);
445    emit_response(&args, "reclassified", updated, merged, namespace, started)
446}
447
448// ---------------------------------------------------------------------------
449// Shared response emitter
450// ---------------------------------------------------------------------------
451
452fn emit_response(
453    args: &ReclassifyRelationArgs,
454    action: &str,
455    count: usize,
456    merged_duplicates: usize,
457    namespace: String,
458    started: std::time::Instant,
459) -> Result<(), AppError> {
460    let response = ReclassifyRelationResponse {
461        action: action.to_string(),
462        from_relation: args.effective_from().to_string(),
463        to_relation: args.effective_to().to_string(),
464        count,
465        merged_duplicates,
466        namespace: namespace.clone(),
467        elapsed_ms: started.elapsed().as_millis() as u64,
468    };
469
470    match args.format {
471        OutputFormat::Json => output::emit_json(&response)?,
472        OutputFormat::Text | OutputFormat::Markdown => {
473            output::emit_text(&format!(
474                "{action}: {count} edges '{}' → '{}' [{namespace}] (duplicates merged: {merged_duplicates})",
475                args.effective_from(), args.effective_to()
476            ));
477        }
478    }
479    Ok(())
480}
481#[cfg(test)]
482#[path = "reclassify_relation_tests.rs"]
483mod tests;