Skip to main content

sqlite_graphrag/commands/
read.rs

1//! Handler for the `read` CLI subcommand.
2
3use crate::errors::AppError;
4use crate::output;
5use crate::paths::AppPaths;
6use crate::storage::connection::open_ro;
7use crate::storage::memories;
8use serde::Serialize;
9
10#[derive(clap::Args)]
11#[command(after_long_help = "EXAMPLES:\n  \
12    # Read a memory by name (positional)\n  \
13    sqlite-graphrag read onboarding\n\n  \
14    # Read using the named flag form\n  \
15    sqlite-graphrag read --name onboarding\n\n  \
16    # Read by memory ID (integer emitted in JSON output of most commands)\n  \
17    sqlite-graphrag read --id 42 --json\n\n  \
18    # Read from a specific namespace\n  \
19    sqlite-graphrag read onboarding --namespace my-project")]
20pub struct ReadArgs {
21    /// Memory name as a positional argument. Alternative to `--name`.
22    #[arg(
23        value_name = "NAME",
24        conflicts_with = "name",
25        help = "Memory name (kebab-case slug); alternative to --name"
26    )]
27    pub name_positional: Option<String>,
28    /// Memory name to read. Returns NotFound (exit 4) if missing or soft-deleted.
29    #[arg(long)]
30    pub name: Option<String>,
31    /// Memory ID (integer) for direct lookup. Conflicts with --name and positional NAME.
32    #[arg(
33        long,
34        conflicts_with_all = ["name", "name_positional"],
35        help = "Memory ID (integer) for direct lookup"
36    )]
37    pub id: Option<i64>,
38    #[arg(
39        long,
40        help = "Namespace (env: SQLITE_GRAPHRAG_NAMESPACE, default: global)"
41    )]
42    pub namespace: Option<String>,
43    /// Include linked entities and relationships in the response.
44    #[arg(
45        long,
46        help = "Include graph context (entities + relationships) in response"
47    )]
48    pub with_graph: bool,
49    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
50    pub json: bool,
51    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
52    pub db: Option<String>,
53}
54
55#[derive(Serialize)]
56struct ReadResponse {
57    /// Canonical storage field. Preserved for compatibility with v2.0.0 clients.
58    id: i64,
59    /// Semantic alias of `id` for the contract documented in SKILL.md.
60    memory_id: i64,
61    namespace: String,
62    name: String,
63    /// Semantic alias of `memory_type` for the documented contract.
64    #[serde(rename = "type")]
65    type_alias: String,
66    memory_type: String,
67    description: String,
68    body: String,
69    body_hash: String,
70    session_id: Option<String>,
71    source: String,
72    metadata: serde_json::Value,
73    /// Most recent memory version, useful for optimistic control via `--expected-updated-at`.
74    version: i64,
75    created_at: i64,
76    /// RFC 3339 UTC timestamp parallel to `created_at` for ISO 8601 parsers.
77    created_at_iso: String,
78    updated_at: i64,
79    /// RFC 3339 UTC timestamp parallel to `updated_at` for ISO 8601 parsers.
80    updated_at_iso: String,
81    /// Linked entities (opt-in via --with-graph).
82    #[serde(skip_serializing_if = "Option::is_none")]
83    entities: Option<Vec<ReadEntityBinding>>,
84    /// Relationships from linked entities (opt-in via --with-graph).
85    #[serde(skip_serializing_if = "Option::is_none")]
86    relationships: Option<Vec<ReadRelationshipBinding>>,
87    /// Total execution time in milliseconds from handler start to serialisation.
88    elapsed_ms: u64,
89}
90
91#[derive(Serialize)]
92struct ReadEntityBinding {
93    entity_id: i64,
94    name: String,
95    entity_type: String,
96}
97
98#[derive(Serialize)]
99struct ReadRelationshipBinding {
100    from: String,
101    to: String,
102    relation: String,
103    weight: f64,
104}
105
106fn epoch_to_iso(epoch: i64) -> String {
107    crate::tz::epoch_to_iso(epoch)
108}
109
110pub fn run(args: ReadArgs) -> Result<(), AppError> {
111    let start = std::time::Instant::now();
112    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
113    let paths = AppPaths::resolve(args.db.as_deref())?;
114    crate::storage::connection::ensure_db_ready(&paths)?;
115    let conn = open_ro(&paths.db)?;
116
117    let row_opt = if let Some(id) = args.id {
118        let r = memories::read_full(&conn, id)?;
119        if let Some(ref row) = r {
120            if row.namespace != namespace {
121                return Err(AppError::NotFound(format!(
122                    "memory id {id} exists but belongs to namespace '{}', not '{namespace}'",
123                    row.namespace
124                )));
125            }
126        }
127        r
128    } else {
129        let name = args.name_positional.or(args.name).ok_or_else(|| {
130            AppError::Validation(
131                "name or --id required: pass name as positional argument, via --name, or use --id"
132                    .to_string(),
133            )
134        })?;
135        memories::read_by_name(&conn, &namespace, &name)?
136    };
137
138    match row_opt {
139        Some(row) => {
140            // Resolve current version via memory_versions table (highest version for this memory_id).
141            let version: i64 = conn
142                .query_row(
143                    "SELECT COALESCE(MAX(version), 1) FROM memory_versions WHERE memory_id=?1",
144                    rusqlite::params![row.id],
145                    |r| r.get(0),
146                )
147                .unwrap_or(1);
148
149            // G22: optional graph context
150            let (entities, relationships) = if args.with_graph {
151                let mut ent_stmt = conn.prepare_cached(
152                    "SELECT e.id, e.name, e.type FROM memory_entities me \
153                     JOIN entities e ON e.id = me.entity_id \
154                     WHERE me.memory_id = ?1",
155                )?;
156                let ents: Vec<ReadEntityBinding> = ent_stmt
157                    .query_map(rusqlite::params![row.id], |r| {
158                        Ok(ReadEntityBinding {
159                            entity_id: r.get(0)?,
160                            name: r.get(1)?,
161                            entity_type: r.get(2)?,
162                        })
163                    })?
164                    .filter_map(|r| r.ok())
165                    .collect();
166                drop(ent_stmt);
167
168                let entity_ids: Vec<i64> = ents.iter().map(|e| e.entity_id).collect();
169                let rels: Vec<ReadRelationshipBinding> = if !entity_ids.is_empty() {
170                    let placeholders: String = entity_ids
171                        .iter()
172                        .map(|id| id.to_string())
173                        .collect::<Vec<_>>()
174                        .join(",");
175                    let sql = format!(
176                        "SELECT e1.name, e2.name, r.relation, r.weight \
177                         FROM relationships r \
178                         JOIN entities e1 ON e1.id = r.source_id \
179                         JOIN entities e2 ON e2.id = r.target_id \
180                         WHERE r.source_id IN ({placeholders}) OR r.target_id IN ({placeholders})"
181                    );
182                    let mut rel_stmt = conn.prepare(&sql)?;
183                    let result: Vec<ReadRelationshipBinding> = rel_stmt
184                        .query_map([], |r| {
185                            Ok(ReadRelationshipBinding {
186                                from: r.get(0)?,
187                                to: r.get(1)?,
188                                relation: r.get(2)?,
189                                weight: r.get(3)?,
190                            })
191                        })?
192                        .filter_map(|r| r.ok())
193                        .collect();
194                    drop(rel_stmt);
195                    result
196                } else {
197                    vec![]
198                };
199                (Some(ents), Some(rels))
200            } else {
201                (None, None)
202            };
203
204            let response = ReadResponse {
205                id: row.id,
206                memory_id: row.id,
207                namespace: row.namespace,
208                name: row.name,
209                type_alias: row.memory_type.clone(),
210                memory_type: row.memory_type,
211                description: row.description,
212                body: row.body,
213                body_hash: row.body_hash,
214                session_id: row.session_id,
215                source: row.source,
216                metadata: serde_json::from_str::<serde_json::Value>(&row.metadata)
217                    .unwrap_or(serde_json::Value::Null),
218                version,
219                created_at: row.created_at,
220                created_at_iso: epoch_to_iso(row.created_at),
221                updated_at: row.updated_at,
222                updated_at_iso: epoch_to_iso(row.updated_at),
223                entities,
224                relationships,
225                elapsed_ms: start.elapsed().as_millis() as u64,
226            };
227            output::emit_json(&response)?;
228        }
229        None => {
230            let label = if let Some(id) = args.id {
231                format!("id={id}")
232            } else {
233                "unknown".to_string()
234            };
235            return Err(AppError::NotFound(format!(
236                "memory not found: {label} in namespace '{namespace}'"
237            )));
238        }
239    }
240
241    Ok(())
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn epoch_to_iso_converts_zero_to_unix_epoch() {
250        let result = epoch_to_iso(0);
251        assert!(
252            result.starts_with("1970-01-01T00:00:00"),
253            "epoch 0 must map to 1970-01-01T00:00:00, got: {result}"
254        );
255    }
256
257    #[test]
258    fn epoch_to_iso_converts_known_timestamp() {
259        let result = epoch_to_iso(1_705_320_000);
260        assert!(
261            result.starts_with("2024-01-15"),
262            "timestamp 1705320000 must map to 2024-01-15, got: {result}"
263        );
264    }
265
266    #[test]
267    fn epoch_to_iso_returns_fallback_for_invalid_negative_epoch() {
268        let result = epoch_to_iso(i64::MIN);
269        assert!(
270            !result.is_empty(),
271            "must return a non-empty string even for invalid epoch"
272        );
273    }
274
275    #[test]
276    fn read_response_serializes_id_and_memory_id_aliases() {
277        let resp = ReadResponse {
278            id: 42,
279            memory_id: 42,
280            namespace: "global".to_string(),
281            name: "my-mem".to_string(),
282            type_alias: "fact".to_string(),
283            memory_type: "fact".to_string(),
284            description: "desc".to_string(),
285            body: "body".to_string(),
286            body_hash: "abc123".to_string(),
287            session_id: None,
288            source: "agent".to_string(),
289            metadata: serde_json::json!({}),
290            version: 1,
291            created_at: 1_705_320_000,
292            created_at_iso: "2024-01-15T12:00:00Z".to_string(),
293            updated_at: 1_705_320_000,
294            updated_at_iso: "2024-01-15T12:00:00Z".to_string(),
295            entities: None,
296            relationships: None,
297            elapsed_ms: 5,
298        };
299
300        let json = serde_json::to_value(&resp).expect("serialization failed");
301        assert_eq!(json["id"], 42);
302        assert_eq!(json["memory_id"], 42);
303        assert_eq!(json["type"], "fact");
304        assert_eq!(json["memory_type"], "fact");
305        assert_eq!(json["elapsed_ms"], 5u64);
306        assert!(
307            json["session_id"].is_null(),
308            "session_id None must serialize as null"
309        );
310        // metadata must serialize as a JSON object, not as an escaped string
311        assert!(
312            json["metadata"].is_object(),
313            "metadata must be a JSON object"
314        );
315    }
316
317    #[test]
318    fn read_response_session_id_some_serializes_string() {
319        let resp = ReadResponse {
320            id: 1,
321            memory_id: 1,
322            namespace: "global".to_string(),
323            name: "mem".to_string(),
324            type_alias: "skill".to_string(),
325            memory_type: "skill".to_string(),
326            description: "d".to_string(),
327            body: "b".to_string(),
328            body_hash: "h".to_string(),
329            session_id: Some("sess-123".to_string()),
330            source: "agent".to_string(),
331            metadata: serde_json::json!({}),
332            version: 2,
333            created_at: 0,
334            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
335            updated_at: 0,
336            updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
337            entities: None,
338            relationships: None,
339            elapsed_ms: 0,
340        };
341
342        let json = serde_json::to_value(&resp).expect("serialization failed");
343        assert_eq!(json["session_id"], "sess-123");
344    }
345
346    #[test]
347    fn read_response_elapsed_ms_is_present() {
348        let resp = ReadResponse {
349            id: 7,
350            memory_id: 7,
351            namespace: "ns".to_string(),
352            name: "n".to_string(),
353            type_alias: "procedure".to_string(),
354            memory_type: "procedure".to_string(),
355            description: "d".to_string(),
356            body: "b".to_string(),
357            body_hash: "h".to_string(),
358            session_id: None,
359            source: "agent".to_string(),
360            metadata: serde_json::json!({}),
361            version: 3,
362            created_at: 1000,
363            created_at_iso: "1970-01-01T00:16:40Z".to_string(),
364            updated_at: 2000,
365            updated_at_iso: "1970-01-01T00:33:20Z".to_string(),
366            entities: None,
367            relationships: None,
368            elapsed_ms: 123,
369        };
370
371        let json = serde_json::to_value(&resp).expect("serialization failed");
372        assert_eq!(json["elapsed_ms"], 123u64);
373        assert!(json["created_at_iso"].is_string());
374        assert!(json["updated_at_iso"].is_string());
375    }
376
377    #[test]
378    fn read_response_metadata_object_not_escaped_string() {
379        // P2-A: metadata must serialize as a JSON object, not as an escaped string.
380        let resp = ReadResponse {
381            id: 3,
382            memory_id: 3,
383            namespace: "ns".to_string(),
384            name: "meta-test".to_string(),
385            type_alias: "fact".to_string(),
386            memory_type: "fact".to_string(),
387            description: "d".to_string(),
388            body: "b".to_string(),
389            body_hash: "h".to_string(),
390            session_id: None,
391            source: "agent".to_string(),
392            metadata: serde_json::json!({"key": "value", "number": 42}),
393            version: 1,
394            created_at: 0,
395            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
396            updated_at: 0,
397            updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
398            entities: None,
399            relationships: None,
400            elapsed_ms: 1,
401        };
402
403        let json = serde_json::to_value(&resp).expect("serialization failed");
404        // Must be object, not a JSON string containing escaped JSON.
405        assert!(json["metadata"].is_object());
406        assert_eq!(json["metadata"]["key"], "value");
407        assert_eq!(json["metadata"]["number"], 42);
408    }
409
410    #[test]
411    fn read_response_metadata_fallback_to_null_for_invalid_json() {
412        // P2-A: fallback when metadata is an invalid string.
413        let raw = "invalid-json{{{";
414        let parsed =
415            serde_json::from_str::<serde_json::Value>(raw).unwrap_or(serde_json::Value::Null);
416        assert!(parsed.is_null());
417    }
418}