use rusqlite::Connection;
use crate::types::{GraphError, NodeRecord, Result};
const SCHEMA_VERSION: u64 = 3;
pub fn init_schema(conn: &Connection) -> Result<()> {
conn.execute_batch(
"
BEGIN;
CREATE TABLE IF NOT EXISTS nodes (
key BLOB PRIMARY KEY,
label TEXT NOT NULL DEFAULT '',
value BLOB NOT NULL
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_nodes_label ON nodes(label);
CREATE TABLE IF NOT EXISTS adj_out (
key BLOB PRIMARY KEY,
value BLOB NOT NULL
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS adj_in (
key BLOB PRIMARY KEY,
value BLOB NOT NULL
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS edge_props (
key BLOB PRIMARY KEY,
value BLOB NOT NULL
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value BLOB NOT NULL
) WITHOUT ROWID;
COMMIT;
",
)?;
conn.execute(
"INSERT OR IGNORE INTO metadata (key, value) VALUES ('schema_version', ?1)",
[&SCHEMA_VERSION.to_be_bytes()[..]],
)?;
conn.execute(
"INSERT OR IGNORE INTO metadata (key, value) VALUES ('next_node_id', ?1)",
[&1u64.to_be_bytes()[..]],
)?;
conn.execute(
"INSERT OR IGNORE INTO metadata (key, value) VALUES ('next_edge_seq', ?1)",
[&0u64.to_be_bytes()[..]],
)?;
let mut stmt =
conn.prepare_cached("SELECT value FROM metadata WHERE key = 'schema_version'")?;
let db_version = if let Ok(raw) = stmt.query_row([], |row| row.get::<_, Vec<u8>>(0)) {
if raw.len() == 8 {
if let Ok(bytes) = <[u8; 8]>::try_from(&raw[..8]) {
u64::from_be_bytes(bytes)
} else {
0
}
} else {
0
}
} else {
0
};
drop(stmt);
if db_version > SCHEMA_VERSION {
return Err(GraphError::SchemaMismatch {
found: db_version,
supported: SCHEMA_VERSION,
hint: None,
});
}
if db_version == 1 {
migrate_v1_to_v2(conn)?;
}
if db_version <= 2 {
migrate_v2_to_v3(conn)?;
}
Ok(())
}
fn migrate_v1_to_v2(conn: &Connection) -> Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS nodes_v2 (
key BLOB PRIMARY KEY,
label TEXT NOT NULL DEFAULT '',
value BLOB NOT NULL
) WITHOUT ROWID;",
)?;
let mut read_stmt = conn.prepare("SELECT key, value FROM nodes ORDER BY key")?;
let rows: Vec<(Vec<u8>, Vec<u8>)> = read_stmt
.query_map([], |row| {
Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, Vec<u8>>(1)?))
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
drop(read_stmt);
let mut insert_stmt =
conn.prepare("INSERT INTO nodes_v2 (key, label, value) VALUES (?1, ?2, ?3)")?;
for (key, data) in &rows {
let label = match rmp_serde::from_slice::<NodeRecord>(data) {
Ok(record) => record.labels.join(":"),
Err(_) => String::new(),
};
insert_stmt.execute(rusqlite::params![key, label, data])?;
}
drop(insert_stmt);
conn.execute_batch(
"DROP TABLE nodes;
ALTER TABLE nodes_v2 RENAME TO nodes;
CREATE INDEX idx_nodes_label ON nodes(label);",
)?;
conn.execute(
"INSERT OR REPLACE INTO metadata (key, value) VALUES ('schema_version', ?1)",
[&2u64.to_be_bytes()[..]],
)?;
Ok(())
}
fn migrate_v2_to_v3(conn: &Connection) -> Result<()> {
use std::collections::HashMap;
let mut counts: HashMap<String, u64> = HashMap::new();
{
let mut stmt = conn.prepare("SELECT key FROM edge_props")?;
let rows = stmt.query_map([], |row| row.get::<_, Vec<u8>>(0))?;
for row in rows {
let key = row?;
let label = crate::storage::edge::label_from_edge_props_key(&key)
.ok_or_else(|| GraphError::Serialization {
context: "migrate_v2_to_v3".to_string(),
source: format!("malformed edge_props key (len={})", key.len()),
hint: None,
})?
.to_string();
*counts.entry(label).or_insert(0) += 1;
}
}
for (type_name, count) in counts {
let key = format!("stats:edge_type_count:{type_name}");
conn.execute(
"INSERT OR REPLACE INTO metadata (key, value) VALUES (?1, ?2)",
rusqlite::params![key, count.to_be_bytes().as_slice()],
)?;
}
conn.execute(
"INSERT OR REPLACE INTO metadata (key, value) VALUES ('schema_version', ?1)",
[&3u64.to_be_bytes()[..]],
)?;
Ok(())
}
#[cfg(test)]
mod migration_v3_tests {
use super::*;
use rusqlite::Connection;
fn seed_v2_db_with_raw_edges() -> Connection {
let conn = Connection::open_in_memory().unwrap();
init_schema(&conn).unwrap();
conn.execute(
"INSERT OR REPLACE INTO metadata (key, value) VALUES ('schema_version', ?1)",
[&2u64.to_be_bytes()[..]],
)
.unwrap();
conn.execute(
"DELETE FROM metadata WHERE key LIKE 'stats:edge_type_count:%'",
[],
)
.unwrap();
let put = |src: u64, dst: u64, label: &str, seq: u64| {
let mut key = Vec::new();
key.extend_from_slice(&src.to_be_bytes());
key.extend_from_slice(&dst.to_be_bytes());
key.extend_from_slice(label.as_bytes());
key.push(0x00);
key.extend_from_slice(&seq.to_be_bytes());
conn.execute(
"INSERT INTO edge_props (key, value) VALUES (?1, ?2)",
rusqlite::params![key, &[][..]],
)
.unwrap();
};
put(1, 2, "KNOWS", 0);
put(1, 2, "KNOWS", 1);
put(2, 3, "KNOWS", 2);
put(1, 2, "WORKS_AT", 3);
conn
}
#[test]
fn migrate_v2_to_v3_backfills_edge_type_counts() {
let conn = seed_v2_db_with_raw_edges();
init_schema(&conn).unwrap();
let mut got = crate::stats::get_all_edge_type_counts(&conn).unwrap();
got.sort();
assert_eq!(
got,
vec![("KNOWS".to_string(), 3), ("WORKS_AT".to_string(), 1),]
);
let raw: Vec<u8> = conn
.query_row(
"SELECT value FROM metadata WHERE key = 'schema_version'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(raw.len(), 8);
assert_eq!(u64::from_be_bytes(raw.try_into().unwrap()), 3);
}
#[test]
fn migrate_is_idempotent_when_already_v3() {
let conn = Connection::open_in_memory().unwrap();
init_schema(&conn).unwrap();
init_schema(&conn).unwrap();
assert!(crate::stats::get_all_edge_type_counts(&conn)
.unwrap()
.is_empty());
}
}