Skip to main content

sqlite_graphrag/commands/
rename_entity.rs

1//! Handler for the `rename-entity` CLI subcommand.
2//!
3//! Renames an entity preserving all relationships and memory bindings.
4//! Only the `name` column in `entities` and the corresponding `vec_entities`
5//! row need updating because relationships use integer FK `entity_id`.
6
7use crate::entity_type::EntityType;
8use crate::errors::AppError;
9use crate::i18n::errors_msg;
10use crate::output::{self, OutputFormat};
11use crate::paths::AppPaths;
12use crate::storage::connection::open_rw;
13use crate::storage::entities;
14use rusqlite::params;
15use serde::Serialize;
16
17#[derive(clap::Args)]
18#[command(after_long_help = "EXAMPLES:\n  \
19    # Rename an entity\n  \
20    sqlite-graphrag rename-entity --name old-name --new-name new-name\n\n  \
21    # Rename with namespace\n  \
22    sqlite-graphrag rename-entity --name auth --new-name authentication --namespace my-project\n\n  \
23    # Rename by ID (unambiguous when homonyms exist across namespaces)\n  \
24    sqlite-graphrag rename-entity --id 42 --new-name authentication")]
25pub struct RenameEntityArgs {
26    /// Current entity name to rename.
27    #[arg(
28        long,
29        value_name = "NAME",
30        required_unless_present = "id",
31        conflicts_with = "id"
32    )]
33    pub name: Option<String>,
34    /// v1.1.1 (P5): entity ID to rename. IDs are globally unique, so --id
35    /// disambiguates homonyms across namespaces. Conflicts with --name; the
36    /// entity must belong to the resolved namespace.
37    #[arg(long, value_name = "ID")]
38    pub id: Option<i64>,
39    /// New name for the entity.
40    #[arg(long, value_name = "NEW_NAME")]
41    pub new_name: String,
42    #[arg(long)]
43    pub namespace: Option<String>,
44    #[arg(long, value_enum, default_value = "json")]
45    pub format: OutputFormat,
46    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
47    pub json: bool,
48    #[arg(long)]
49    pub db: Option<String>,
50}
51
52#[derive(Serialize)]
53struct RenameEntityResponse {
54    action: String,
55    old_name: String,
56    new_name: String,
57    entity_id: i64,
58    namespace: String,
59    elapsed_ms: u64,
60}
61
62/// v1.1.1 (P5): resolves an entity ID to `(id, type, stored name)`, enforcing
63/// that the entity exists AND belongs to the namespace — IDs are global, so a
64/// bare existence check could silently cross namespaces.
65fn lookup_entity_by_id(
66    conn: &rusqlite::Connection,
67    namespace: &str,
68    id: i64,
69) -> Result<(i64, EntityType, String), AppError> {
70    let mut stmt = conn
71        .prepare_cached("SELECT id, type, name FROM entities WHERE id = ?1 AND namespace = ?2")?;
72    match stmt.query_row(params![id, namespace], |r| {
73        Ok((
74            r.get::<_, i64>(0)?,
75            r.get::<_, EntityType>(1)?,
76            r.get::<_, String>(2)?,
77        ))
78    }) {
79        Ok(row) => Ok(row),
80        Err(rusqlite::Error::QueryReturnedNoRows) => Err(AppError::NotFound(format!(
81            "entity id={id} not found in namespace '{namespace}'"
82        ))),
83        Err(e) => Err(AppError::Database(e)),
84    }
85}
86
87pub fn run(
88    args: RenameEntityArgs,
89    llm_backend: crate::cli::LlmBackendChoice,
90    embedding_backend: crate::cli::EmbeddingBackendChoice,
91) -> Result<(), AppError> {
92    let start = std::time::Instant::now();
93    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
94    let paths = AppPaths::resolve(args.db.as_deref())?;
95
96    crate::storage::connection::ensure_db_ready(&paths)?;
97
98    let mut conn = open_rw(&paths.db)?;
99
100    // Verify the source entity exists and fetch id, type and stored name —
101    // by ID (v1.1.1 P5, unambiguous across homonyms) or by normalized name.
102    // Existence is validated here, BEFORE any mutation.
103    let (entity_id, entity_type, old_name) = match args.id {
104        Some(id) => lookup_entity_by_id(&conn, &namespace, id)?,
105        None => {
106            let Some(ref raw_name) = args.name else {
107                return Err(AppError::Validation(
108                    "--name or --id is required".to_string(),
109                ));
110            };
111            // Normalize the lookup name to match the normalized stored names.
112            let lookup_name = crate::parsers::normalize_entity_name(raw_name);
113            let mut stmt = conn.prepare_cached(
114                "SELECT id, type FROM entities WHERE namespace = ?1 AND name = ?2",
115            )?;
116            match stmt.query_row(params![namespace, lookup_name], |r| {
117                Ok((r.get::<_, i64>(0)?, r.get::<_, EntityType>(1)?))
118            }) {
119                Ok((id, ty)) => (id, ty, lookup_name),
120                Err(rusqlite::Error::QueryReturnedNoRows) => {
121                    return Err(AppError::NotFound(errors_msg::entity_not_found(
122                        raw_name, &namespace,
123                    )))
124                }
125                Err(e) => return Err(AppError::Database(e)),
126            }
127        }
128    };
129
130    // Validate the raw new name first (catches short ALL_CAPS NER noise),
131    // then normalize it for storage to preserve the normalized-name invariant.
132    entities::validate_entity_name(&args.new_name)?;
133    let new_name = crate::parsers::normalize_entity_name(&args.new_name);
134
135    if old_name == new_name {
136        return Err(AppError::Validation(
137            "source and target entity names are identical".to_string(),
138        ));
139    }
140
141    // Ensure new name is not already taken in this namespace.
142    if entities::find_entity_id(&conn, &namespace, &new_name)?.is_some() {
143        return Err(AppError::Validation(format!(
144            "entity with name '{new_name}' already exists in namespace '{namespace}'"
145        )));
146    }
147
148    // GAP-CLI-PERF-RENAME-01 / EMBED-NONE (v1.1.8): re-embed the new name only
149    // when a real embedding backend is available. Intentional `--llm-backend
150    // none` (or empty vectors) must not block a pure metadata rename for ~30s.
151    let skip_embed = crate::embedder::should_skip_embedding_on_failure();
152    let embedding: Option<Vec<f32>> = match crate::embedder::embed_passage_with_embedding_choice(
153        &paths.models,
154        &new_name,
155        embedding_backend,
156        llm_backend,
157    ) {
158        Ok((emb, _backend)) if emb.is_empty() => None,
159        Ok((emb, _backend)) => Some(emb),
160        // v1.1.2 (Gap 2): typed payload rejections are permanent and must not
161        // be swallowed by --skip-embedding-on-failure.
162        Err(
163            e @ (AppError::Validation(_)
164            | AppError::BodyTooLarge { .. }
165            | AppError::TooManyTokens { .. }),
166        ) => return Err(e),
167        Err(e) if skip_embed => {
168            tracing::warn!(error = %e, "rename-entity: embedding failed; --skip-embedding-on-failure active, persisting without embedding");
169            None
170        }
171        Err(e) => return Err(e),
172    };
173
174    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
175    tx.execute(
176        "UPDATE entities SET name = ?1, updated_at = unixepoch() WHERE id = ?2",
177        params![new_name, entity_id],
178    )?;
179    // v1.0.76: BLOB-backed entity_embeddings table (PK = entity_id).
180    // G43: reuse the canonical writer instead of a duplicated INSERT that
181    // hardcoded dim=384 and a removed local model name; `upsert_entity_vec`
182    // records the real vector length and the CLI version as `model`.
183    if let Some(ref emb) = embedding {
184        entities::upsert_entity_vec(&tx, entity_id, &namespace, entity_type, emb, &new_name)?;
185    }
186    tx.commit()?;
187
188    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
189
190    let response = RenameEntityResponse {
191        action: "renamed".to_string(),
192        old_name,
193        new_name,
194        entity_id,
195        namespace: namespace.clone(),
196        elapsed_ms: start.elapsed().as_millis() as u64,
197    };
198
199    match args.format {
200        OutputFormat::Json => output::emit_json(&response)?,
201        OutputFormat::Text | OutputFormat::Markdown => {
202            output::emit_text(&format!(
203                "renamed entity: '{}' → '{}' [{}]",
204                response.old_name, response.new_name, response.namespace
205            ));
206        }
207    }
208
209    Ok(())
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    // v1.1.1 (P5): ID lookup is namespace-scoped and returns the stored name,
217    // so homonyms across namespaces resolve deterministically.
218    #[test]
219    fn lookup_entity_by_id_disambiguates_homonyms_across_namespaces() {
220        let conn = rusqlite::Connection::open_in_memory().unwrap();
221        conn.execute_batch(
222            "CREATE TABLE entities (
223                id INTEGER PRIMARY KEY,
224                namespace TEXT NOT NULL,
225                name TEXT NOT NULL,
226                type TEXT NOT NULL,
227                UNIQUE(namespace, name)
228            );",
229        )
230        .unwrap();
231        conn.execute(
232            "INSERT INTO entities (id, namespace, name, type)
233             VALUES (1, 'ns-a', 'auth', 'concept'), (2, 'ns-b', 'auth', 'tool')",
234            [],
235        )
236        .unwrap();
237
238        let (id, ty, name) = lookup_entity_by_id(&conn, "ns-b", 2).unwrap();
239        assert_eq!(id, 2);
240        assert_eq!(name, "auth");
241        assert_eq!(ty, EntityType::Tool);
242
243        let err = lookup_entity_by_id(&conn, "ns-b", 1).unwrap_err();
244        assert_eq!(err.exit_code(), 4, "cross-namespace ID must be NotFound");
245        assert!(err.to_string().contains("id=1"), "obtido: {err}");
246    }
247
248    // v1.1.1 (P5): --name and --id are mutually exclusive at the clap level,
249    // and at least one selector is required.
250    #[derive(clap::Parser)]
251    struct TestCli {
252        #[command(flatten)]
253        args: RenameEntityArgs,
254    }
255
256    #[test]
257    fn clap_rejects_name_combined_with_id() {
258        use clap::Parser;
259        let err =
260            match TestCli::try_parse_from(["t", "--name", "auth", "--id", "42", "--new-name", "x"])
261            {
262                Ok(_) => panic!("expected argument conflict"),
263                Err(e) => e,
264            };
265        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
266    }
267
268    #[test]
269    fn clap_requires_name_or_id() {
270        use clap::Parser;
271        assert!(TestCli::try_parse_from(["t", "--new-name", "x"]).is_err());
272        let ok = match TestCli::try_parse_from(["t", "--id", "7", "--new-name", "x"]) {
273            Ok(cli) => cli,
274            Err(e) => panic!("expected successful parse: {e}"),
275        };
276        assert_eq!(ok.args.id, Some(7));
277        assert!(ok.args.name.is_none());
278    }
279
280    #[test]
281    fn rename_entity_response_serializes_all_fields() {
282        let resp = RenameEntityResponse {
283            action: "renamed".to_string(),
284            old_name: "auth".to_string(),
285            new_name: "authentication".to_string(),
286            entity_id: 42,
287            namespace: "global".to_string(),
288            elapsed_ms: 7,
289        };
290        let json = serde_json::to_value(&resp).expect("serialization failed");
291        assert_eq!(json["action"], "renamed");
292        assert_eq!(json["old_name"], "auth");
293        assert_eq!(json["new_name"], "authentication");
294        assert_eq!(json["entity_id"], 42);
295        assert_eq!(json["namespace"], "global");
296        assert!(json["elapsed_ms"].is_number());
297    }
298
299    #[test]
300    fn rename_entity_response_action_is_renamed() {
301        let resp = RenameEntityResponse {
302            action: "renamed".to_string(),
303            old_name: "x".to_string(),
304            new_name: "y".to_string(),
305            entity_id: 1,
306            namespace: "ns".to_string(),
307            elapsed_ms: 1,
308        };
309        assert_eq!(resp.action, "renamed");
310    }
311
312    #[test]
313    fn rename_entity_response_entity_id_preserved() {
314        let resp = RenameEntityResponse {
315            action: "renamed".to_string(),
316            old_name: "old".to_string(),
317            new_name: "new".to_string(),
318            entity_id: 999,
319            namespace: "test-ns".to_string(),
320            elapsed_ms: 5,
321        };
322        let json = serde_json::to_value(&resp).expect("serialization failed");
323        assert_eq!(json["entity_id"], 999);
324    }
325
326    #[test]
327    fn rejects_rename_entity_to_same_name() {
328        use crate::errors::AppError;
329        let err = AppError::Validation("source and target entity names are identical".to_string());
330        assert_eq!(err.exit_code(), 1);
331        assert!(err.to_string().contains("identical"));
332    }
333
334    #[test]
335    fn rename_entity_response_namespace_reflected() {
336        let resp = RenameEntityResponse {
337            action: "renamed".to_string(),
338            old_name: "a".to_string(),
339            new_name: "b".to_string(),
340            entity_id: 10,
341            namespace: "my-project".to_string(),
342            elapsed_ms: 2,
343        };
344        let json = serde_json::to_value(&resp).expect("serialization failed");
345        assert_eq!(json["namespace"], "my-project");
346    }
347}