Skip to main content

sqlite_graphrag/commands/
stats.rs

1//! Handler for the `stats` CLI subcommand.
2
3use crate::errors::AppError;
4use crate::output;
5use crate::paths::AppPaths;
6use crate::storage::connection::open_ro;
7use serde::Serialize;
8
9#[derive(clap::Args)]
10#[command(after_long_help = "EXAMPLES:\n  \
11    # Show database statistics (memory counts, sizes, namespace breakdown)\n  \
12    sqlite-graphrag stats\n\n  \
13    # Stats for a database at a custom path\n  \
14    sqlite-graphrag stats --db /path/to/graphrag.sqlite\n\n  \
15    # Explicit database path\n  \
16    sqlite-graphrag stats --db /data/graphrag.sqlite")]
17/// Stats args.
18pub struct StatsArgs {
19    /// Path to the SQLite database file.
20    #[arg(long)]
21    pub db: Option<String>,
22    /// Explicit JSON flag. Accepted as a no-op because output is already JSON by default.
23    #[arg(long, default_value_t = false)]
24    pub json: bool,
25    /// Output format: `json` or `text`. JSON is always emitted on stdout regardless of the value.
26    #[arg(long, value_parser = ["json", "text"], hide = true)]
27    pub format: Option<String>,
28}
29
30#[derive(Serialize)]
31struct StatsResponse {
32    memories: i64,
33    /// Alias of `memories` for the documented contract in SKILL.md.
34    memories_total: i64,
35    /// GAP-SG-43: documented alias consumers read as `.total_memories`. Was
36    /// absent (so clients saw `null`); now populated with the live memory count
37    /// so `stats --json` is self-sufficient without a `list` fallback.
38    total_memories: i64,
39    entities: i64,
40    /// Alias of `entities` for the documented contract.
41    entities_total: i64,
42    relationships: i64,
43    /// Alias of `relationships` for the documented contract.
44    relationships_total: i64,
45    /// Semantic alias of `relationships` per the contract in SKILL.md.
46    edges: i64,
47    /// Total indexed chunks (one row per chunk in `memory_chunks`).
48    chunks_total: i64,
49    /// Average length of the body field in active (non-deleted) memories.
50    avg_body_len: f64,
51    namespaces: Vec<String>,
52    db_size_bytes: u64,
53    /// Semantic alias of `db_size_bytes` for the documented contract.
54    db_bytes: u64,
55    /// Latest applied migration number from `refinery_schema_history`.
56    /// Emitted as a JSON number for cross-command consistency with `health` (since v1.0.35).
57    /// Returns `0` when the database has no recorded migrations yet.
58    schema_version: u32,
59    /// Total execution time in milliseconds from handler start to serialisation.
60    elapsed_ms: u64,
61}
62
63/// Run.
64pub fn run(args: StatsArgs) -> Result<(), AppError> {
65    let start = std::time::Instant::now();
66    let _ = args.json; // --json is a no-op because output is already JSON by default
67    let _ = args.format; // --format is a no-op; JSON is always emitted on stdout
68    let paths = AppPaths::resolve(args.db.as_deref())?;
69
70    crate::storage::connection::ensure_db_ready(&paths)?;
71
72    let conn = open_ro(&paths.db)?;
73
74    let memories: i64 = conn.query_row(
75        "SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL",
76        [],
77        |r| r.get(0),
78    )?;
79    let entities: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?;
80    let relationships: i64 =
81        conn.query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))?;
82
83    let mut stmt = conn.prepare_cached(
84        "SELECT DISTINCT namespace FROM memories WHERE deleted_at IS NULL ORDER BY namespace",
85    )?;
86    let namespaces: Vec<String> = stmt
87        .query_map([], |r| r.get(0))?
88        .collect::<Result<Vec<_>, _>>()?;
89
90    let schema_version: u32 = conn
91        .query_row(
92            "SELECT MAX(version) FROM refinery_schema_history",
93            [],
94            |row| row.get::<_, Option<i64>>(0),
95        )
96        .ok()
97        .flatten()
98        .map(|v| v.max(0) as u32)
99        .unwrap_or(0);
100
101    let db_size_bytes = std::fs::metadata(&paths.db).map(|m| m.len()).unwrap_or(0);
102
103    // v1.0.21 P1-C: query uses the (correct) `memory_chunks` table.
104    // If the table does not exist (legacy pre-chunking DB), the error is "no such table"
105    // and the fallback returns 0. Other errors are logged via tracing for audit.
106    let chunks_total: i64 = match conn.query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| {
107        r.get::<_, i64>(0)
108    }) {
109        Ok(n) => n,
110        Err(rusqlite::Error::SqliteFailure(_, Some(msg))) if msg.contains("no such table") => 0,
111        Err(e) => {
112            tracing::warn!(target: "stats", error = %e, "memory_chunks count failed");
113            0
114        }
115    };
116
117    let avg_body_len: f64 = conn
118        .query_row(
119            "SELECT COALESCE(AVG(LENGTH(body)), 0.0) FROM memories WHERE deleted_at IS NULL",
120            [],
121            |r| r.get(0),
122        )
123        .unwrap_or(0.0);
124
125    output::emit_json(&StatsResponse {
126        memories,
127        memories_total: memories,
128        total_memories: memories,
129        entities,
130        entities_total: entities,
131        relationships,
132        relationships_total: relationships,
133        edges: relationships,
134        chunks_total,
135        avg_body_len,
136        namespaces,
137        db_size_bytes,
138        db_bytes: db_size_bytes,
139        schema_version,
140        elapsed_ms: start.elapsed().as_millis() as u64,
141    })?;
142
143    Ok(())
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn stats_response_serializes_all_fields() {
152        let resp = StatsResponse {
153            memories: 10,
154            memories_total: 10,
155            total_memories: 10,
156            entities: 5,
157            entities_total: 5,
158            relationships: 3,
159            relationships_total: 3,
160            edges: 3,
161            chunks_total: 20,
162            avg_body_len: 42.5,
163            namespaces: vec!["global".to_string(), "project".to_string()],
164            db_size_bytes: 8192,
165            db_bytes: 8192,
166            schema_version: 6,
167            elapsed_ms: 7,
168        };
169        let json = serde_json::to_value(&resp).expect("serialization failed");
170        assert_eq!(json["memories"], 10);
171        assert_eq!(json["memories_total"], 10);
172        // GAP-SG-43: total_memories must be a populated number, never null.
173        assert_eq!(json["total_memories"], 10);
174        assert!(
175            json["total_memories"].is_number(),
176            "total_memories must be a number, not null"
177        );
178        assert_eq!(json["entities"], 5);
179        assert_eq!(json["entities_total"], 5);
180        assert_eq!(json["relationships"], 3);
181        assert_eq!(json["relationships_total"], 3);
182        assert_eq!(json["edges"], 3);
183        assert_eq!(json["chunks_total"], 20);
184        assert_eq!(json["db_size_bytes"], 8192u64);
185        assert_eq!(json["db_bytes"], 8192u64);
186        assert_eq!(json["schema_version"], 6);
187        assert_eq!(json["elapsed_ms"], 7u64);
188    }
189
190    #[test]
191    fn stats_response_namespaces_is_string_array() {
192        let resp = StatsResponse {
193            memories: 0,
194            memories_total: 0,
195            total_memories: 0,
196            entities: 0,
197            entities_total: 0,
198            relationships: 0,
199            relationships_total: 0,
200            edges: 0,
201            chunks_total: 0,
202            avg_body_len: 0.0,
203            namespaces: vec!["ns1".to_string(), "ns2".to_string(), "ns3".to_string()],
204            db_size_bytes: 0,
205            db_bytes: 0,
206            schema_version: 0,
207            elapsed_ms: 0,
208        };
209        let json = serde_json::to_value(&resp).expect("serialization failed");
210        let arr = json["namespaces"]
211            .as_array()
212            .expect("namespaces must be array");
213        assert_eq!(arr.len(), 3);
214        assert_eq!(arr[0], "ns1");
215        assert_eq!(arr[1], "ns2");
216        assert_eq!(arr[2], "ns3");
217    }
218
219    #[test]
220    fn stats_response_namespaces_empty_serializes_empty_array() {
221        let resp = StatsResponse {
222            memories: 0,
223            memories_total: 0,
224            total_memories: 0,
225            entities: 0,
226            entities_total: 0,
227            relationships: 0,
228            relationships_total: 0,
229            edges: 0,
230            chunks_total: 0,
231            avg_body_len: 0.0,
232            namespaces: vec![],
233            db_size_bytes: 0,
234            db_bytes: 0,
235            schema_version: 0,
236            elapsed_ms: 0,
237        };
238        let json = serde_json::to_value(&resp).expect("serialization failed");
239        let arr = json["namespaces"]
240            .as_array()
241            .expect("namespaces must be array");
242        assert!(arr.is_empty(), "empty namespaces must serialize as []");
243    }
244
245    #[test]
246    fn stats_response_aliases_memories_total_and_memories_equal() {
247        let resp = StatsResponse {
248            memories: 42,
249            memories_total: 42,
250            total_memories: 42,
251            entities: 7,
252            entities_total: 7,
253            relationships: 2,
254            relationships_total: 2,
255            edges: 2,
256            chunks_total: 0,
257            avg_body_len: 0.0,
258            namespaces: vec![],
259            db_size_bytes: 0,
260            db_bytes: 0,
261            schema_version: 6,
262            elapsed_ms: 0,
263        };
264        let json = serde_json::to_value(&resp).expect("serialization failed");
265        assert_eq!(json["memories"], json["memories_total"]);
266        assert_eq!(json["entities"], json["entities_total"]);
267        assert_eq!(json["relationships"], json["relationships_total"]);
268        assert_eq!(json["relationships"], json["edges"]);
269        assert_eq!(json["db_size_bytes"], json["db_bytes"]);
270    }
271}