Building a Graph Database in Rust
Unlike traditional relational databases, which represent data in tables, graph databases capture the rich relationships between entities as…
Unlike traditional relational databases, which represent data in tables, graph databases capture the rich relationships between entities as first-class citizens. They model data as nodes (entities) and edges (relationships), closely mirroring how information is connected and related in real-world systems.
Why Graph Databases?
Graph databases have a unique strength: they can efficiently traverse and query complex relationships. Think of social networks where you want to identify mutual friends, or recommendation systems where you’d like to find related products based on user behavior. Such operations, which could be cumbersome and inefficient in relational databases, become inherently natural in graph databases.
Use Cases:
- Social Networks: Graph databases excel at representing and querying social relations, making operations like “friends of friends” exceptionally fast.
- Recommendation Engines: Services like Netflix or Amazon use graph-like structures to provide users with product or movie recommendations based on user behavior and preferences.
- Fraud Detection: Financial institutions utilize graph databases to detect unusual patterns or connections that might indicate fraudulent activities.
- Supply Chain & Logistics: Understanding dependencies and relationships in supply routes, especially to identify vulnerabilities or optimize for efficiency.
- Knowledge Graphs: Entities and their interrelations, such as those used by Google to enhance its search results, are best represented as graphs.
- Bioinformatics: In genetics research, graph databases can model the intricate relationships and pathways of genes, proteins, and more.
Given this backdrop, there’s a growing interest in understanding graph databases and even building custom ones tailored for specific needs. In this article, we’ll use Rust, a language celebrated for its safety and performance, to construct a simple yet illustrative graph database. Whether you’re diving into this out of sheer curiosity or have a specific project in mind, this hands-on guide aims to provide a solid foundation.
Let’s Begin!
Step 1: Setting Up the Project
Initialize a new Rust project:
cargo new graph_db
cd graph_db
Step 2: Defining Our Data Structures
We’ll start by defining the primary entities: Nodes and Relationships.
use std::collections::HashMap;
#[derive(Debug, Clone)]
struct Node {
id: u32,
label: String,
properties: HashMap<String, String>,
}
#[derive(Debug, Clone)]
struct Relationship {
id: u32,
label: String,
start_node: u32,
end_node: u32,
properties: HashMap<String, String>,
}
Each Node
and Relationship
has an ID, label, and a set of properties.
Step 3: Creating the GraphDatabase Structure
This will be the main database object holding nodes and relationships.
#[derive(Debug)]
struct GraphDatabase {
nodes: HashMap<u32, Node>,
relationships: HashMap<u32, Relationship>,
}
Step 4: Implementing Database Methods
impl GraphDatabase {
fn new() -> Self {
GraphDatabase {
nodes: HashMap::new(),
relationships: HashMap::new(),
}
}
fn add_node(&mut self, id: u32, label: String, properties: HashMap<String, String>) {
let node = Node { id, label, properties };
self.nodes.insert(id, node);
}
fn add_relationship(&mut self, id: u32, label: String, start_node: u32, end_node: u32, properties: HashMap<String, String>) {
let relationship = Relationship { id, label, start_node, end_node, properties };
self.relationships.insert(id, relationship);
}
// ... additional methods for querying and updates ...
}
Step 5: Building the CLI
A Command-Line Interface (CLI) allows us to interact with our database.
For this, you can parse user input using Rust’s std::io
library and execute corresponding database commands.
Step 6: Coloring the CLI
To enhance the CLI, you can use the colored
crate for colorful terminal output.
[dependencies]
colored = "2.0"
With this crate, you can easily color strings, e.g., "text".blue()
.
Step 7: The Main Loop and Initialization Screen
The main function will house our command loop and an initialization screen to greet the user.
use colored::*;
use std::thread;
use std::time::Duration;
// ... previous code ...
fn clear_screen() {
print!("\x1B[2J\x1B[H");
io::stdout().flush().unwrap();
}
fn main() {
println!("{}", "Welcome to Rust Graph".green().bold());
thread::sleep(Duration::from_secs(3));
clear_screen();
let mut db = GraphDatabase::new();
loop {
// Command loop logic
}
}
Writing Tests for the GraphDatabase
Let’s start by writing tests for the methods in our GraphDatabase
.
1. Testing Node Creation
We want to ensure that after adding a node, we can retrieve it by its ID.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_and_get_node() {
let mut db = GraphDatabase::new();
let properties = HashMap::new(); // Initially, an empty property set.
db.add_node(1, "User".to_string(), properties.clone());
let node = db.get_node_by_id(1).unwrap();
assert_eq!(node.label, "User");
assert_eq!(node.properties, properties);
}
}
2. Testing Relationship Creation
We want to confirm that adding relationships works and that they link the correct nodes.
#[test]
fn test_add_and_get_relationship() {
let mut db = GraphDatabase::new();
let properties = HashMap::new();
db.add_node(1, "User".to_string(), properties.clone());
db.add_node(2, "Post".to_string(), properties.clone());
db.add_relationship(1, "WROTE".to_string(), 1, 2, properties.clone());
let relationship = db.relationships.get(&1).unwrap();
assert_eq!(relationship.start_node, 1);
assert_eq!(relationship.end_node, 2);
assert_eq!(relationship.label, "WROTE");
}
3. Testing Node Querying by Property
This test ensures that we can fetch nodes based on property values.
#[test]
fn test_query_node_by_property() {
let mut db = GraphDatabase::new();
let mut properties = HashMap::new();
properties.insert("username".to_string(), "alice".to_string());
db.add_node(1, "User".to_string(), properties);
let nodes = db.get_nodes_by_property("username", "alice");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].id, 1);
}
Running the Tests
Simply run the following command in your terminal:
cargo test
This will compile and run all tests in your crate.
Putting it all together
That’s the updated code:
use std::collections::HashMap;
use std::{io, thread};
use std::io::Write;
use std::time::Duration;
use colored::Colorize;
#[derive(Debug, Clone)]
struct Node {
id: u32,
label: String,
properties: HashMap<String, String>,
}
#[derive(Debug, Clone)]
struct Relationship {
id: u32,
label: String,
start_node: u32,
end_node: u32,
properties: HashMap<String, String>,
}
#[derive(Debug)]
struct GraphDatabase {
nodes: HashMap<u32, Node>,
relationships: HashMap<u32, Relationship>,
}
impl GraphDatabase {
fn new() -> Self {
GraphDatabase {
nodes: HashMap::new(),
relationships: HashMap::new(),
}
}
fn add_node(&mut self, id: u32, label: String, properties: HashMap<String, String>) {
let node = Node { id, label, properties };
self.nodes.insert(id, node);
}
fn add_relationship(&mut self, id: u32, label: String, start_node: u32, end_node: u32, properties: HashMap<String, String>) {
let relationship = Relationship { id, label, start_node, end_node, properties };
self.relationships.insert(id, relationship);
}
fn get_node_by_id(&self, id: u32) -> Option<&Node> {
self.nodes.get(&id)
}
fn get_relationships_of_node(&self, node_id: u32) -> Vec<&Relationship> {
self.relationships.values().filter(|&rel| rel.start_node == node_id || rel.end_node == node_id).collect()
}
fn get_nodes_by_property(&self, key: &str, value: &str) -> Vec<&Node> {
self.nodes.values().filter(|&node| node.properties.get(key) == Some(&value.to_string())).collect()
}
}
fn main() {
// Initialization screen
println!("{}", "Welcome to Rust Graph".green().bold());
// Sleep for 3 seconds
thread::sleep(Duration::from_secs(3));
let mut db = GraphDatabase::new();
loop {
print!("{} ", "graphdb>".blue().bold());
io::stdout().flush().unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).expect("Failed to read line");
let parts: Vec<&str> = input.trim().split_whitespace().collect();
match parts.as_slice() {
["exit"] => break,
["add", "node", id, label, properties] => {
// For simplicity, assume properties are in the format key:value,key2:value2,...
let properties: HashMap<_, _> = properties.split(',')
.map(|s| {
let parts: Vec<_> = s.split(':').collect();
(parts[0].to_string(), parts[1].to_string())
})
.collect();
db.add_node(id.parse().unwrap(), label.to_string(), properties);
println!("Node added.");
},
["add", "relationship", id, label, start, end, properties] => {
let properties: HashMap<_, _> = properties.split(',')
.map(|s| {
let parts: Vec<_> = s.split(':').collect();
(parts[0].to_string(), parts[1].to_string())
})
.collect();
db.add_relationship(id.parse().unwrap(), label.to_string(), start.parse().unwrap(), end.parse().unwrap(), properties);
println!("Relationship added.");
},
["query", "node", "id", id] => {
match db.get_node_by_id(id.parse().unwrap()) {
Some(node) => println!("{:?}", node),
None => println!("Node not found."),
}
},
["query", "node", "property", key, value] => {
let nodes = db.get_nodes_by_property(key, value);
for node in nodes {
println!("{:?}", node);
}
},
_ => println!("Unknown command."),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_and_get_node() {
let mut db = GraphDatabase::new();
let properties = HashMap::new(); // Initially, an empty property set.
db.add_node(1, "User".to_string(), properties.clone());
let node = db.get_node_by_id(1).unwrap();
assert_eq!(node.label, "User");
assert_eq!(node.properties, properties);
}
#[test]
fn test_add_and_get_relationship() {
let mut db = GraphDatabase::new();
let properties = HashMap::new();
db.add_node(1, "User".to_string(), properties.clone());
db.add_node(2, "Post".to_string(), properties.clone());
db.add_relationship(1, "WROTE".to_string(), 1, 2, properties.clone());
let relationship = db.relationships.get(&1).unwrap();
assert_eq!(relationship.start_node, 1);
assert_eq!(relationship.end_node, 2);
assert_eq!(relationship.label, "WROTE");
}
#[test]
fn test_query_node_by_property() {
let mut db = GraphDatabase::new();
let mut properties = HashMap::new();
properties.insert("username".to_string(), "alice".to_string());
db.add_node(1, "User".to_string(), properties);
let nodes = db.get_nodes_by_property("username", "alice");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].id, 1);
}
}
Wrapping Up
In this guide, we embarked on an exploratory journey into the world of graph databases and learned how to craft one using Rust. From understanding the unique strengths of graph databases to delving deep into its use cases like social networks, recommendation engines, and fraud detection, we provided a hands-on introduction to this exciting domain. By leveraging Rust’s powerful features, we demonstrated the construction of a simple, yet illustrative graph database, offering readers a tangible grasp of both graph databases and Rust’s capabilities.
If you found this approach of learning Rust through hands-on projects captivating, we encourage you to delve deeper into similar projects. Here are a few intriguing articles you might enjoy:
🛠 Developing a Fully Functional API Gateway in Rust — Dive into setting up a robust and scalable gateway for your microservices.
🛠 Implementing a Network Traffic Analyzer — Decode the mysteries of data packets traveling through your network.
🛠 Building an Application Container in Rust — Craft a performant, secure container from the ground up.
🛠 Crafting a Secure Server-to-Server Handshake with Rust & OpenSSL — Venture into establishing a unique, secure handshake between servers.
🛠 Building a Function-as-a-Service (FaaS) in Rust — Dive into cloud computing and craft your own FaaS platform.
🛠 Implementing a P2P Database in Rust — Understand and construct a Peer-to-Peer key-value database.
🛠 Implementing a VPN Server in Rust — Dive into the world of VPNs and set up your server.
Happy coding, and remember: the best way to learn is by doing!
Read more articles about Rust in my Rust Programming Library!
Visit my Blog for more articles, news, and software engineering stuff!
Follow me on Medium, LinkedIn, and Twitter.
Leave a comment, and drop me a message!
All the best,
Luis Soares
CTO | Tech Lead | Senior Software Engineer | Cloud Solutions Architect | Rust 🦀 | Golang | Java | ML AI & Statistics | Web3 & Blockchain