From 857255cdeb087996718335373cb72e7d43a509da Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Wed, 25 Feb 2026 14:23:19 +0100 Subject: [PATCH 01/34] slow graph query logger --- Cargo.lock | 2 + Cargo.toml | 1 + examples/api/api-config.toml | 3 +- examples/watcher/watcher-config.toml | 1 + nexus-common/Cargo.toml | 1 + nexus-common/default.config.toml | 1 + nexus-common/src/db/config/mod.rs | 2 +- nexus-common/src/db/config/neo4j.rs | 9 ++ nexus-common/src/db/connectors/neo4j.rs | 30 ++--- nexus-common/src/db/graph/exec.rs | 19 ++- nexus-common/src/db/graph/graph.rs | 98 +++++++++++++++ nexus-common/src/db/graph/mod.rs | 5 + nexus-common/src/db/graph/queries/del.rs | 2 +- nexus-common/src/db/graph/queries/get.rs | 18 +-- nexus-common/src/db/graph/queries/put.rs | 2 +- nexus-common/src/db/graph/query.rs | 113 ++++++++++++++++++ nexus-common/src/db/graph/setup.rs | 7 +- nexus-common/src/db/mod.rs | 3 +- nexus-common/src/db/reindex.rs | 2 +- nexus-common/src/models/file/details.rs | 2 +- nexus-common/src/models/follow/followers.rs | 2 +- nexus-common/src/models/follow/following.rs | 2 +- nexus-common/src/models/follow/traits.rs | 2 +- nexus-common/src/models/notification/mod.rs | 2 +- nexus-common/src/models/post/search.rs | 2 +- nexus-common/src/models/post/stream.rs | 5 +- .../src/models/tag/traits/collection.rs | 2 +- nexus-common/src/models/traits.rs | 2 +- nexus-common/src/models/user/details.rs | 2 +- .../tests/event_processor/follows/utils.rs | 2 +- .../tests/event_processor/mentions/utils.rs | 2 +- .../tests/event_processor/mutes/utils.rs | 2 +- .../tests/event_processor/posts/utils.rs | 2 +- .../tests/event_processor/tags/utils.rs | 2 +- nexus-webapi/src/mock.rs | 3 +- nexusd/Cargo.toml | 1 + nexusd/src/migrations/default.config.toml | 3 +- nexusd/src/migrations/manager.rs | 11 +- 38 files changed, 305 insertions(+), 65 deletions(-) create mode 100644 nexus-common/src/db/graph/graph.rs create mode 100644 nexus-common/src/db/graph/query.rs diff --git a/Cargo.lock b/Cargo.lock index 64f8d7d30..2a8e21690 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3057,6 +3057,7 @@ dependencies = [ "chrono", "deadpool-redis", "dirs", + "futures", "neo4rs", "opentelemetry 0.31.0", "opentelemetry-appender-tracing", @@ -3155,6 +3156,7 @@ dependencies = [ "chrono", "clap", "dirs", + "futures", "neo4rs", "nexus-common", "nexus-watcher", diff --git a/Cargo.toml b/Cargo.toml index e8dc57d94..7af2c298d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,3 +31,4 @@ thiserror = "2.0.17" tokio = { version = "1.49.0", features = ["full"] } tokio-shared-rt = "0.1" tracing = "0.1.44" +futures = "0.3" diff --git a/examples/api/api-config.toml b/examples/api/api-config.toml index e273a4e99..e016cea6a 100644 --- a/examples/api/api-config.toml +++ b/examples/api/api-config.toml @@ -18,4 +18,5 @@ redis = "redis://127.0.0.1:6379" uri = "bolt://localhost:7687" # Not needed in the Community Edition #user = "neo4j" -password = "12345678" \ No newline at end of file +password = "12345678" +slow_query_threshold_ms=100 \ No newline at end of file diff --git a/examples/watcher/watcher-config.toml b/examples/watcher/watcher-config.toml index 35de107f9..35206c15f 100644 --- a/examples/watcher/watcher-config.toml +++ b/examples/watcher/watcher-config.toml @@ -31,3 +31,4 @@ uri = "bolt://localhost:7687" # Not needed in the Community Edition #user = "neo4j" password = "12345678" +slow_query_threshold_ms=100 \ No newline at end of file diff --git a/nexus-common/Cargo.toml b/nexus-common/Cargo.toml index 3eaace589..606d48a00 100644 --- a/nexus-common/Cargo.toml +++ b/nexus-common/Cargo.toml @@ -11,6 +11,7 @@ license = "MIT" async-trait = { workspace = true } dirs = "6.0.0" chrono = { workspace = true } +futures = { workspace = true } neo4rs = { workspace = true } opentelemetry = { workspace = true } opentelemetry-appender-tracing = "0.31.1" diff --git a/nexus-common/default.config.toml b/nexus-common/default.config.toml index 6caae7f27..7591c139a 100644 --- a/nexus-common/default.config.toml +++ b/nexus-common/default.config.toml @@ -51,3 +51,4 @@ uri = "bolt://localhost:7687" # Not needed in the Community Edition the profile username, just the password #user = "neo4j" password = "12345678" +slow_query_threshold_ms=100 diff --git a/nexus-common/src/db/config/mod.rs b/nexus-common/src/db/config/mod.rs index 134725091..5e4952bbd 100644 --- a/nexus-common/src/db/config/mod.rs +++ b/nexus-common/src/db/config/mod.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use std::fmt::Debug; mod neo4j; -pub use neo4j::Neo4JConfig; +pub use neo4j::{Neo4JConfig, DEFAULT_SLOW_QUERY_THRESHOLD_MS}; pub const REDIS_URI: &str = "redis://localhost:6379"; diff --git a/nexus-common/src/db/config/neo4j.rs b/nexus-common/src/db/config/neo4j.rs index 1e9ec56cb..31fd81501 100644 --- a/nexus-common/src/db/config/neo4j.rs +++ b/nexus-common/src/db/config/neo4j.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; pub const NEO4J_URI: &str = "bolt://localhost:7687"; pub const NEO4J_USER: &str = "neo4j"; pub const NEO4J_PASS: &str = "12345678"; +pub const DEFAULT_SLOW_QUERY_THRESHOLD_MS: u64 = 100; // Create temporal struct to wrap database config #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] @@ -11,18 +12,26 @@ pub struct Neo4JConfig { #[serde(default = "default_neo4j_user")] pub user: String, pub password: String, + /// Queries exceeding this threshold (in milliseconds) are logged as warnings. + #[serde(default = "default_slow_query_threshold_ms")] + pub slow_query_threshold_ms: u64, } fn default_neo4j_user() -> String { String::from("neo4j") } +fn default_slow_query_threshold_ms() -> u64 { + DEFAULT_SLOW_QUERY_THRESHOLD_MS +} + impl Default for Neo4JConfig { fn default() -> Self { Self { uri: String::from(NEO4J_URI), user: String::from(NEO4J_USER), password: String::from(NEO4J_PASS), + slow_query_threshold_ms: DEFAULT_SLOW_QUERY_THRESHOLD_MS, } } } diff --git a/nexus-common/src/db/connectors/neo4j.rs b/nexus-common/src/db/connectors/neo4j.rs index d8b2b46e3..e53a44fe0 100644 --- a/nexus-common/src/db/connectors/neo4j.rs +++ b/nexus-common/src/db/connectors/neo4j.rs @@ -1,25 +1,24 @@ -use neo4rs::{query, Graph}; +use neo4rs::Graph; + +use crate::db::graph::query::query; use std::fmt; use std::sync::OnceLock; +use std::time::Duration; use tracing::{debug, info}; +use crate::db::graph::{GraphExec, TracedGraph}; use crate::db::setup::setup_graph; use crate::db::Neo4JConfig; use crate::types::DynError; pub struct Neo4jConnector { - pub graph: Graph, + graph: TracedGraph, } impl Neo4jConnector { /// Initialize and register the global Neo4j connector and verify connectivity pub async fn init(neo4j_config: &Neo4JConfig) -> Result<(), DynError> { - let neo4j_connector = Neo4jConnector::new_connection( - &neo4j_config.uri, - &neo4j_config.user, - &neo4j_config.password, - ) - .await?; + let neo4j_connector = Neo4jConnector::new_connection(neo4j_config).await?; neo4j_connector.ping(&neo4j_config.uri).await?; @@ -34,9 +33,12 @@ impl Neo4jConnector { } /// Create and return a new connector after defining a database connection - async fn new_connection(uri: &str, user: &str, password: &str) -> Result { - let graph = Graph::new(uri, user, password).await?; - let neo4j_connector = Neo4jConnector { graph }; + async fn new_connection(config: &Neo4JConfig) -> Result { + let graph = Graph::new(&config.uri, &config.user, &config.password).await?; + let threshold = Duration::from_millis(config.slow_query_threshold_ms); + let neo4j_connector = Neo4jConnector { + graph: TracedGraph::new(graph).with_slow_query_threshold(threshold), + }; info!("Created Neo4j connector"); Ok(neo4j_connector) @@ -44,7 +46,7 @@ impl Neo4jConnector { /// Perform a health-check PING over the Bolt protocol to the Neo4j server async fn ping(&self, neo4j_uri: &str) -> Result<(), DynError> { - if let Err(neo4j_err) = self.graph.execute(query("RETURN 1")).await { + if let Err(neo4j_err) = self.graph.run(query("RETURN 1")).await { return Err(format!("Failed to PING to Neo4j at {neo4j_uri}, {neo4j_err}").into()); } @@ -56,13 +58,13 @@ impl Neo4jConnector { impl fmt::Debug for Neo4jConnector { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Neo4jConnector") - .field("graph", &"Graph instance") + .field("graph", &"TracedGraph instance") .finish() } } /// Helper to retrieve a Neo4j graph connection. -pub fn get_neo4j_graph() -> Result { +pub fn get_neo4j_graph() -> Result { NEO4J_CONNECTOR .get() .ok_or("Neo4jConnector not initialized") diff --git a/nexus-common/src/db/graph/exec.rs b/nexus-common/src/db/graph/exec.rs index 2b21e9d64..5d2e15e24 100644 --- a/nexus-common/src/db/graph/exec.rs +++ b/nexus-common/src/db/graph/exec.rs @@ -1,6 +1,8 @@ -use crate::db::get_neo4j_graph; +use super::query::Query; +use crate::db::{get_neo4j_graph, GraphExec}; use crate::types::DynError; -use neo4rs::{Query, Row}; +use futures::TryStreamExt; +use neo4rs::Row; use serde::de::DeserializeOwned; /// Represents the outcome of a mutation-like query in the graph database. @@ -38,7 +40,7 @@ pub async fn execute_graph_operation(query: Query) -> Result Result<(), DynError> { let graph = get_neo4j_graph()?; let mut result = graph.execute(query).await?; - result.next().await?; + result.try_next().await?; Ok(()) } @@ -47,20 +49,15 @@ pub async fn fetch_row_from_graph(query: Query) -> Result, DynError> let mut result = graph.execute(query).await?; - result.next().await.map_err(Into::into) + result.try_next().await.map_err(Into::into) } pub async fn fetch_all_rows_from_graph(query: Query) -> Result, DynError> { let graph = get_neo4j_graph()?; - let mut result = graph.execute(query).await?; - let mut rows = Vec::new(); - - while let Some(row) = result.next().await? { - rows.push(row); - } + let result = graph.execute(query).await?; - Ok(rows) + result.try_collect().await.map_err(Into::into) } /// Fetch the value of type T mapped to a specific key from the first row of a graph query's result diff --git a/nexus-common/src/db/graph/graph.rs b/nexus-common/src/db/graph/graph.rs new file mode 100644 index 000000000..47d1c847c --- /dev/null +++ b/nexus-common/src/db/graph/graph.rs @@ -0,0 +1,98 @@ +use async_trait::async_trait; +use futures::stream::BoxStream; +use futures::{StreamExt, TryStreamExt}; +use neo4rs::{Graph, Row, Txn}; +use std::time::{Duration, Instant}; +use tracing::{debug, error, warn}; + +use super::query::Query; +use crate::db::config::DEFAULT_SLOW_QUERY_THRESHOLD_MS; + +/// Abstraction over graph database operations. +/// Callers depend on this trait, not the concrete TracedGraph. +#[async_trait] +pub trait GraphExec: Send + Sync { + /// Execute query, return boxed row stream. + async fn execute( + &self, + query: Query, + ) -> neo4rs::Result>>; + + /// Fire-and-forget query execution. + async fn run(&self, query: Query) -> neo4rs::Result<()>; + + /// Start a transaction. + async fn start_txn(&self) -> neo4rs::Result; +} + +#[derive(Clone)] +pub struct TracedGraph { + inner: Graph, + slow_query_threshold: Duration, +} + +impl TracedGraph { + pub fn new(graph: Graph) -> Self { + Self { + inner: graph, + slow_query_threshold: Duration::from_millis(DEFAULT_SLOW_QUERY_THRESHOLD_MS), + } + } + + pub fn with_slow_query_threshold(mut self, threshold: Duration) -> Self { + self.slow_query_threshold = threshold; + self + } +} + +#[async_trait] +impl GraphExec for TracedGraph { + async fn execute( + &self, + query: Query, + ) -> neo4rs::Result>> { + let start = Instant::now(); + let populated_cypher = query.to_cypher_populated(); + let result = self.inner.execute(query.into()).await; + let elapsed = start.elapsed(); + + match &result { + Ok(_) if elapsed > self.slow_query_threshold => { + warn!(elapsed_ms = elapsed.as_millis(), query = %populated_cypher, "Slow Neo4j query"); + } + Ok(_) => { + debug!(elapsed_ms = elapsed.as_millis(), query = %populated_cypher, "Neo4j query"); + } + Err(e) => { + error!(elapsed_ms = elapsed.as_millis(), query = %populated_cypher, error = %e, "Neo4j query failed"); + } + } + + Ok(result?.into_stream().map_err(Into::into).boxed()) + } + + async fn run(&self, query: Query) -> neo4rs::Result<()> { + let start = Instant::now(); + let populated_cypher = query.to_cypher_populated(); + let result = self.inner.run(query.into()).await; + let elapsed = start.elapsed(); + + match &result { + Ok(()) if elapsed > self.slow_query_threshold => { + warn!(elapsed_ms = elapsed.as_millis(), query = %populated_cypher, "Slow Neo4j run"); + } + Ok(()) => { + debug!(elapsed_ms = elapsed.as_millis(), query = %populated_cypher, "Neo4j run"); + } + Err(e) => { + error!(elapsed_ms = elapsed.as_millis(), query = %populated_cypher, error = %e, "Neo4j run failed"); + } + } + + result + } + + async fn start_txn(&self) -> neo4rs::Result { + self.inner.start_txn().await + } +} diff --git a/nexus-common/src/db/graph/mod.rs b/nexus-common/src/db/graph/mod.rs index 76fed3e57..1bf05e0dd 100644 --- a/nexus-common/src/db/graph/mod.rs +++ b/nexus-common/src/db/graph/mod.rs @@ -1,3 +1,8 @@ pub mod exec; +mod graph; pub mod queries; +pub mod query; pub mod setup; + +pub use graph::{GraphExec, TracedGraph}; +pub use query::{query, Query}; diff --git a/nexus-common/src/db/graph/queries/del.rs b/nexus-common/src/db/graph/queries/del.rs index e206145ae..04b8995ba 100644 --- a/nexus-common/src/db/graph/queries/del.rs +++ b/nexus-common/src/db/graph/queries/del.rs @@ -1,4 +1,4 @@ -use neo4rs::{query, Query}; +use crate::db::graph::query::{query, Query}; /// Deletes a user node and all its relationships /// # Arguments diff --git a/nexus-common/src/db/graph/queries/get.rs b/nexus-common/src/db/graph/queries/get.rs index 4c57500a0..4e23e6da4 100644 --- a/nexus-common/src/db/graph/queries/get.rs +++ b/nexus-common/src/db/graph/queries/get.rs @@ -1,10 +1,10 @@ +use crate::db::graph::query::{query, Query}; use crate::models::post::StreamSource; use crate::types::routes::HotTagsInputDTO; use crate::types::Pagination; use crate::types::StreamReach; use crate::types::StreamSorting; use crate::types::Timeframe; -use neo4rs::{query, Query}; use pubky_app_specs::PubkyAppPostKind; // Retrieve post node by post id and author id @@ -152,7 +152,7 @@ pub fn get_users_details_by_ids(user_ids: &[&str]) -> Query { } /// Retrieves unique global tags for posts, returning a list of `post_ids` and `timestamp` pairs for each tag label. -pub fn global_tags_by_post() -> neo4rs::Query { +pub fn global_tags_by_post() -> Query { query( " MATCH (tagger:User)-[t:TAGGED]->(post:Post)<-[:AUTHORED]-(author:User) @@ -168,7 +168,7 @@ pub fn global_tags_by_post() -> neo4rs::Query { /// Retrieves unique global tags for posts, calculating an engagement score based on tag counts, /// replies, reposts and mentions. The query returns a `key` by combining author's ID /// and post's ID, along with a sorted set of engagement scores for each tag label. -pub fn global_tags_by_post_engagement() -> neo4rs::Query { +pub fn global_tags_by_post_engagement() -> Query { query( " MATCH (author:User)-[:AUTHORED]->(post:Post)<-[tag:TAGGED]-(tagger:User) @@ -188,7 +188,7 @@ pub fn global_tags_by_post_engagement() -> neo4rs::Query { } // Retrieve all the tags of the post -pub fn post_tags(user_id: &str, post_id: &str) -> neo4rs::Query { +pub fn post_tags(user_id: &str, post_id: &str) -> Query { query( " MATCH (u:User {id: $user_id})-[:AUTHORED]->(p:Post {id: $post_id}) @@ -212,7 +212,7 @@ pub fn post_tags(user_id: &str, post_id: &str) -> neo4rs::Query { } // Retrieve all the tags of the user -pub fn user_tags(user_id: &str) -> neo4rs::Query { +pub fn user_tags(user_id: &str) -> Query { query( " MATCH (u:User {id: $user_id}) @@ -274,7 +274,7 @@ pub fn get_all_homeservers() -> Query { /// - `label`: The tag label. /// - `taggers`: A list of tagger user IDs who applied the tag. /// - `taggers_count`: The number of taggers who applied the tag. -pub fn get_viewer_trusted_network_tags(user_id: &str, viewer_id: &str, depth: u8) -> neo4rs::Query { +pub fn get_viewer_trusted_network_tags(user_id: &str, viewer_id: &str, depth: u8) -> Query { let graph_query = format!( " MATCH (viewer:User {{id: $viewer_id}}) @@ -302,7 +302,7 @@ pub fn get_viewer_trusted_network_tags(user_id: &str, viewer_id: &str, depth: u8 .param("viewer_id", viewer_id) } -pub fn user_counts(user_id: &str) -> neo4rs::Query { +pub fn user_counts(user_id: &str) -> Query { query( " MATCH (u:User {id: $user_id}) @@ -911,7 +911,7 @@ pub fn post_is_safe_to_delete(author_id: &str, post_id: &str) -> Query { /// Find user recommendations: active users (with 5+ posts) who are 1-3 degrees of separation away /// from the given user, but not directly followed by them -pub fn recommend_users(user_id: &str, limit: usize) -> neo4rs::Query { +pub fn recommend_users(user_id: &str, limit: usize) -> Query { query( " MATCH (user:User {id: $user_id}) @@ -931,7 +931,7 @@ pub fn recommend_users(user_id: &str, limit: usize) -> neo4rs::Query { } /// Retrieve specific tag created by the user -pub fn get_tag_by_tagger_and_id(tagger_id: &str, tag_id: &str) -> neo4rs::Query { +pub fn get_tag_by_tagger_and_id(tagger_id: &str, tag_id: &str) -> Query { query( " MATCH (tagger:User { id: $tagger_id})-[tag:TAGGED {id: $tag_id }]->(tagged) diff --git a/nexus-common/src/db/graph/queries/put.rs b/nexus-common/src/db/graph/queries/put.rs index 7a2a4eef3..88579d5d4 100644 --- a/nexus-common/src/db/graph/queries/put.rs +++ b/nexus-common/src/db/graph/queries/put.rs @@ -1,7 +1,7 @@ +use crate::db::graph::query::{query, Query}; use crate::models::post::PostRelationships; use crate::models::{file::FileDetails, post::PostDetails, user::UserDetails}; use crate::types::DynError; -use neo4rs::{query, Query}; use pubky_app_specs::{ParsedUri, Resource}; /// Create a user node diff --git a/nexus-common/src/db/graph/query.rs b/nexus-common/src/db/graph/query.rs new file mode 100644 index 000000000..d25c8f965 --- /dev/null +++ b/nexus-common/src/db/graph/query.rs @@ -0,0 +1,113 @@ +use std::fmt::Write; + +use neo4rs::{BoltList, BoltMap, BoltString, BoltType}; + +/// Our own `Query` type that mirrors `neo4rs::Query` but exposes +/// `cypher()` and `params_map()` for logging and tracing. +#[derive(Clone)] +pub struct Query { + cypher: String, + params: BoltMap, +} + +impl Query { + pub fn new(cypher: impl Into) -> Self { + Self { + cypher: cypher.into(), + params: BoltMap::default(), + } + } + + pub fn param>(mut self, key: &str, value: T) -> Self { + self.params.put(key.into(), value.into()); + self + } + + pub fn params(mut self, input: impl IntoIterator) -> Self + where + K: Into, + V: Into, + { + for (k, v) in input { + self.params.put(k.into(), v.into()); + } + self + } + + pub fn cypher(&self) -> &str { + &self.cypher + } + + pub fn params_map(&self) -> &BoltMap { + &self.params + } + + /// Returns the cypher string with `$param` placeholders replaced by their + /// literal values, ready to copy-paste into a Neo4j browser. + pub fn to_cypher_populated(&self) -> String { + let mut out = self.cypher.clone(); + // Sort keys by length descending so `$skip` is replaced before a + // hypothetical `$s`, avoiding partial substitutions. + let mut entries: Vec<_> = self.params.value.iter().collect(); + entries.sort_by(|a, b| b.0.value.len().cmp(&a.0.value.len())); + for (k, v) in entries { + let placeholder = format!("${}", k.value); + let literal = bolt_to_cypher_literal(v); + out = out.replace(&placeholder, &literal); + } + out + } +} + +/// Format a `BoltType` value as a Neo4j cypher literal. +fn bolt_to_cypher_literal(val: &BoltType) -> String { + match val { + BoltType::String(s) => format!("'{}'", s.value.replace('\\', "\\\\").replace('\'', "\\'")), + BoltType::Integer(i) => i.value.to_string(), + BoltType::Float(f) => format!("{}", f.value), + BoltType::Boolean(b) => if b.value { "true" } else { "false" }.to_string(), + BoltType::Null(_) => "null".to_string(), + BoltType::List(list) => bolt_list_to_cypher(list), + BoltType::Map(map) => bolt_map_to_cypher(map), + other => format!("{:?}", other), + } +} + +fn bolt_list_to_cypher(list: &BoltList) -> String { + let mut out = String::from('['); + for (i, item) in list.value.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + out.push_str(&bolt_to_cypher_literal(item)); + } + out.push(']'); + out +} + +fn bolt_map_to_cypher(map: &BoltMap) -> String { + let mut out = String::from('{'); + for (i, (k, v)) in map.value.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + let _ = write!(out, "{}: {}", k.value, bolt_to_cypher_literal(v)); + } + out.push('}'); + out +} + +impl From for neo4rs::Query { + fn from(q: Query) -> neo4rs::Query { + let mut nq = neo4rs::Query::new(q.cypher); + for (k, v) in q.params.value { + nq = nq.param(&k.value, v); + } + nq + } +} + +/// Drop-in replacement for `neo4rs::query()` +pub fn query(cypher: &str) -> Query { + Query::new(cypher) +} diff --git a/nexus-common/src/db/graph/setup.rs b/nexus-common/src/db/graph/setup.rs index ad1719fb5..dec1e4443 100644 --- a/nexus-common/src/db/graph/setup.rs +++ b/nexus-common/src/db/graph/setup.rs @@ -1,5 +1,8 @@ -use crate::{db::get_neo4j_graph, types::DynError}; -use neo4rs::query; +use crate::db::graph::query::query; +use crate::{ + db::{get_neo4j_graph, GraphExec}, + types::DynError, +}; use tracing::info; /// Ensure the Neo4j graph has the required constraints and indexes diff --git a/nexus-common/src/db/mod.rs b/nexus-common/src/db/mod.rs index feac6805b..94f7e665a 100644 --- a/nexus-common/src/db/mod.rs +++ b/nexus-common/src/db/mod.rs @@ -1,7 +1,7 @@ mod config; mod connectors; mod errors; -mod graph; +pub mod graph; pub mod kv; pub mod reindex; @@ -14,4 +14,5 @@ pub use errors::*; pub use graph::exec::*; pub use graph::queries; pub use graph::setup; +pub use graph::{GraphExec, TracedGraph}; pub use kv::RedisOps; diff --git a/nexus-common/src/db/reindex.rs b/nexus-common/src/db/reindex.rs index 5a88303ea..89982a45e 100644 --- a/nexus-common/src/db/reindex.rs +++ b/nexus-common/src/db/reindex.rs @@ -1,4 +1,5 @@ use crate::db::graph::exec::fetch_all_rows_from_graph; +use crate::db::graph::query::query; use crate::models::follow::{Followers, Following, UserFollows}; use crate::models::post::search::PostsByTagSearch; use crate::models::post::Bookmark; @@ -14,7 +15,6 @@ use crate::{ models::post::{PostCounts, PostDetails, PostRelationships}, models::user::UserCounts, }; -use neo4rs::query; use tokio::task::JoinSet; use tracing::info; diff --git a/nexus-common/src/models/file/details.rs b/nexus-common/src/models/file/details.rs index 1a5411683..f07e377a6 100644 --- a/nexus-common/src/models/file/details.rs +++ b/nexus-common/src/models/file/details.rs @@ -1,3 +1,4 @@ +use crate::db::graph::Query; use crate::db::kv::RedisResult; use crate::db::DbError; use crate::db::{exec_single_row, queries, RedisOps}; @@ -6,7 +7,6 @@ use crate::models::traits::Collection; use crate::types::DynError; use async_trait::async_trait; use chrono::Utc; -use neo4rs::Query; use pubky_app_specs::{ParsedUri, PubkyAppFile, Resource}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; diff --git a/nexus-common/src/models/follow/followers.rs b/nexus-common/src/models/follow/followers.rs index d31a4a20f..aa7f10830 100644 --- a/nexus-common/src/models/follow/followers.rs +++ b/nexus-common/src/models/follow/followers.rs @@ -1,6 +1,6 @@ +use crate::db::graph::Query; use crate::db::{queries, RedisOps}; use async_trait::async_trait; -use neo4rs::Query; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; diff --git a/nexus-common/src/models/follow/following.rs b/nexus-common/src/models/follow/following.rs index 5e3f5ad2a..f2e24cfb9 100644 --- a/nexus-common/src/models/follow/following.rs +++ b/nexus-common/src/models/follow/following.rs @@ -1,6 +1,6 @@ +use crate::db::graph::Query; use crate::db::{queries, RedisOps}; use async_trait::async_trait; -use neo4rs::Query; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; diff --git a/nexus-common/src/models/follow/traits.rs b/nexus-common/src/models/follow/traits.rs index 98f160797..7e089f106 100644 --- a/nexus-common/src/models/follow/traits.rs +++ b/nexus-common/src/models/follow/traits.rs @@ -1,3 +1,4 @@ +use crate::db::graph::Query; use crate::db::kv::RedisResult; use crate::db::{ execute_graph_operation, fetch_row_from_graph, queries, OperationOutcome, RedisOps, @@ -5,7 +6,6 @@ use crate::db::{ use crate::types::DynError; use async_trait::async_trait; use chrono::Utc; -use neo4rs::Query; #[async_trait] pub trait UserFollows: Sized + RedisOps + AsRef<[String]> + Default { diff --git a/nexus-common/src/models/notification/mod.rs b/nexus-common/src/models/notification/mod.rs index ab928a34b..9e3a57fba 100644 --- a/nexus-common/src/models/notification/mod.rs +++ b/nexus-common/src/models/notification/mod.rs @@ -79,7 +79,7 @@ pub enum NotificationBody { }, } -type QueryFunction = fn(&str, &str) -> neo4rs::Query; +type QueryFunction = fn(&str, &str) -> crate::db::graph::Query; type ExtractFunction = Box (String, String) + Send>; impl Default for NotificationBody { diff --git a/nexus-common/src/models/post/search.rs b/nexus-common/src/models/post/search.rs index a915c4dca..c7471e25d 100644 --- a/nexus-common/src/models/post/search.rs +++ b/nexus-common/src/models/post/search.rs @@ -1,3 +1,4 @@ +use crate::db::graph::Query; use crate::db::kv::{RedisResult, ScoreAction, SortOrder}; use crate::db::queries::get::{global_tags_by_post, global_tags_by_post_engagement}; use crate::db::{fetch_all_rows_from_graph, RedisOps}; @@ -6,7 +7,6 @@ use crate::models::tag::post::TagPost; use crate::models::tag::traits::TaggersCollection; use crate::types::DynError; use crate::types::{Pagination, StreamSorting}; -use neo4rs::Query; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; diff --git a/nexus-common/src/models/post/stream.rs b/nexus-common/src/models/post/stream.rs index 2fc437796..a4ada1158 100644 --- a/nexus-common/src/models/post/stream.rs +++ b/nexus-common/src/models/post/stream.rs @@ -1,11 +1,12 @@ use super::{Bookmark, PostCounts, PostDetails, PostView}; use crate::db::kv::{RedisResult, ScoreAction, SortOrder}; -use crate::db::{get_neo4j_graph, queries, RedisOps}; +use crate::db::{get_neo4j_graph, queries, GraphExec, RedisOps}; use crate::models::{ follow::{Followers, Following, Friends, UserFollows}, post::search::PostsByTagSearch, }; use crate::types::{DynError, Pagination, StreamSorting}; +use futures::TryStreamExt; use pubky_app_specs::PubkyAppPostKind; use serde::{Deserialize, Serialize}; use tokio::task::spawn; @@ -268,7 +269,7 @@ impl PostStream { // Track the last post's indexed_at value let mut last_post_indexed_at: Option = None; - while let Some(row) = result.next().await? { + while let Some(row) = result.try_next().await? { let author_id: String = row.get("author_id")?; let post_id: String = row.get("post_id")?; let indexed_at: i64 = row.get("indexed_at")?; diff --git a/nexus-common/src/models/tag/traits/collection.rs b/nexus-common/src/models/tag/traits/collection.rs index 543d1e251..e07c1f12b 100644 --- a/nexus-common/src/models/tag/traits/collection.rs +++ b/nexus-common/src/models/tag/traits/collection.rs @@ -1,10 +1,10 @@ +use crate::db::graph::Query; use crate::db::kv::{RedisResult, ScoreAction, SortOrder}; use crate::db::{ execute_graph_operation, fetch_row_from_graph, queries, OperationOutcome, RedisOps, }; use crate::types::DynError; use async_trait::async_trait; -use neo4rs::Query; use tracing::error; use crate::models::tag::{post::POST_TAGS_KEY_PARTS, user::USER_TAGS_KEY_PARTS}; diff --git a/nexus-common/src/models/traits.rs b/nexus-common/src/models/traits.rs index 015ff5839..8a01f1bee 100644 --- a/nexus-common/src/models/traits.rs +++ b/nexus-common/src/models/traits.rs @@ -1,9 +1,9 @@ +use crate::db::graph::Query; use crate::db::kv::RedisResult; use crate::db::{exec_single_row, fetch_all_rows_from_graph, RedisOps}; use crate::types::DynError; use async_trait::async_trait; use core::fmt; -use neo4rs::Query; use std::fmt::Debug; pub trait CollectionId { diff --git a/nexus-common/src/models/user/details.rs b/nexus-common/src/models/user/details.rs index 90fc5c93d..e76d53d57 100644 --- a/nexus-common/src/models/user/details.rs +++ b/nexus-common/src/models/user/details.rs @@ -1,11 +1,11 @@ use super::UserSearch; +use crate::db::graph::Query; use crate::db::kv::RedisResult; use crate::db::{exec_single_row, queries, RedisOps}; use crate::models::traits::Collection; use crate::types::DynError; use async_trait::async_trait; use chrono::Utc; -use neo4rs::Query; use pubky_app_specs::{PubkyAppUser, PubkyAppUserLink, PubkyId}; use serde::{Deserialize, Deserializer, Serialize}; use serde_json; diff --git a/nexus-watcher/tests/event_processor/follows/utils.rs b/nexus-watcher/tests/event_processor/follows/utils.rs index a4df8980a..507df2689 100644 --- a/nexus-watcher/tests/event_processor/follows/utils.rs +++ b/nexus-watcher/tests/event_processor/follows/utils.rs @@ -1,6 +1,6 @@ use anyhow::Result; -use neo4rs::{query, Query}; use nexus_common::db::fetch_key_from_graph; +use nexus_common::db::graph::query::{query, Query}; pub async fn find_follow_relationship(follower: &str, followee: &str) -> Result { let query = user_following_query(follower, followee); diff --git a/nexus-watcher/tests/event_processor/mentions/utils.rs b/nexus-watcher/tests/event_processor/mentions/utils.rs index 20cef9407..43ae85cb0 100644 --- a/nexus-watcher/tests/event_processor/mentions/utils.rs +++ b/nexus-watcher/tests/event_processor/mentions/utils.rs @@ -1,6 +1,6 @@ use anyhow::Result; -use neo4rs::{query, Query}; use nexus_common::db::fetch_key_from_graph; +use nexus_common::db::graph::query::{query, Query}; pub async fn find_post_mentions(follower: &str, followee: &str) -> Result> { let query = post_mention_query(follower, followee); diff --git a/nexus-watcher/tests/event_processor/mutes/utils.rs b/nexus-watcher/tests/event_processor/mutes/utils.rs index c12f8d347..87301939c 100644 --- a/nexus-watcher/tests/event_processor/mutes/utils.rs +++ b/nexus-watcher/tests/event_processor/mutes/utils.rs @@ -1,6 +1,6 @@ use anyhow::Result; -use neo4rs::query; use nexus_common::db::fetch_key_from_graph; +use nexus_common::db::graph::query::query; pub async fn find_mute_relationship(muter: &str, mutee: &str) -> Result { let query = diff --git a/nexus-watcher/tests/event_processor/posts/utils.rs b/nexus-watcher/tests/event_processor/posts/utils.rs index 2657a8a37..2c0ba5ff8 100644 --- a/nexus-watcher/tests/event_processor/posts/utils.rs +++ b/nexus-watcher/tests/event_processor/posts/utils.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use neo4rs::{query, Query}; +use nexus_common::db::graph::query::{query, Query}; use nexus_common::{ db::{fetch_key_from_graph, RedisOps}, models::post::{ diff --git a/nexus-watcher/tests/event_processor/tags/utils.rs b/nexus-watcher/tests/event_processor/tags/utils.rs index b0b00eb12..351f53ad0 100644 --- a/nexus-watcher/tests/event_processor/tags/utils.rs +++ b/nexus-watcher/tests/event_processor/tags/utils.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use neo4rs::{query, Query}; +use nexus_common::db::graph::query::{query, Query}; use nexus_common::{ db::{fetch_key_from_graph, RedisOps}, models::{ diff --git a/nexus-webapi/src/mock.rs b/nexus-webapi/src/mock.rs index 78f35660d..263a805dd 100644 --- a/nexus-webapi/src/mock.rs +++ b/nexus-webapi/src/mock.rs @@ -1,8 +1,7 @@ use crate::{api_context::ApiContextBuilder, NexusApiBuilder}; use clap::ValueEnum; -use neo4rs::query; use nexus_common::{ - db::{get_neo4j_graph, get_redis_conn, reindex}, + db::{get_neo4j_graph, get_redis_conn, graph::query::query, reindex, GraphExec}, ApiConfig, }; use std::process::Stdio; diff --git a/nexusd/Cargo.toml b/nexusd/Cargo.toml index c19f01109..afdf803a2 100644 --- a/nexusd/Cargo.toml +++ b/nexusd/Cargo.toml @@ -12,6 +12,7 @@ async-trait = { workspace = true } dirs = "6.0.0" chrono = { workspace = true } clap = { workspace = true, features = ["derive"] } +futures = { workspace = true } neo4rs = { workspace = true } nexus-webapi = { version = "0.4.1", path = "../nexus-webapi" } nexus-common = { version = "0.4.1", path = "../nexus-common" } diff --git a/nexusd/src/migrations/default.config.toml b/nexusd/src/migrations/default.config.toml index 07119850c..b58dae823 100644 --- a/nexusd/src/migrations/default.config.toml +++ b/nexusd/src/migrations/default.config.toml @@ -19,4 +19,5 @@ redis = "redis://localhost:6379" uri = "bolt://localhost:7687" # Not needed in the Community Edition #user = "neo4j" -password = "12345678" \ No newline at end of file +password = "12345678" +slow_query_threshold_ms=100 \ No newline at end of file diff --git a/nexusd/src/migrations/manager.rs b/nexusd/src/migrations/manager.rs index 30a88afc2..861f82076 100644 --- a/nexusd/src/migrations/manager.rs +++ b/nexusd/src/migrations/manager.rs @@ -1,7 +1,10 @@ use async_trait::async_trait; use chrono::Utc; -use neo4rs::{Graph, Query}; -use nexus_common::{db::get_neo4j_graph, types::DynError}; +use futures::TryStreamExt; +use nexus_common::{ + db::{get_neo4j_graph, graph::Query, GraphExec, TracedGraph}, + types::DynError, +}; use serde::{Deserialize, Serialize}; use std::any::Any; use tracing::info; @@ -86,7 +89,7 @@ pub struct MigrationNode { const MIGRATION_PATH: &str = "nexusd/src/migrations/migrations_list/"; pub struct MigrationManager { - graph: Graph, + graph: TracedGraph, migrations: Vec>, } @@ -218,7 +221,7 @@ impl MigrationManager { let query = Query::new("MATCH (m:Migration) RETURN COLLECT(m) as migrations".to_string()); let mut result = self.graph.execute(query).await.map_err(|e| e.to_string())?; - match result.next().await { + match result.try_next().await { Ok(row) => match row { Some(row) => match row.get::>("migrations") { Ok(migrations) => Ok(migrations), From 9b42ae27d1a34a011bbb8647f78f5fd6aef9f2eb Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Thu, 26 Feb 2026 10:06:32 +0100 Subject: [PATCH 02/34] interpolate only if logged --- nexus-common/src/db/graph/graph.rs | 34 +++++++++--------------------- nexus-common/src/db/graph/query.rs | 27 ++++++++++++++---------- 2 files changed, 26 insertions(+), 35 deletions(-) diff --git a/nexus-common/src/db/graph/graph.rs b/nexus-common/src/db/graph/graph.rs index 47d1c847c..3d02740d4 100644 --- a/nexus-common/src/db/graph/graph.rs +++ b/nexus-common/src/db/graph/graph.rs @@ -3,9 +3,9 @@ use futures::stream::BoxStream; use futures::{StreamExt, TryStreamExt}; use neo4rs::{Graph, Row, Txn}; use std::time::{Duration, Instant}; -use tracing::{debug, error, warn}; +use tracing::warn; -use super::query::Query; +use super::query::{populate_cypher, Query}; use crate::db::config::DEFAULT_SLOW_QUERY_THRESHOLD_MS; /// Abstraction over graph database operations. @@ -51,42 +51,28 @@ impl GraphExec for TracedGraph { &self, query: Query, ) -> neo4rs::Result>> { + let cypher = query.cypher().to_owned(); + let params = query.params_map().clone(); let start = Instant::now(); - let populated_cypher = query.to_cypher_populated(); let result = self.inner.execute(query.into()).await; let elapsed = start.elapsed(); - match &result { - Ok(_) if elapsed > self.slow_query_threshold => { - warn!(elapsed_ms = elapsed.as_millis(), query = %populated_cypher, "Slow Neo4j query"); - } - Ok(_) => { - debug!(elapsed_ms = elapsed.as_millis(), query = %populated_cypher, "Neo4j query"); - } - Err(e) => { - error!(elapsed_ms = elapsed.as_millis(), query = %populated_cypher, error = %e, "Neo4j query failed"); - } + if elapsed > self.slow_query_threshold { + warn!(elapsed_ms = elapsed.as_millis(), query = %populate_cypher(&cypher, ¶ms), "Slow Neo4j query"); } Ok(result?.into_stream().map_err(Into::into).boxed()) } async fn run(&self, query: Query) -> neo4rs::Result<()> { + let cypher = query.cypher().to_owned(); + let params = query.params_map().clone(); let start = Instant::now(); - let populated_cypher = query.to_cypher_populated(); let result = self.inner.run(query.into()).await; let elapsed = start.elapsed(); - match &result { - Ok(()) if elapsed > self.slow_query_threshold => { - warn!(elapsed_ms = elapsed.as_millis(), query = %populated_cypher, "Slow Neo4j run"); - } - Ok(()) => { - debug!(elapsed_ms = elapsed.as_millis(), query = %populated_cypher, "Neo4j run"); - } - Err(e) => { - error!(elapsed_ms = elapsed.as_millis(), query = %populated_cypher, error = %e, "Neo4j run failed"); - } + if elapsed > self.slow_query_threshold { + warn!(elapsed_ms = elapsed.as_millis(), query = %populate_cypher(&cypher, ¶ms), "Slow Neo4j query"); } result diff --git a/nexus-common/src/db/graph/query.rs b/nexus-common/src/db/graph/query.rs index d25c8f965..0437a584f 100644 --- a/nexus-common/src/db/graph/query.rs +++ b/nexus-common/src/db/graph/query.rs @@ -45,20 +45,25 @@ impl Query { /// Returns the cypher string with `$param` placeholders replaced by their /// literal values, ready to copy-paste into a Neo4j browser. pub fn to_cypher_populated(&self) -> String { - let mut out = self.cypher.clone(); - // Sort keys by length descending so `$skip` is replaced before a - // hypothetical `$s`, avoiding partial substitutions. - let mut entries: Vec<_> = self.params.value.iter().collect(); - entries.sort_by(|a, b| b.0.value.len().cmp(&a.0.value.len())); - for (k, v) in entries { - let placeholder = format!("${}", k.value); - let literal = bolt_to_cypher_literal(v); - out = out.replace(&placeholder, &literal); - } - out + populate_cypher(&self.cypher, &self.params) } } +/// Replaces `$param` placeholders in `cypher` with literal values from `params`. +pub fn populate_cypher(cypher: &str, params: &BoltMap) -> String { + let mut out = cypher.to_owned(); + // Sort keys by length descending so `$skip` is replaced before a + // hypothetical `$s`, avoiding partial substitutions. + let mut entries: Vec<_> = params.value.iter().collect(); + entries.sort_by(|a, b| b.0.value.len().cmp(&a.0.value.len())); + for (k, v) in entries { + let placeholder = format!("${}", k.value); + let literal = bolt_to_cypher_literal(v); + out = out.replace(&placeholder, &literal); + } + out +} + /// Format a `BoltType` value as a Neo4j cypher literal. fn bolt_to_cypher_literal(val: &BoltType) -> String { match val { From de6c027baf80075497adcbf8cfe645638fb72479 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Thu, 26 Feb 2026 12:02:10 +0100 Subject: [PATCH 03/34] add query labels --- nexus-common/src/db/connectors/neo4j.rs | 4 +- nexus-common/src/db/graph/graph.rs | 20 ++- nexus-common/src/db/graph/queries/del.rs | 22 ++- nexus-common/src/db/graph/queries/get.rs | 94 ++++++---- nexus-common/src/db/graph/queries/put.rs | 38 ++-- nexus-common/src/db/graph/query.rs | 211 ++++++++++++++++++++++- nexus-common/src/db/graph/setup.rs | 4 +- nexus-common/src/db/reindex.rs | 10 +- nexus-webapi/src/mock.rs | 4 +- nexusd/src/migrations/manager.rs | 7 +- 10 files changed, 331 insertions(+), 83 deletions(-) diff --git a/nexus-common/src/db/connectors/neo4j.rs b/nexus-common/src/db/connectors/neo4j.rs index e53a44fe0..db0db8468 100644 --- a/nexus-common/src/db/connectors/neo4j.rs +++ b/nexus-common/src/db/connectors/neo4j.rs @@ -1,6 +1,6 @@ use neo4rs::Graph; -use crate::db::graph::query::query; +use crate::db::graph::query::Query; use std::fmt; use std::sync::OnceLock; use std::time::Duration; @@ -46,7 +46,7 @@ impl Neo4jConnector { /// Perform a health-check PING over the Bolt protocol to the Neo4j server async fn ping(&self, neo4j_uri: &str) -> Result<(), DynError> { - if let Err(neo4j_err) = self.graph.run(query("RETURN 1")).await { + if let Err(neo4j_err) = self.graph.run(Query::new("ping", "RETURN 1")).await { return Err(format!("Failed to PING to Neo4j at {neo4j_uri}, {neo4j_err}").into()); } diff --git a/nexus-common/src/db/graph/graph.rs b/nexus-common/src/db/graph/graph.rs index 3d02740d4..2c8b7512d 100644 --- a/nexus-common/src/db/graph/graph.rs +++ b/nexus-common/src/db/graph/graph.rs @@ -5,7 +5,7 @@ use neo4rs::{Graph, Row, Txn}; use std::time::{Duration, Instant}; use tracing::warn; -use super::query::{populate_cypher, Query}; +use super::query::Query; use crate::db::config::DEFAULT_SLOW_QUERY_THRESHOLD_MS; /// Abstraction over graph database operations. @@ -51,28 +51,30 @@ impl GraphExec for TracedGraph { &self, query: Query, ) -> neo4rs::Result>> { - let cypher = query.cypher().to_owned(); - let params = query.params_map().clone(); + let label = query.label().map(str::to_owned); let start = Instant::now(); let result = self.inner.execute(query.into()).await; let elapsed = start.elapsed(); - if elapsed > self.slow_query_threshold { - warn!(elapsed_ms = elapsed.as_millis(), query = %populate_cypher(&cypher, ¶ms), "Slow Neo4j query"); + if let Some(label) = &label { + if elapsed > self.slow_query_threshold { + warn!(elapsed_ms = elapsed.as_millis(), query = %label, "Slow Neo4j query"); + } } Ok(result?.into_stream().map_err(Into::into).boxed()) } async fn run(&self, query: Query) -> neo4rs::Result<()> { - let cypher = query.cypher().to_owned(); - let params = query.params_map().clone(); + let label = query.label().map(str::to_owned); let start = Instant::now(); let result = self.inner.run(query.into()).await; let elapsed = start.elapsed(); - if elapsed > self.slow_query_threshold { - warn!(elapsed_ms = elapsed.as_millis(), query = %populate_cypher(&cypher, ¶ms), "Slow Neo4j query"); + if let Some(label) = &label { + if elapsed > self.slow_query_threshold { + warn!(elapsed_ms = elapsed.as_millis(), query = %label, "Slow Neo4j query"); + } } result diff --git a/nexus-common/src/db/graph/queries/del.rs b/nexus-common/src/db/graph/queries/del.rs index 04b8995ba..c5b9c5440 100644 --- a/nexus-common/src/db/graph/queries/del.rs +++ b/nexus-common/src/db/graph/queries/del.rs @@ -1,10 +1,11 @@ -use crate::db::graph::query::{query, Query}; +use crate::db::graph::query::Query; /// Deletes a user node and all its relationships /// # Arguments /// * `user_id` - The unique identifier of the user to be deleted pub fn delete_user(user_id: &str) -> Query { - query( + Query::new( + "delete_user", "MATCH (u:User {id: $id}) DETACH DELETE u;", ) @@ -16,7 +17,8 @@ pub fn delete_user(user_id: &str) -> Query { /// * `author_id` - The unique identifier of the user who authored the post. /// * `post_id` - The unique identifier of the post to be deleted. pub fn delete_post(author_id: &str, post_id: &str) -> Query { - query( + Query::new( + "delete_post", "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) DETACH DELETE p;", ) @@ -29,7 +31,8 @@ pub fn delete_post(author_id: &str, post_id: &str) -> Query { /// * `follower_id` - The unique identifier of the user who is following another user. /// * `followee_id` - The unique identifier of the user being followed pub fn delete_follow(follower_id: &str, followee_id: &str) -> Query { - query( + Query::new( + "delete_follow", "// Important that MATCH to check if both users are in the graph MATCH (follower:User {id: $follower_id}), (followee:User {id: $followee_id}) // Check if follow already exist @@ -47,7 +50,8 @@ pub fn delete_follow(follower_id: &str, followee_id: &str) -> Query { /// * `user_id` - The unique identifier of the user who muted another user /// * `muted_id` - The unique identifier of the user who was muted pub fn delete_mute(user_id: &str, muted_id: &str) -> Query { - query( + Query::new( + "delete_mute", "// Important that MATCH to check if both users are in the graph MATCH (user:User {id: $user_id}), (muted:User {id: $muted_id}) OPTIONAL MATCH (user)-[existing:MUTED]->(muted) @@ -64,7 +68,7 @@ pub fn delete_mute(user_id: &str, muted_id: &str) -> Query { /// * `user_id` - The unique identifier of the user who created the bookmark. /// * `bookmark_id` - The unique identifier of the bookmark relationship to be deleted. pub fn delete_bookmark(user_id: &str, bookmark_id: &str) -> Query { - query( + Query::new("delete_bookmark", "MATCH (u:User {id: $user_id})-[b:BOOKMARKED {id: $bookmark_id}]->(post:Post)<-[:AUTHORED]-(author:User) WITH post.id as post_id, author.id as author_id, b DELETE b @@ -79,7 +83,8 @@ pub fn delete_bookmark(user_id: &str, bookmark_id: &str) -> Query { /// * `user_id` - The unique identifier of the user who created the tag. /// * `tag_id` - The unique identifier of the `TAGGED` relationship to be deleted. pub fn delete_tag(user_id: &str, tag_id: &str) -> Query { - query( + Query::new( + "delete_tag", "MATCH (user:User {id: $user_id})-[tag:TAGGED {id: $tag_id}]->(target) OPTIONAL MATCH (target)<-[:AUTHORED]-(author:User) WITH CASE WHEN target:User THEN target.id ELSE null END AS user_id, @@ -99,7 +104,8 @@ pub fn delete_tag(user_id: &str, tag_id: &str) -> Query { /// * `owner_id` - The unique identifier of the user who owns the file /// * `file_id` - The unique identifier of the file to be deleted pub fn delete_file(owner_id: &str, file_id: &str) -> Query { - query( + Query::new( + "delete_file", "MATCH (f:File {id: $id, owner_id: $owner_id}) DETACH DELETE f;", ) diff --git a/nexus-common/src/db/graph/queries/get.rs b/nexus-common/src/db/graph/queries/get.rs index 4e23e6da4..b12f64198 100644 --- a/nexus-common/src/db/graph/queries/get.rs +++ b/nexus-common/src/db/graph/queries/get.rs @@ -1,4 +1,4 @@ -use crate::db::graph::query::{query, Query}; +use crate::db::graph::query::Query; use crate::models::post::StreamSource; use crate::types::routes::HotTagsInputDTO; use crate::types::Pagination; @@ -9,7 +9,8 @@ use pubky_app_specs::PubkyAppPostKind; // Retrieve post node by post id and author id pub fn get_post_by_id(author_id: &str, post_id: &str) -> Query { - query( + Query::new( + "get_post_by_id", " MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) OPTIONAL MATCH (p)-[replied:REPLIED]->(parent_post:Post)<-[:AUTHORED]-(author:User) @@ -34,7 +35,8 @@ pub fn get_post_by_id(author_id: &str, post_id: &str) -> Query { } pub fn post_counts(author_id: &str, post_id: &str) -> Query { - query( + Query::new( + "post_counts", " MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) WITH p @@ -56,7 +58,8 @@ pub fn post_counts(author_id: &str, post_id: &str) -> Query { // Check if the viewer_id has a bookmark in the post pub fn post_bookmark(author_id: &str, post_id: &str, viewer_id: &str) -> Query { - query( + Query::new( + "post_bookmark", "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) MATCH (viewer:User {id: $viewer_id})-[b:BOOKMARKED]->(p) RETURN b", @@ -68,7 +71,8 @@ pub fn post_bookmark(author_id: &str, post_id: &str, viewer_id: &str) -> Query { // Check all the bookmarks that user creates pub fn user_bookmarks(user_id: &str) -> Query { - query( + Query::new( + "user_bookmarks", "MATCH (u:User {id: $user_id})-[b:BOOKMARKED]->(p:Post)<-[:AUTHORED]-(author:User) RETURN b, p.id AS post_id, author.id AS author_id", ) @@ -77,7 +81,7 @@ pub fn user_bookmarks(user_id: &str) -> Query { // Get all the bookmarks that a post has received (used for edit/delete notifications) pub fn get_post_bookmarks(author_id: &str, post_id: &str) -> Query { - query( + Query::new("get_post_bookmarks", "MATCH (bookmarker:User)-[b:BOOKMARKED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) RETURN b.id AS bookmark_id, bookmarker.id AS bookmarker_id", ) @@ -87,7 +91,7 @@ pub fn get_post_bookmarks(author_id: &str, post_id: &str) -> Query { // Get all the reposts that a post has received (used for edit/delete notifications) pub fn get_post_reposts(author_id: &str, post_id: &str) -> Query { - query( + Query::new("get_post_reposts", "MATCH (reposter:User)-[:AUTHORED]->(repost:Post)-[:REPOSTED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) RETURN reposter.id AS reposter_id, repost.id AS repost_id", ) @@ -97,7 +101,7 @@ pub fn get_post_reposts(author_id: &str, post_id: &str) -> Query { // Get all the replies that a post has received (used for edit/delete notifications) pub fn get_post_replies(author_id: &str, post_id: &str) -> Query { - query( + Query::new("get_post_replies", "MATCH (replier:User)-[:AUTHORED]->(reply:Post)-[:REPLIED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) RETURN replier.id AS replier_id, reply.id AS reply_id", ) @@ -107,7 +111,7 @@ pub fn get_post_replies(author_id: &str, post_id: &str) -> Query { // Get all the tags/taggers that a post has received (used for edit/delete notifications) pub fn get_post_tags(author_id: &str, post_id: &str) -> Query { - query( + Query::new("get_post_tags", "MATCH (tagger:User)-[t:TAGGED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) RETURN tagger.id AS tagger_id, t.id AS tag_id", ) @@ -116,7 +120,8 @@ pub fn get_post_tags(author_id: &str, post_id: &str) -> Query { } pub fn post_relationships(author_id: &str, post_id: &str) -> Query { - query( + Query::new( + "post_relationships", "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) OPTIONAL MATCH (p)-[:REPLIED]->(replied_post:Post)<-[:AUTHORED]-(replied_author:User) OPTIONAL MATCH (p)-[:REPOSTED]->(reposted_post:Post)<-[:AUTHORED]-(reposted_author:User) @@ -135,7 +140,8 @@ pub fn post_relationships(author_id: &str, post_id: &str) -> Query { // Retrieve many users by id // We return also id if not we will not get not found users pub fn get_users_details_by_ids(user_ids: &[&str]) -> Query { - query( + Query::new( + "get_users_details_by_ids", " UNWIND $ids AS id OPTIONAL MATCH (record:User {id: id}) @@ -153,7 +159,8 @@ pub fn get_users_details_by_ids(user_ids: &[&str]) -> Query { /// Retrieves unique global tags for posts, returning a list of `post_ids` and `timestamp` pairs for each tag label. pub fn global_tags_by_post() -> Query { - query( + Query::new( + "global_tags_by_post", " MATCH (tagger:User)-[t:TAGGED]->(post:Post)<-[:AUTHORED]-(author:User) WITH t.label AS label, author.id + ':' + post.id AS post_id, post.indexed_at AS score @@ -169,7 +176,7 @@ pub fn global_tags_by_post() -> Query { /// replies, reposts and mentions. The query returns a `key` by combining author's ID /// and post's ID, along with a sorted set of engagement scores for each tag label. pub fn global_tags_by_post_engagement() -> Query { - query( + Query::new("global_tags_by_post_engagement", " MATCH (author:User)-[:AUTHORED]->(post:Post)<-[tag:TAGGED]-(tagger:User) WITH post, COUNT(tag) AS tags_count, tag.label AS label, author.id + ':' + post.id AS key @@ -189,7 +196,8 @@ pub fn global_tags_by_post_engagement() -> Query { // Retrieve all the tags of the post pub fn post_tags(user_id: &str, post_id: &str) -> Query { - query( + Query::new( + "post_tags", " MATCH (u:User {id: $user_id})-[:AUTHORED]->(p:Post {id: $post_id}) CALL { @@ -213,7 +221,8 @@ pub fn post_tags(user_id: &str, post_id: &str) -> Query { // Retrieve all the tags of the user pub fn user_tags(user_id: &str) -> Query { - query( + Query::new( + "user_tags", " MATCH (u:User {id: $user_id}) CALL { @@ -236,7 +245,8 @@ pub fn user_tags(user_id: &str) -> Query { /// Retrieve a homeserver by ID pub fn get_homeserver_by_id(id: &str) -> Query { - query( + Query::new( + "get_homeserver_by_id", "MATCH (hs:Homeserver {id: $id}) WITH hs.id AS id RETURN id", @@ -246,7 +256,8 @@ pub fn get_homeserver_by_id(id: &str) -> Query { /// Retrieves all homeserver IDs pub fn get_all_homeservers() -> Query { - query( + Query::new( + "get_all_homeservers", "MATCH (hs:Homeserver) WITH collect(hs.id) AS homeservers_list RETURN homeservers_list", @@ -297,13 +308,14 @@ pub fn get_viewer_trusted_network_tags(user_id: &str, viewer_id: &str, depth: u8 ); // Add to the query the params - query(graph_query.as_str()) + Query::new("get_viewer_trusted_network_tags", graph_query.as_str()) .param("user_id", user_id) .param("viewer_id", viewer_id) } pub fn user_counts(user_id: &str) -> Query { - query( + Query::new( + "user_counts", " MATCH (u:User {id: $user_id}) // tags that reference this user @@ -356,7 +368,7 @@ pub fn get_user_followers(user_id: &str, skip: Option, limit: Option, limit: Option) -> Query { @@ -372,7 +384,7 @@ pub fn get_user_following(user_id: &str, skip: Option, limit: Option, limit: Option) -> Query { @@ -388,7 +400,7 @@ pub fn get_user_muted(user_id: &str, skip: Option, limit: Option) if let Some(limit_value) = limit { query_string.push_str(&format!(" LIMIT {limit_value}")); } - query(&query_string).param("user_id", user_id) + Query::new("get_user_muted", &query_string).param("user_id", user_id) } fn stream_reach_to_graph_subquery(reach: &StreamReach) -> String { @@ -405,7 +417,8 @@ fn stream_reach_to_graph_subquery(reach: &StreamReach) -> String { } pub fn get_tags_by_label_prefix(label_prefix: &str) -> Query { - query( + Query::new( + "get_tags_by_label_prefix", " MATCH ()-[t:TAGGED]->() WHERE t.label STARTS WITH $label_prefix @@ -416,7 +429,8 @@ pub fn get_tags_by_label_prefix(label_prefix: &str) -> Query { } pub fn get_tags() -> Query { - query( + Query::new( + "get_tags", " MATCH ()-[t:TAGGED]->() RETURN COLLECT(DISTINCT t.label) AS tag_labels @@ -431,7 +445,8 @@ pub fn get_tag_taggers_by_reach( skip: usize, limit: usize, ) -> Query { - query( + Query::new( + "get_tag_taggers_by_reach", format!( " {} @@ -471,7 +486,8 @@ pub fn get_hot_tags_by_reach( }; let (from, to) = tags_query.timeframe.to_timestamp_range(); - query( + Query::new( + "get_hot_tags_by_reach", format!( " {} @@ -511,7 +527,8 @@ pub fn get_global_hot_tags(tags_query: &HotTagsInputDTO) -> Query { None => String::from("Post|User"), }; let (from, to) = tags_query.timeframe.to_timestamp_range(); - query( + Query::new( + "get_global_hot_tags", format!( " MATCH (user: User)-[tag:TAGGED]->(tagged:{}) @@ -549,7 +566,8 @@ pub fn get_influencers_by_reach( timeframe: &Timeframe, ) -> Query { let (from, to) = timeframe.to_timestamp_range(); - query( + Query::new( + "get_influencers_by_reach", format!( " {} @@ -595,7 +613,8 @@ pub fn get_influencers_by_reach( pub fn get_global_influencers(skip: usize, limit: usize, timeframe: &Timeframe) -> Query { let (from, to) = timeframe.to_timestamp_range(); - query( + Query::new( + "get_global_influencers", " MATCH (user:User) WHERE user.name <> '[DELETED]' @@ -631,7 +650,8 @@ pub fn get_global_influencers(skip: usize, limit: usize, timeframe: &Timeframe) } pub fn get_files_by_ids(key_pair: &[&[&str]]) -> Query { - query( + Query::new( + "get_files_by_ids", " UNWIND $pairs AS pair OPTIONAL MATCH (record:File {owner_id: pair[0], id: pair[1]}) @@ -835,7 +855,7 @@ fn build_query_with_params( kind: Option, pagination: &Pagination, ) -> Query { - let mut query = query(cypher); + let mut query = Query::new("post_stream", cypher); if let Some(observer_id) = source.get_observer() { query = query.param("observer_id", observer_id.to_string()); @@ -863,7 +883,8 @@ fn build_query_with_params( /// # Arguments /// * `user_id` - The unique identifier of the user pub fn user_is_safe_to_delete(user_id: &str) -> Query { - query( + Query::new( + "user_is_safe_to_delete", " MATCH (u:User {id: $user_id}) // Ensures all relationships to the user (u) are checked, counting as 0 if none exist @@ -884,7 +905,8 @@ pub fn user_is_safe_to_delete(user_id: &str) -> Query { /// * `author_id` - The unique identifier of the user who authored the post /// * `post_id` - The unique identifier of the post pub fn post_is_safe_to_delete(author_id: &str, post_id: &str) -> Query { - query( + Query::new( + "post_is_safe_to_delete", " MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) // Ensures all relationships to the post (p) are checked, counting as 0 if none exist @@ -912,7 +934,8 @@ pub fn post_is_safe_to_delete(author_id: &str, post_id: &str) -> Query { /// Find user recommendations: active users (with 5+ posts) who are 1-3 degrees of separation away /// from the given user, but not directly followed by them pub fn recommend_users(user_id: &str, limit: usize) -> Query { - query( + Query::new( + "recommend_users", " MATCH (user:User {id: $user_id}) MATCH (user)-[:FOLLOWS*1..3]->(potential:User) @@ -932,7 +955,8 @@ pub fn recommend_users(user_id: &str, limit: usize) -> Query { /// Retrieve specific tag created by the user pub fn get_tag_by_tagger_and_id(tagger_id: &str, tag_id: &str) -> Query { - query( + Query::new( + "get_tag_by_tagger_and_id", " MATCH (tagger:User { id: $tagger_id})-[tag:TAGGED {id: $tag_id }]->(tagged) OPTIONAL MATCH (author:User)-[:AUTHORED]->(tagged) diff --git a/nexus-common/src/db/graph/queries/put.rs b/nexus-common/src/db/graph/queries/put.rs index 88579d5d4..b8ff2fc16 100644 --- a/nexus-common/src/db/graph/queries/put.rs +++ b/nexus-common/src/db/graph/queries/put.rs @@ -1,4 +1,4 @@ -use crate::db::graph::query::{query, Query}; +use crate::db::graph::query::Query; use crate::models::post::PostRelationships; use crate::models::{file::FileDetails, post::PostDetails, user::UserDetails}; use crate::types::DynError; @@ -8,7 +8,7 @@ use pubky_app_specs::{ParsedUri, Resource}; pub fn create_user(user: &UserDetails) -> Result { let links = serde_json::to_string(&user.links)?; - let query = query( + let query = Query::new("create_user", "MERGE (u:User {id: $id}) SET u.name = $name, u.bio = $bio, u.status = $status, u.links = $links, u.image = $image, u.indexed_at = $indexed_at;", ) @@ -73,7 +73,7 @@ pub fn create_post( let kind = serde_json::to_string(&post.kind)?; - let mut cypher_query = query(&cypher) + let mut cypher_query = Query::new("create_post", &cypher) .param("author_id", post.author.to_string()) .param("post_id", post.id.to_string()) .param("content", post.content.to_string()) @@ -137,7 +137,8 @@ pub fn create_mention_relationship( post_id: &str, mentioned_user_id: &str, ) -> Query { - query( + Query::new( + "create_mention_relationship", "MATCH (author:User {id: $author_id})-[:AUTHORED]->(post:Post {id: $post_id}), (mentioned_user:User {id: $mentioned_user_id}) MERGE (post)-[:MENTIONED]->(mentioned_user)", @@ -155,7 +156,8 @@ pub fn create_mention_relationship( /// * `followee_id` - The unique identifier of the user to be followed. /// * `indexed_at` - A timestamp representing when the relationship was indexed or updated. pub fn create_follow(follower_id: &str, followee_id: &str, indexed_at: i64) -> Query { - query( + Query::new( + "create_follow", "MATCH (follower:User {id: $follower_id}), (followee:User {id: $followee_id}) // Check if follow already existed OPTIONAL MATCH (follower)-[existing:FOLLOWS]->(followee) @@ -175,10 +177,11 @@ pub fn create_follow(follower_id: &str, followee_id: &str, indexed_at: i64) -> Q /// * `muted_id` - The unique identifier of the user to be muted. /// * `indexed_at` - A timestamp indicating when the relationship was created or last updated. pub fn create_mute(user_id: &str, muted_id: &str, indexed_at: i64) -> Query { - query( + Query::new( + "create_mute", "MATCH (user:User {id: $user_id}), (muted:User {id: $muted_id}) // Check if follow already existed - OPTIONAL MATCH (user)-[existing:MUTED]->(muted) + OPTIONAL MATCH (user)-[existing:MUTED]->(muted) MERGE (user)-[r:MUTED]->(muted) SET r.indexed_at = $indexed_at // Returns true if the mute relationship already existed @@ -203,12 +206,13 @@ pub fn create_post_bookmark( bookmark_id: &str, indexed_at: i64, ) -> Query { - query( + Query::new( + "create_post_bookmark", "MATCH (u:User {id: $user_id}) // We assume these nodes are already created. If not we would not be able to add a bookmark MATCH (author:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) // Check if bookmark already existed - OPTIONAL MATCH (u)-[existing:BOOKMARKED]->(p) + OPTIONAL MATCH (u)-[existing:BOOKMARKED]->(p) MERGE (u)-[b:BOOKMARKED]->(p) SET b.indexed_at = $indexed_at, b.id = $bookmark_id @@ -240,12 +244,13 @@ pub fn create_post_tag( label: &str, indexed_at: i64, ) -> Query { - query( + Query::new( + "create_post_tag", "MATCH (user:User {id: $user_id}) // We assume these nodes are already created. If not we would not be able to add a tag MATCH (author:User {id: $author_id})-[:AUTHORED]->(post:Post {id: $post_id}) // Check if tag already existed - OPTIONAL MATCH (user)-[existing:TAGGED {label: $label}]->(post) + OPTIONAL MATCH (user)-[existing:TAGGED {label: $label}]->(post) MERGE (user)-[t:TAGGED {label: $label}]->(post) ON CREATE SET t.indexed_at = $indexed_at, t.id = $tag_id @@ -274,11 +279,12 @@ pub fn create_user_tag( label: &str, indexed_at: i64, ) -> Query { - query( + Query::new( + "create_user_tag", "MATCH (tagged_used:User {id: $tagged_user_id}) MATCH (tagger:User {id: $tagger_user_id}) // Check if tag already existed - OPTIONAL MATCH (tagger)-[existing:TAGGED {label: $label}]->(tagged_used) + OPTIONAL MATCH (tagger)-[existing:TAGGED {label: $label}]->(tagged_used) MERGE (tagger)-[t:TAGGED {label: $label}]->(tagged_used) ON CREATE SET t.indexed_at = $indexed_at, t.id = $tag_id @@ -296,7 +302,8 @@ pub fn create_user_tag( pub fn create_file(file: &FileDetails) -> Result { let urls = serde_json::to_string(&file.urls)?; - let query = query( + let query = Query::new( + "create_file", "MERGE (f:File {id: $id, owner_id: $owner_id}) SET f.uri = $uri, f.indexed_at = $indexed_at, f.created_at = $created_at, f.size = $size, f.src = $src, f.name = $name, f.content_type = $content_type, f.urls = $urls;", @@ -317,7 +324,8 @@ pub fn create_file(file: &FileDetails) -> Result { /// Create a homeserver pub fn create_homeserver(homeserver_id: &str) -> Query { - query( + Query::new( + "create_homeserver", "MERGE (hs:Homeserver { id: $id }) diff --git a/nexus-common/src/db/graph/query.rs b/nexus-common/src/db/graph/query.rs index 0437a584f..ee82c10e7 100644 --- a/nexus-common/src/db/graph/query.rs +++ b/nexus-common/src/db/graph/query.rs @@ -6,18 +6,24 @@ use neo4rs::{BoltList, BoltMap, BoltString, BoltType}; /// `cypher()` and `params_map()` for logging and tracing. #[derive(Clone)] pub struct Query { + label: Option, cypher: String, params: BoltMap, } impl Query { - pub fn new(cypher: impl Into) -> Self { + pub fn new(label: impl Into, cypher: impl Into) -> Self { Self { + label: Some(label.into()), cypher: cypher.into(), params: BoltMap::default(), } } + pub fn label(&self) -> Option<&str> { + self.label.as_deref() + } + pub fn param>(mut self, key: &str, value: T) -> Self { self.params.put(key.into(), value.into()); self @@ -67,7 +73,15 @@ pub fn populate_cypher(cypher: &str, params: &BoltMap) -> String { /// Format a `BoltType` value as a Neo4j cypher literal. fn bolt_to_cypher_literal(val: &BoltType) -> String { match val { - BoltType::String(s) => format!("'{}'", s.value.replace('\\', "\\\\").replace('\'', "\\'")), + BoltType::String(s) => format!( + "'{}'", + s.value + .replace('\\', "\\\\") + .replace('\'', "\\'") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t") + ), BoltType::Integer(i) => i.value.to_string(), BoltType::Float(f) => format!("{}", f.value), BoltType::Boolean(b) => if b.value { "true" } else { "false" }.to_string(), @@ -112,7 +126,194 @@ impl From for neo4rs::Query { } } -/// Drop-in replacement for `neo4rs::query()` -pub fn query(cypher: &str) -> Query { - Query::new(cypher) +/// Convenience constructor – creates a `Query` without a label. +/// Production code should prefer `Query::new(label, cypher)` for explicit labels. +pub fn query(cypher: impl Into) -> Query { + Query { + label: None, + cypher: cypher.into(), + params: BoltMap::default(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── bolt_to_cypher_literal ────────────────────────────────────── + + #[test] + fn literal_string_plain() { + let val = BoltType::String("hello".into()); + assert_eq!(bolt_to_cypher_literal(&val), "'hello'"); + } + + #[test] + fn literal_string_escapes_quotes_and_backslashes() { + let val = BoltType::String("it's a \\path".into()); + assert_eq!(bolt_to_cypher_literal(&val), "'it\\'s a \\\\path'"); + } + + #[test] + fn literal_string_escapes_control_chars() { + let val = BoltType::String("line1\nline2\r\tend".into()); + assert_eq!(bolt_to_cypher_literal(&val), "'line1\\nline2\\r\\tend'"); + } + + #[test] + fn literal_string_empty() { + let val = BoltType::String("".into()); + assert_eq!(bolt_to_cypher_literal(&val), "''"); + } + + #[test] + fn literal_integer() { + let val = BoltType::Integer(neo4rs::BoltInteger::new(42)); + assert_eq!(bolt_to_cypher_literal(&val), "42"); + } + + #[test] + fn literal_negative_integer() { + let val = BoltType::Integer(neo4rs::BoltInteger::new(-1)); + assert_eq!(bolt_to_cypher_literal(&val), "-1"); + } + + #[test] + fn literal_float() { + let val = BoltType::Float(neo4rs::BoltFloat::new(3.14)); + assert_eq!(bolt_to_cypher_literal(&val), "3.14"); + } + + #[test] + fn literal_boolean() { + assert_eq!( + bolt_to_cypher_literal(&BoltType::Boolean(neo4rs::BoltBoolean::new(true))), + "true" + ); + assert_eq!( + bolt_to_cypher_literal(&BoltType::Boolean(neo4rs::BoltBoolean::new(false))), + "false" + ); + } + + #[test] + fn literal_null() { + let val = BoltType::Null(neo4rs::BoltNull); + assert_eq!(bolt_to_cypher_literal(&val), "null"); + } + + #[test] + fn literal_list() { + let list = BoltList::from(vec![ + BoltType::Integer(neo4rs::BoltInteger::new(1)), + BoltType::String("two".into()), + BoltType::Boolean(neo4rs::BoltBoolean::new(false)), + ]); + assert_eq!( + bolt_to_cypher_literal(&BoltType::List(list)), + "[1, 'two', false]" + ); + } + + #[test] + fn literal_list_empty() { + let list = BoltList::from(Vec::::new()); + assert_eq!(bolt_to_cypher_literal(&BoltType::List(list)), "[]"); + } + + #[test] + fn literal_map_single_entry() { + let mut map = BoltMap::default(); + map.put( + "key".into(), + BoltType::Integer(neo4rs::BoltInteger::new(99)), + ); + assert_eq!(bolt_to_cypher_literal(&BoltType::Map(map)), "{key: 99}"); + } + + // ── populate_cypher ───────────────────────────────────────────── + + #[test] + fn populate_basic_substitution() { + let q = query("MATCH (u:User {id: $id}) RETURN u").param("id", "abc123"); + assert_eq!( + q.to_cypher_populated(), + "MATCH (u:User {id: 'abc123'}) RETURN u" + ); + } + + #[test] + fn populate_multiple_params() { + let q = query("MATCH (u:User {id: $id}) SET u.name = $name") + .param("id", "abc") + .param("name", "Alice"); + let result = q.to_cypher_populated(); + assert!(result.contains("'abc'")); + assert!(result.contains("'Alice'")); + assert!(!result.contains("$id")); + assert!(!result.contains("$name")); + } + + #[test] + fn populate_no_params() { + let q = query("RETURN 1"); + assert_eq!(q.to_cypher_populated(), "RETURN 1"); + } + + #[test] + fn populate_prefix_overlap_longer_replaced_first() { + // $user_id should not be partially matched by $user + let q = query("MATCH (u {id: $user_id, name: $user})") + .param("user_id", "id123") + .param("user", "Alice"); + let result = q.to_cypher_populated(); + assert_eq!(result, "MATCH (u {id: 'id123', name: 'Alice'})"); + } + + #[test] + fn populate_integer_and_bool_params() { + let q = query("MATCH (p) WHERE p.age > $age AND p.active = $active RETURN p") + .param("age", 18_i64) + .param("active", true); + let result = q.to_cypher_populated(); + assert!(result.contains("> 18")); + assert!(result.contains("= true")); + } + + #[test] + fn populate_param_not_in_cypher_is_ignored() { + let q = query("RETURN 1").param("unused", "value"); + assert_eq!(q.to_cypher_populated(), "RETURN 1"); + } + + #[test] + fn populate_special_chars_in_value() { + let q = query("SET u.bio = $bio").param("bio", "it's a\nnew \"line\""); + let result = q.to_cypher_populated(); + assert_eq!(result, "SET u.bio = 'it\\'s a\\nnew \"line\"'"); + } + + // ── From for neo4rs::Query ─────────────────────────────── + + #[test] + fn into_neo4rs_query_preserves_cypher() { + let q = query("RETURN $x").param("x", 42_i64); + let nq: neo4rs::Query = q.into(); + // neo4rs::Query doesn't expose cypher publicly, but if the + // conversion compiles and doesn't panic, the basic contract holds. + let _ = nq; + } + + // ── Query builder ─────────────────────────────────────────────── + + #[test] + fn query_builder_params_batch() { + let q = query("MATCH (u {id: $id, name: $name})").params(vec![ + (BoltString::from("id"), BoltType::String("abc".into())), + (BoltString::from("name"), BoltType::String("Bob".into())), + ]); + let result = q.to_cypher_populated(); + assert!(result.contains("'abc'")); + assert!(result.contains("'Bob'")); + } } diff --git a/nexus-common/src/db/graph/setup.rs b/nexus-common/src/db/graph/setup.rs index dec1e4443..f0a64ddc4 100644 --- a/nexus-common/src/db/graph/setup.rs +++ b/nexus-common/src/db/graph/setup.rs @@ -1,4 +1,4 @@ -use crate::db::graph::query::query; +use crate::db::graph::query::Query; use crate::{ db::{get_neo4j_graph, GraphExec}, types::DynError, @@ -38,7 +38,7 @@ pub async fn setup_graph() -> Result<(), DynError> { .map_err(|e| format!("Failed to start transaction: {e}"))?; for &ddl in queries { - if let Err(err) = graph.run(query(ddl)).await { + if let Err(err) = graph.run(Query::new("setup_ddl", ddl)).await { return Err(format!("Failed to apply graph constraints/indexes: {err}").into()); } } diff --git a/nexus-common/src/db/reindex.rs b/nexus-common/src/db/reindex.rs index 89982a45e..f42aba41d 100644 --- a/nexus-common/src/db/reindex.rs +++ b/nexus-common/src/db/reindex.rs @@ -1,5 +1,5 @@ use crate::db::graph::exec::fetch_all_rows_from_graph; -use crate::db::graph::query::query; +use crate::db::graph::query::Query; use crate::models::follow::{Followers, Following, UserFollows}; use crate::models::post::search::PostsByTagSearch; use crate::models::post::Bookmark; @@ -101,7 +101,7 @@ pub async fn reindex_post(author_id: &str, post_id: &str) -> Result<(), DynError } pub async fn get_all_user_ids() -> Result, DynError> { - let query = query("MATCH (u:User) RETURN u.id AS id"); + let query = Query::new("get_all_user_ids", "MATCH (u:User) RETURN u.id AS id"); let rows = fetch_all_rows_from_graph(query).await?; let mut user_ids = Vec::new(); @@ -115,8 +115,10 @@ pub async fn get_all_user_ids() -> Result, DynError> { } async fn get_all_post_ids() -> Result, DynError> { - let query = - query("MATCH (u:User)-[:AUTHORED]->(p:Post) RETURN u.id AS author_id, p.id AS post_id"); + let query = Query::new( + "get_all_post_ids", + "MATCH (u:User)-[:AUTHORED]->(p:Post) RETURN u.id AS author_id, p.id AS post_id", + ); let rows = fetch_all_rows_from_graph(query).await?; let mut post_ids = Vec::new(); diff --git a/nexus-webapi/src/mock.rs b/nexus-webapi/src/mock.rs index 263a805dd..4c84273c9 100644 --- a/nexus-webapi/src/mock.rs +++ b/nexus-webapi/src/mock.rs @@ -1,7 +1,7 @@ use crate::{api_context::ApiContextBuilder, NexusApiBuilder}; use clap::ValueEnum; use nexus_common::{ - db::{get_neo4j_graph, get_redis_conn, graph::query::query, reindex, GraphExec}, + db::{get_neo4j_graph, get_redis_conn, graph::query::Query, reindex, GraphExec}, ApiConfig, }; use std::process::Stdio; @@ -57,7 +57,7 @@ impl MockDb { let graph = get_neo4j_graph().expect("Failed to get Neo4j graph connection"); // drop and run the queries again - let drop_all_query = query("MATCH (n) DETACH DELETE n;"); + let drop_all_query = Query::new("drop_graph", "MATCH (n) DETACH DELETE n;"); graph .run(drop_all_query) .await diff --git a/nexusd/src/migrations/manager.rs b/nexusd/src/migrations/manager.rs index 861f82076..d29548e92 100644 --- a/nexusd/src/migrations/manager.rs +++ b/nexusd/src/migrations/manager.rs @@ -218,7 +218,10 @@ impl MigrationManager { } async fn get_migrations(&self) -> Result, DynError> { - let query = Query::new("MATCH (m:Migration) RETURN COLLECT(m) as migrations".to_string()); + let query = Query::new( + "get_migrations", + "MATCH (m:Migration) RETURN COLLECT(m) as migrations".to_string(), + ); let mut result = self.graph.execute(query).await.map_err(|e| e.to_string())?; match result.try_next().await { @@ -239,6 +242,7 @@ impl MigrationManager { false => MigrationPhase::Backfill, }; let query = Query::new( + "store_migration", "MERGE (m:Migration {id: $id, phase: $phase, created_at: timestamp(), updated_at: 0})" .to_string(), ) @@ -255,6 +259,7 @@ impl MigrationManager { phase: &MigrationPhase, ) -> Result<(), DynError> { let query = Query::new( + "update_migration_phase", "MERGE (m:Migration {id: $id}) SET m.phase = $phase, m.updated_at = timestamp()" .to_string(), ) From 47b0a0f13b995f2f6482563f50289a0baf54f4df Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Thu, 26 Feb 2026 12:14:42 +0100 Subject: [PATCH 04/34] measure row consume time --- nexus-common/src/db/graph/graph.rs | 54 ++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/nexus-common/src/db/graph/graph.rs b/nexus-common/src/db/graph/graph.rs index 2c8b7512d..e8637a0ff 100644 --- a/nexus-common/src/db/graph/graph.rs +++ b/nexus-common/src/db/graph/graph.rs @@ -1,7 +1,9 @@ use async_trait::async_trait; use futures::stream::BoxStream; -use futures::{StreamExt, TryStreamExt}; +use futures::{Stream, StreamExt, TryStreamExt}; use neo4rs::{Graph, Row, Txn}; +use std::pin::Pin; +use std::task::{Context, Poll}; use std::time::{Duration, Instant}; use tracing::warn; @@ -25,6 +27,44 @@ pub trait GraphExec: Send + Sync { async fn start_txn(&self) -> neo4rs::Result; } +/// A stream wrapper that counts rows and logs slow fetch times when dropped. +struct TracedStream { + inner: BoxStream<'static, Result>, + label: Option, + fetch_duration: Duration, + row_count: usize, + threshold: Duration, +} + +impl Stream for TracedStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let poll_start = Instant::now(); + let result = Pin::new(&mut self.inner).poll_next(cx); + self.fetch_duration += poll_start.elapsed(); + if let Poll::Ready(Some(Ok(_))) = &result { + self.row_count += 1; + } + result + } +} + +impl Drop for TracedStream { + fn drop(&mut self) { + if let Some(label) = &self.label { + if self.fetch_duration > self.threshold { + warn!( + elapsed_ms = self.fetch_duration.as_millis(), + rows = self.row_count, + query = %label, + "Slow Neo4j stream fetch" + ); + } + } + } +} + #[derive(Clone)] pub struct TracedGraph { inner: Graph, @@ -58,11 +98,19 @@ impl GraphExec for TracedGraph { if let Some(label) = &label { if elapsed > self.slow_query_threshold { - warn!(elapsed_ms = elapsed.as_millis(), query = %label, "Slow Neo4j query"); + warn!(elapsed_ms = elapsed.as_millis(), query = %label, "Slow Neo4j execute"); } } - Ok(result?.into_stream().map_err(Into::into).boxed()) + let stream = result?.into_stream().map_err(Into::into).boxed(); + let traced = TracedStream { + inner: stream, + label, + fetch_duration: Duration::ZERO, + row_count: 0, + threshold: self.slow_query_threshold, + }; + Ok(traced.boxed()) } async fn run(&self, query: Query) -> neo4rs::Result<()> { From 2e4bcb1704a878675f259f6ac601c12d98d33d42 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Thu, 26 Feb 2026 13:01:32 +0100 Subject: [PATCH 05/34] add comment about execute measure --- nexus-common/src/db/graph/graph.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nexus-common/src/db/graph/graph.rs b/nexus-common/src/db/graph/graph.rs index e8637a0ff..5d211ec66 100644 --- a/nexus-common/src/db/graph/graph.rs +++ b/nexus-common/src/db/graph/graph.rs @@ -92,6 +92,9 @@ impl GraphExec for TracedGraph { query: Query, ) -> neo4rs::Result>> { let label = query.label().map(str::to_owned); + // Measures pool-acquire + Bolt RUN round-trip (query planning & start + // of execution on the server). Does NOT include row fetching — that is + // tracked separately by TracedStream. let start = Instant::now(); let result = self.inner.execute(query.into()).await; let elapsed = start.elapsed(); From 67ddad0c3c9f91eec39be98f0b5c1af17f21bfb5 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Thu, 26 Feb 2026 19:22:07 +0100 Subject: [PATCH 06/34] merge time --- nexus-common/src/db/graph/graph.rs | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/nexus-common/src/db/graph/graph.rs b/nexus-common/src/db/graph/graph.rs index 5d211ec66..3903e4aa9 100644 --- a/nexus-common/src/db/graph/graph.rs +++ b/nexus-common/src/db/graph/graph.rs @@ -27,10 +27,13 @@ pub trait GraphExec: Send + Sync { async fn start_txn(&self) -> neo4rs::Result; } -/// A stream wrapper that counts rows and logs slow fetch times when dropped. +/// A stream wrapper that measures total query time and logs slow queries when dropped. struct TracedStream { inner: BoxStream<'static, Result>, label: Option, + /// Pool-acquire + Bolt RUN round-trip (query planning & start of execution). + execute_duration: Duration, + /// Cumulative time spent inside poll_next (row fetching). fetch_duration: Duration, row_count: usize, threshold: Duration, @@ -53,12 +56,15 @@ impl Stream for TracedStream { impl Drop for TracedStream { fn drop(&mut self) { if let Some(label) = &self.label { - if self.fetch_duration > self.threshold { + let total = self.execute_duration + self.fetch_duration; + if total > self.threshold { warn!( - elapsed_ms = self.fetch_duration.as_millis(), + total_ms = total.as_millis(), + execute_ms = self.execute_duration.as_millis(), + fetch_ms = self.fetch_duration.as_millis(), rows = self.row_count, query = %label, - "Slow Neo4j stream fetch" + "Slow Neo4j query" ); } } @@ -92,23 +98,15 @@ impl GraphExec for TracedGraph { query: Query, ) -> neo4rs::Result>> { let label = query.label().map(str::to_owned); - // Measures pool-acquire + Bolt RUN round-trip (query planning & start - // of execution on the server). Does NOT include row fetching — that is - // tracked separately by TracedStream. let start = Instant::now(); let result = self.inner.execute(query.into()).await; - let elapsed = start.elapsed(); - - if let Some(label) = &label { - if elapsed > self.slow_query_threshold { - warn!(elapsed_ms = elapsed.as_millis(), query = %label, "Slow Neo4j execute"); - } - } + let execute_duration = start.elapsed(); let stream = result?.into_stream().map_err(Into::into).boxed(); let traced = TracedStream { inner: stream, label, + execute_duration, fetch_duration: Duration::ZERO, row_count: 0, threshold: self.slow_query_threshold, From 529f7afeb51e3fa49c2b38eade379a3fed2bb524 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Fri, 27 Feb 2026 09:21:52 +0100 Subject: [PATCH 07/34] remove txns * DDL statements are implicitly committed by Neo4j and idempotent (IF NOT EXISTS). --- nexus-common/src/db/graph/graph.rs | 9 +-------- nexus-common/src/db/graph/setup.rs | 10 ---------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/nexus-common/src/db/graph/graph.rs b/nexus-common/src/db/graph/graph.rs index 3903e4aa9..401602d5f 100644 --- a/nexus-common/src/db/graph/graph.rs +++ b/nexus-common/src/db/graph/graph.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; use futures::stream::BoxStream; use futures::{Stream, StreamExt, TryStreamExt}; -use neo4rs::{Graph, Row, Txn}; +use neo4rs::{Graph, Row}; use std::pin::Pin; use std::task::{Context, Poll}; use std::time::{Duration, Instant}; @@ -22,9 +22,6 @@ pub trait GraphExec: Send + Sync { /// Fire-and-forget query execution. async fn run(&self, query: Query) -> neo4rs::Result<()>; - - /// Start a transaction. - async fn start_txn(&self) -> neo4rs::Result; } /// A stream wrapper that measures total query time and logs slow queries when dropped. @@ -128,8 +125,4 @@ impl GraphExec for TracedGraph { result } - - async fn start_txn(&self) -> neo4rs::Result { - self.inner.start_txn().await - } } diff --git a/nexus-common/src/db/graph/setup.rs b/nexus-common/src/db/graph/setup.rs index f0a64ddc4..c9eab9ce5 100644 --- a/nexus-common/src/db/graph/setup.rs +++ b/nexus-common/src/db/graph/setup.rs @@ -31,21 +31,11 @@ pub async fn setup_graph() -> Result<(), DynError> { let graph = get_neo4j_graph()?; - // Start an explicit transaction - let txn = graph - .start_txn() - .await - .map_err(|e| format!("Failed to start transaction: {e}"))?; - for &ddl in queries { if let Err(err) = graph.run(Query::new("setup_ddl", ddl)).await { return Err(format!("Failed to apply graph constraints/indexes: {err}").into()); } } - // Commit everything in one go - txn.commit() - .await - .map_err(|e| format!("Failed to commit the transaction: {e}"))?; info!("Neo4j graph constraints and indexes have been applied successfully"); From 1deaa59bdc6fb30c909224e8642219964d6f9820 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Sun, 1 Mar 2026 17:25:15 +0100 Subject: [PATCH 08/34] remove redundant to_string() --- nexus-common/src/db/graph/mod.rs | 4 ++-- nexusd/src/migrations/manager.rs | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/nexus-common/src/db/graph/mod.rs b/nexus-common/src/db/graph/mod.rs index 0c015a342..635a016a6 100644 --- a/nexus-common/src/db/graph/mod.rs +++ b/nexus-common/src/db/graph/mod.rs @@ -1,10 +1,10 @@ pub mod error; pub mod exec; -mod traced; pub mod queries; pub mod query; pub mod setup; +mod traced; pub use error::{GraphError, GraphResult}; -pub use traced::{GraphExec, TracedGraph}; pub use query::{query, Query}; +pub use traced::{GraphExec, TracedGraph}; diff --git a/nexusd/src/migrations/manager.rs b/nexusd/src/migrations/manager.rs index d29548e92..af671032d 100644 --- a/nexusd/src/migrations/manager.rs +++ b/nexusd/src/migrations/manager.rs @@ -220,7 +220,7 @@ impl MigrationManager { async fn get_migrations(&self) -> Result, DynError> { let query = Query::new( "get_migrations", - "MATCH (m:Migration) RETURN COLLECT(m) as migrations".to_string(), + "MATCH (m:Migration) RETURN COLLECT(m) as migrations", ); let mut result = self.graph.execute(query).await.map_err(|e| e.to_string())?; @@ -243,8 +243,7 @@ impl MigrationManager { }; let query = Query::new( "store_migration", - "MERGE (m:Migration {id: $id, phase: $phase, created_at: timestamp(), updated_at: 0})" - .to_string(), + "MERGE (m:Migration {id: $id, phase: $phase, created_at: timestamp(), updated_at: 0})", ) .param("id", id) .param("phase", initial_phase.to_string()); @@ -260,8 +259,7 @@ impl MigrationManager { ) -> Result<(), DynError> { let query = Query::new( "update_migration_phase", - "MERGE (m:Migration {id: $id}) SET m.phase = $phase, m.updated_at = timestamp()" - .to_string(), + "MERGE (m:Migration {id: $id}) SET m.phase = $phase, m.updated_at = timestamp()", ) .param("id", id) .param("phase", phase.to_string()); From b7278ceb675e45431b7a7b1e62fa2c4b7df50d1b Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Sun, 1 Mar 2026 17:26:29 +0100 Subject: [PATCH 09/34] consistent import path --- nexus-common/src/db/connectors/neo4j.rs | 2 +- nexus-common/src/db/graph/queries/del.rs | 2 +- nexus-common/src/db/graph/queries/get.rs | 2 +- nexus-common/src/db/graph/queries/put.rs | 2 +- nexus-common/src/db/graph/setup.rs | 2 +- nexus-common/src/db/reindex.rs | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/nexus-common/src/db/connectors/neo4j.rs b/nexus-common/src/db/connectors/neo4j.rs index ea098f265..e57285f67 100644 --- a/nexus-common/src/db/connectors/neo4j.rs +++ b/nexus-common/src/db/connectors/neo4j.rs @@ -1,6 +1,6 @@ use neo4rs::Graph; -use crate::db::graph::query::Query; +use crate::db::graph::Query; use std::fmt; use std::sync::OnceLock; use std::time::Duration; diff --git a/nexus-common/src/db/graph/queries/del.rs b/nexus-common/src/db/graph/queries/del.rs index c5b9c5440..72cfae446 100644 --- a/nexus-common/src/db/graph/queries/del.rs +++ b/nexus-common/src/db/graph/queries/del.rs @@ -1,4 +1,4 @@ -use crate::db::graph::query::Query; +use crate::db::graph::Query; /// Deletes a user node and all its relationships /// # Arguments diff --git a/nexus-common/src/db/graph/queries/get.rs b/nexus-common/src/db/graph/queries/get.rs index b12f64198..007d34099 100644 --- a/nexus-common/src/db/graph/queries/get.rs +++ b/nexus-common/src/db/graph/queries/get.rs @@ -1,4 +1,4 @@ -use crate::db::graph::query::Query; +use crate::db::graph::Query; use crate::models::post::StreamSource; use crate::types::routes::HotTagsInputDTO; use crate::types::Pagination; diff --git a/nexus-common/src/db/graph/queries/put.rs b/nexus-common/src/db/graph/queries/put.rs index 331e847eb..637420f71 100644 --- a/nexus-common/src/db/graph/queries/put.rs +++ b/nexus-common/src/db/graph/queries/put.rs @@ -1,5 +1,5 @@ use crate::db::graph::error::{GraphError, GraphResult}; -use crate::db::graph::query::Query; +use crate::db::graph::Query; use crate::models::post::PostRelationships; use crate::models::{file::FileDetails, post::PostDetails, user::UserDetails}; use pubky_app_specs::{ParsedUri, Resource}; diff --git a/nexus-common/src/db/graph/setup.rs b/nexus-common/src/db/graph/setup.rs index be5073fa1..8509220bd 100644 --- a/nexus-common/src/db/graph/setup.rs +++ b/nexus-common/src/db/graph/setup.rs @@ -1,7 +1,7 @@ use crate::db::get_neo4j_graph; use crate::db::graph::error::{GraphError, GraphResult}; -use crate::db::graph::query::Query; use crate::db::graph::GraphExec; +use crate::db::graph::Query; use tracing::info; /// Ensure the Neo4j graph has the required constraints and indexes diff --git a/nexus-common/src/db/reindex.rs b/nexus-common/src/db/reindex.rs index f42aba41d..e028a69e5 100644 --- a/nexus-common/src/db/reindex.rs +++ b/nexus-common/src/db/reindex.rs @@ -1,5 +1,5 @@ use crate::db::graph::exec::fetch_all_rows_from_graph; -use crate::db::graph::query::Query; +use crate::db::graph::Query; use crate::models::follow::{Followers, Following, UserFollows}; use crate::models::post::search::PostsByTagSearch; use crate::models::post::Bookmark; From fb5d9003a2071c17f27de2dbe7db346539dfb502 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Sun, 1 Mar 2026 17:27:17 +0100 Subject: [PATCH 10/34] remove redundant map_err --- nexus-common/src/db/graph/traced.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nexus-common/src/db/graph/traced.rs b/nexus-common/src/db/graph/traced.rs index 401602d5f..225337657 100644 --- a/nexus-common/src/db/graph/traced.rs +++ b/nexus-common/src/db/graph/traced.rs @@ -1,6 +1,6 @@ use async_trait::async_trait; use futures::stream::BoxStream; -use futures::{Stream, StreamExt, TryStreamExt}; +use futures::{Stream, StreamExt}; use neo4rs::{Graph, Row}; use std::pin::Pin; use std::task::{Context, Poll}; @@ -99,7 +99,7 @@ impl GraphExec for TracedGraph { let result = self.inner.execute(query.into()).await; let execute_duration = start.elapsed(); - let stream = result?.into_stream().map_err(Into::into).boxed(); + let stream = result?.into_stream().boxed(); let traced = TracedStream { inner: stream, label, From 05c281c9c6e62db33859aed02eb27fb03daa386c Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Sun, 1 Mar 2026 17:27:58 +0100 Subject: [PATCH 11/34] toml formatting --- examples/api/api-config.toml | 2 +- examples/watcher/watcher-config.toml | 2 +- nexus-common/default.config.toml | 2 +- nexusd/src/migrations/default.config.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/api/api-config.toml b/examples/api/api-config.toml index e016cea6a..508a021fa 100644 --- a/examples/api/api-config.toml +++ b/examples/api/api-config.toml @@ -19,4 +19,4 @@ uri = "bolt://localhost:7687" # Not needed in the Community Edition #user = "neo4j" password = "12345678" -slow_query_threshold_ms=100 \ No newline at end of file +slow_query_threshold_ms = 100 diff --git a/examples/watcher/watcher-config.toml b/examples/watcher/watcher-config.toml index 35206c15f..29eea9f7c 100644 --- a/examples/watcher/watcher-config.toml +++ b/examples/watcher/watcher-config.toml @@ -31,4 +31,4 @@ uri = "bolt://localhost:7687" # Not needed in the Community Edition #user = "neo4j" password = "12345678" -slow_query_threshold_ms=100 \ No newline at end of file +slow_query_threshold_ms = 100 diff --git a/nexus-common/default.config.toml b/nexus-common/default.config.toml index 7591c139a..cfd58d988 100644 --- a/nexus-common/default.config.toml +++ b/nexus-common/default.config.toml @@ -51,4 +51,4 @@ uri = "bolt://localhost:7687" # Not needed in the Community Edition the profile username, just the password #user = "neo4j" password = "12345678" -slow_query_threshold_ms=100 +slow_query_threshold_ms = 100 diff --git a/nexusd/src/migrations/default.config.toml b/nexusd/src/migrations/default.config.toml index b58dae823..7624853ea 100644 --- a/nexusd/src/migrations/default.config.toml +++ b/nexusd/src/migrations/default.config.toml @@ -20,4 +20,4 @@ uri = "bolt://localhost:7687" # Not needed in the Community Edition #user = "neo4j" password = "12345678" -slow_query_threshold_ms=100 \ No newline at end of file +slow_query_threshold_ms = 100 From 96e9782f736015d6b9835d50e6ed94b3237aed16 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Sun, 1 Mar 2026 17:30:06 +0100 Subject: [PATCH 12/34] add config comments --- examples/api/api-config.toml | 1 + examples/watcher/watcher-config.toml | 1 + nexus-common/default.config.toml | 1 + nexusd/src/migrations/default.config.toml | 1 + 4 files changed, 4 insertions(+) diff --git a/examples/api/api-config.toml b/examples/api/api-config.toml index 508a021fa..d2e450061 100644 --- a/examples/api/api-config.toml +++ b/examples/api/api-config.toml @@ -19,4 +19,5 @@ uri = "bolt://localhost:7687" # Not needed in the Community Edition #user = "neo4j" password = "12345678" +# Queries taking longer than this (ms) will be logged as warnings slow_query_threshold_ms = 100 diff --git a/examples/watcher/watcher-config.toml b/examples/watcher/watcher-config.toml index 29eea9f7c..e2d395f64 100644 --- a/examples/watcher/watcher-config.toml +++ b/examples/watcher/watcher-config.toml @@ -31,4 +31,5 @@ uri = "bolt://localhost:7687" # Not needed in the Community Edition #user = "neo4j" password = "12345678" +# Queries taking longer than this (ms) will be logged as warnings slow_query_threshold_ms = 100 diff --git a/nexus-common/default.config.toml b/nexus-common/default.config.toml index cfd58d988..ed4ff7d05 100644 --- a/nexus-common/default.config.toml +++ b/nexus-common/default.config.toml @@ -51,4 +51,5 @@ uri = "bolt://localhost:7687" # Not needed in the Community Edition the profile username, just the password #user = "neo4j" password = "12345678" +# Queries taking longer than this (ms) will be logged as warnings slow_query_threshold_ms = 100 diff --git a/nexusd/src/migrations/default.config.toml b/nexusd/src/migrations/default.config.toml index 7624853ea..58c1787cc 100644 --- a/nexusd/src/migrations/default.config.toml +++ b/nexusd/src/migrations/default.config.toml @@ -20,4 +20,5 @@ uri = "bolt://localhost:7687" # Not needed in the Community Edition #user = "neo4j" password = "12345678" +# Queries taking longer than this (ms) will be logged as warnings slow_query_threshold_ms = 100 From b56c2f93c5bebd02b08cea63348399d7d8ae1e34 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Mon, 2 Mar 2026 09:14:41 +0100 Subject: [PATCH 13/34] fix --- nexus-common/src/db/graph/traced.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nexus-common/src/db/graph/traced.rs b/nexus-common/src/db/graph/traced.rs index 225337657..401602d5f 100644 --- a/nexus-common/src/db/graph/traced.rs +++ b/nexus-common/src/db/graph/traced.rs @@ -1,6 +1,6 @@ use async_trait::async_trait; use futures::stream::BoxStream; -use futures::{Stream, StreamExt}; +use futures::{Stream, StreamExt, TryStreamExt}; use neo4rs::{Graph, Row}; use std::pin::Pin; use std::task::{Context, Poll}; @@ -99,7 +99,7 @@ impl GraphExec for TracedGraph { let result = self.inner.execute(query.into()).await; let execute_duration = start.elapsed(); - let stream = result?.into_stream().boxed(); + let stream = result?.into_stream().map_err(Into::into).boxed(); let traced = TracedStream { inner: stream, label, From fbba68ddd880249c0cb2dd7c2bbfa543992f60d4 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Mon, 2 Mar 2026 11:35:14 +0100 Subject: [PATCH 14/34] disable logging in migrations and mock --- nexus-common/src/db/config/neo4j.rs | 10 ++++++ nexus-common/src/db/connectors/neo4j.rs | 28 ++++++++------- nexus-common/src/db/graph/exec.rs | 2 +- nexus-common/src/db/graph/mod.rs | 2 +- nexus-common/src/db/graph/setup.rs | 1 - nexus-common/src/db/graph/traced.rs | 43 ++++++++++++++++++++--- nexus-common/src/db/mod.rs | 2 +- nexus-common/src/models/post/stream.rs | 2 +- nexus-webapi/src/mock.rs | 23 ++++++------ nexusd/src/migrations/default.config.toml | 2 ++ nexusd/src/migrations/manager.rs | 11 +++--- 11 files changed, 86 insertions(+), 40 deletions(-) diff --git a/nexus-common/src/db/config/neo4j.rs b/nexus-common/src/db/config/neo4j.rs index 31fd81501..666f2657b 100644 --- a/nexus-common/src/db/config/neo4j.rs +++ b/nexus-common/src/db/config/neo4j.rs @@ -13,8 +13,13 @@ pub struct Neo4JConfig { pub user: String, pub password: String, /// Queries exceeding this threshold (in milliseconds) are logged as warnings. + /// Only used when `slow_query_logging` is enabled. #[serde(default = "default_slow_query_threshold_ms")] pub slow_query_threshold_ms: u64, + /// Enable slow-query logging. Defaults to true. + /// Set to false for CLI/admin commands where tracing overhead is unnecessary. + #[serde(default = "default_slow_query_logging")] + pub slow_query_logging: bool, } fn default_neo4j_user() -> String { @@ -25,6 +30,10 @@ fn default_slow_query_threshold_ms() -> u64 { DEFAULT_SLOW_QUERY_THRESHOLD_MS } +fn default_slow_query_logging() -> bool { + true +} + impl Default for Neo4JConfig { fn default() -> Self { Self { @@ -32,6 +41,7 @@ impl Default for Neo4JConfig { user: String::from(NEO4J_USER), password: String::from(NEO4J_PASS), slow_query_threshold_ms: DEFAULT_SLOW_QUERY_THRESHOLD_MS, + slow_query_logging: true, } } } diff --git a/nexus-common/src/db/connectors/neo4j.rs b/nexus-common/src/db/connectors/neo4j.rs index e57285f67..66c289865 100644 --- a/nexus-common/src/db/connectors/neo4j.rs +++ b/nexus-common/src/db/connectors/neo4j.rs @@ -1,19 +1,17 @@ -use neo4rs::Graph; - use crate::db::graph::Query; use std::fmt; -use std::sync::OnceLock; +use std::sync::{Arc, OnceLock}; use std::time::Duration; use tracing::{debug, info}; use crate::db::graph::error::{GraphError, GraphResult}; -use crate::db::graph::{GraphExec, TracedGraph}; +use crate::db::graph::{Graph, GraphExec, TracedGraph}; use crate::db::setup::setup_graph; use crate::db::Neo4JConfig; use crate::types::DynError; pub struct Neo4jConnector { - graph: TracedGraph, + graph: Arc, } impl Neo4jConnector { @@ -35,14 +33,18 @@ impl Neo4jConnector { /// Create and return a new connector after defining a database connection async fn new_connection(config: &Neo4JConfig) -> GraphResult { - let graph = Graph::new(&config.uri, &config.user, &config.password).await?; - let threshold = Duration::from_millis(config.slow_query_threshold_ms); - let neo4j_connector = Neo4jConnector { - graph: TracedGraph::new(graph).with_slow_query_threshold(threshold), + let neo4j_graph = neo4rs::Graph::new(&config.uri, &config.user, &config.password).await?; + let graph = Graph::new(neo4j_graph); + + let graph: Arc = if config.slow_query_logging { + let threshold = Duration::from_millis(config.slow_query_threshold_ms); + Arc::new(TracedGraph::new(graph).with_slow_query_threshold(threshold)) + } else { + Arc::new(graph) }; - info!("Created Neo4j connector"); - Ok(neo4j_connector) + info!("Created Neo4j connector"); + Ok(Neo4jConnector { graph }) } /// Perform a health-check PING over the Bolt protocol to the Neo4j server @@ -59,13 +61,13 @@ impl Neo4jConnector { impl fmt::Debug for Neo4jConnector { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Neo4jConnector") - .field("graph", &"TracedGraph instance") + .field("graph", &"GraphExec instance") .finish() } } /// Helper to retrieve a Neo4j graph connection. -pub fn get_neo4j_graph() -> GraphResult { +pub fn get_neo4j_graph() -> GraphResult> { NEO4J_CONNECTOR .get() .ok_or(GraphError::ConnectionNotInitialized) diff --git a/nexus-common/src/db/graph/exec.rs b/nexus-common/src/db/graph/exec.rs index 3ef96ed48..c8c170f33 100644 --- a/nexus-common/src/db/graph/exec.rs +++ b/nexus-common/src/db/graph/exec.rs @@ -1,5 +1,5 @@ use super::query::Query; -use crate::db::{get_neo4j_graph, graph::error::GraphResult, GraphExec}; +use crate::db::{get_neo4j_graph, graph::error::GraphResult}; use futures::TryStreamExt; use neo4rs::Row; use serde::de::DeserializeOwned; diff --git a/nexus-common/src/db/graph/mod.rs b/nexus-common/src/db/graph/mod.rs index 635a016a6..4d69d3cef 100644 --- a/nexus-common/src/db/graph/mod.rs +++ b/nexus-common/src/db/graph/mod.rs @@ -7,4 +7,4 @@ mod traced; pub use error::{GraphError, GraphResult}; pub use query::{query, Query}; -pub use traced::{GraphExec, TracedGraph}; +pub use traced::{Graph, GraphExec, TracedGraph}; diff --git a/nexus-common/src/db/graph/setup.rs b/nexus-common/src/db/graph/setup.rs index 8509220bd..090a9cbbd 100644 --- a/nexus-common/src/db/graph/setup.rs +++ b/nexus-common/src/db/graph/setup.rs @@ -1,6 +1,5 @@ use crate::db::get_neo4j_graph; use crate::db::graph::error::{GraphError, GraphResult}; -use crate::db::graph::GraphExec; use crate::db::graph::Query; use tracing::info; diff --git a/nexus-common/src/db/graph/traced.rs b/nexus-common/src/db/graph/traced.rs index 401602d5f..3b1f87803 100644 --- a/nexus-common/src/db/graph/traced.rs +++ b/nexus-common/src/db/graph/traced.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; use futures::stream::BoxStream; use futures::{Stream, StreamExt, TryStreamExt}; -use neo4rs::{Graph, Row}; +use neo4rs::Row; use std::pin::Pin; use std::task::{Context, Poll}; use std::time::{Duration, Instant}; @@ -11,7 +11,7 @@ use super::query::Query; use crate::db::config::DEFAULT_SLOW_QUERY_THRESHOLD_MS; /// Abstraction over graph database operations. -/// Callers depend on this trait, not the concrete TracedGraph. +/// Callers depend on this trait, not the concrete implementations. #[async_trait] pub trait GraphExec: Send + Sync { /// Execute query, return boxed row stream. @@ -24,6 +24,39 @@ pub trait GraphExec: Send + Sync { async fn run(&self, query: Query) -> neo4rs::Result<()>; } +/// Thin wrapper around `neo4rs::Graph` implementing `GraphExec` without tracing. +#[derive(Clone)] +pub struct Graph { + inner: neo4rs::Graph, +} + +impl Graph { + pub fn new(graph: neo4rs::Graph) -> Self { + Self { inner: graph } + } +} + +#[async_trait] +impl GraphExec for Graph { + async fn execute( + &self, + query: Query, + ) -> neo4rs::Result>> { + let stream = self + .inner + .execute(query.into()) + .await? + .into_stream() + .map_err(Into::into) + .boxed(); + Ok(stream) + } + + async fn run(&self, query: Query) -> neo4rs::Result<()> { + self.inner.run(query.into()).await + } +} + /// A stream wrapper that measures total query time and logs slow queries when dropped. struct TracedStream { inner: BoxStream<'static, Result>, @@ -68,6 +101,7 @@ impl Drop for TracedStream { } } +/// Decorator around `Graph` that logs slow queries. #[derive(Clone)] pub struct TracedGraph { inner: Graph, @@ -96,10 +130,9 @@ impl GraphExec for TracedGraph { ) -> neo4rs::Result>> { let label = query.label().map(str::to_owned); let start = Instant::now(); - let result = self.inner.execute(query.into()).await; + let stream = self.inner.execute(query).await?; let execute_duration = start.elapsed(); - let stream = result?.into_stream().map_err(Into::into).boxed(); let traced = TracedStream { inner: stream, label, @@ -114,7 +147,7 @@ impl GraphExec for TracedGraph { async fn run(&self, query: Query) -> neo4rs::Result<()> { let label = query.label().map(str::to_owned); let start = Instant::now(); - let result = self.inner.run(query.into()).await; + let result = self.inner.run(query).await; let elapsed = start.elapsed(); if let Some(label) = &label { diff --git a/nexus-common/src/db/mod.rs b/nexus-common/src/db/mod.rs index ac91028a9..977ae983d 100644 --- a/nexus-common/src/db/mod.rs +++ b/nexus-common/src/db/mod.rs @@ -13,5 +13,5 @@ pub use graph::error::{GraphError, GraphResult}; pub use graph::exec::*; pub use graph::queries; pub use graph::setup; -pub use graph::{GraphExec, TracedGraph}; +pub use graph::GraphExec; pub use kv::RedisOps; diff --git a/nexus-common/src/models/post/stream.rs b/nexus-common/src/models/post/stream.rs index 71c3f728b..fb9bd8790 100644 --- a/nexus-common/src/models/post/stream.rs +++ b/nexus-common/src/models/post/stream.rs @@ -1,6 +1,6 @@ use super::{Bookmark, PostCounts, PostDetails, PostView}; use crate::db::kv::{RedisResult, ScoreAction, SortOrder}; -use crate::db::{get_neo4j_graph, queries, GraphError, GraphExec, GraphResult, RedisOps}; +use crate::db::{get_neo4j_graph, queries, GraphError, GraphResult, RedisOps}; use crate::models::error::ModelError; use crate::models::error::ModelResult; use crate::models::{ diff --git a/nexus-webapi/src/mock.rs b/nexus-webapi/src/mock.rs index 4c84273c9..87094c401 100644 --- a/nexus-webapi/src/mock.rs +++ b/nexus-webapi/src/mock.rs @@ -1,7 +1,7 @@ use crate::{api_context::ApiContextBuilder, NexusApiBuilder}; use clap::ValueEnum; use nexus_common::{ - db::{get_neo4j_graph, get_redis_conn, graph::query::Query, reindex, GraphExec}, + db::{get_neo4j_graph, get_redis_conn, graph::query::Query, reindex}, ApiConfig, }; use std::process::Stdio; @@ -18,9 +18,12 @@ pub enum MockType { pub struct MockDb {} impl MockDb { - pub async fn clear_database() { + /// Initialize the database stack for CLI db commands (no slow-query logging). + async fn init_stack() { + let mut api_config = ApiConfig::default(); + api_config.stack.db.neo4j.slow_query_logging = false; let api_context = ApiContextBuilder::from_default_config_dir() - .api_config(ApiConfig::default()) + .api_config(api_config) .try_build() .await .expect("Failed to create ApiContext"); @@ -28,6 +31,10 @@ impl MockDb { .init_stack() .await .expect("Failed to initialize stack"); + } + + pub async fn clear_database() { + Self::init_stack().await; Self::drop_cache().await; Self::drop_graph().await; @@ -35,15 +42,7 @@ impl MockDb { } pub async fn run(mock_type: Option) { - let api_context = ApiContextBuilder::from_default_config_dir() - .api_config(ApiConfig::default()) - .try_build() - .await - .expect("Failed to create ApiContext"); - NexusApiBuilder(api_context) - .init_stack() - .await - .expect("Failed to initialize stack"); + Self::init_stack().await; match mock_type { Some(MockType::Redis) => Self::sync_redis().await, diff --git a/nexusd/src/migrations/default.config.toml b/nexusd/src/migrations/default.config.toml index 58c1787cc..c4aed3b5b 100644 --- a/nexusd/src/migrations/default.config.toml +++ b/nexusd/src/migrations/default.config.toml @@ -22,3 +22,5 @@ uri = "bolt://localhost:7687" password = "12345678" # Queries taking longer than this (ms) will be logged as warnings slow_query_threshold_ms = 100 +# Disable slow query logging for CLI migration commands +slow_query_logging = false diff --git a/nexusd/src/migrations/manager.rs b/nexusd/src/migrations/manager.rs index af671032d..779441960 100644 --- a/nexusd/src/migrations/manager.rs +++ b/nexusd/src/migrations/manager.rs @@ -2,11 +2,12 @@ use async_trait::async_trait; use chrono::Utc; use futures::TryStreamExt; use nexus_common::{ - db::{get_neo4j_graph, graph::Query, GraphExec, TracedGraph}, + db::{get_neo4j_graph, graph::Query, GraphExec}, types::DynError, }; use serde::{Deserialize, Serialize}; use std::any::Any; +use std::sync::Arc; use tracing::info; use crate::migrations::utils::{self, generate_template}; @@ -89,18 +90,18 @@ pub struct MigrationNode { const MIGRATION_PATH: &str = "nexusd/src/migrations/migrations_list/"; pub struct MigrationManager { - graph: TracedGraph, + graph: Arc, migrations: Vec>, } impl Default for MigrationManager { fn default() -> Self { - let graph_connection = match get_neo4j_graph() { - Ok(connection) => connection, + let graph = match get_neo4j_graph() { + Ok(graph) => graph, Err(e) => panic!("Could not initialise migration manager: {e:?}"), }; Self { - graph: graph_connection, + graph, migrations: Vec::new(), } } From 90bbb463ad370e47bc7f0411ccb1cb06b8d4a3bd Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Mon, 2 Mar 2026 12:06:47 +0100 Subject: [PATCH 15/34] reduce visibility --- nexus-common/src/db/graph/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nexus-common/src/db/graph/mod.rs b/nexus-common/src/db/graph/mod.rs index 4d69d3cef..43358959c 100644 --- a/nexus-common/src/db/graph/mod.rs +++ b/nexus-common/src/db/graph/mod.rs @@ -7,4 +7,5 @@ mod traced; pub use error::{GraphError, GraphResult}; pub use query::{query, Query}; -pub use traced::{Graph, GraphExec, TracedGraph}; +pub use traced::GraphExec; +pub(crate) use traced::{Graph, TracedGraph}; From 3a493180917a46cee8598968e651cdb0f76f45f7 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Mon, 2 Mar 2026 12:14:17 +0100 Subject: [PATCH 16/34] static str instead of String --- nexus-common/src/db/graph/query.rs | 10 +++++----- nexus-common/src/db/graph/traced.rs | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/nexus-common/src/db/graph/query.rs b/nexus-common/src/db/graph/query.rs index ee82c10e7..5f036b1a5 100644 --- a/nexus-common/src/db/graph/query.rs +++ b/nexus-common/src/db/graph/query.rs @@ -6,22 +6,22 @@ use neo4rs::{BoltList, BoltMap, BoltString, BoltType}; /// `cypher()` and `params_map()` for logging and tracing. #[derive(Clone)] pub struct Query { - label: Option, + label: Option<&'static str>, cypher: String, params: BoltMap, } impl Query { - pub fn new(label: impl Into, cypher: impl Into) -> Self { + pub fn new(label: &'static str, cypher: impl Into) -> Self { Self { - label: Some(label.into()), + label: Some(label), cypher: cypher.into(), params: BoltMap::default(), } } - pub fn label(&self) -> Option<&str> { - self.label.as_deref() + pub fn label(&self) -> Option<&'static str> { + self.label } pub fn param>(mut self, key: &str, value: T) -> Self { diff --git a/nexus-common/src/db/graph/traced.rs b/nexus-common/src/db/graph/traced.rs index 3b1f87803..a2d7879f8 100644 --- a/nexus-common/src/db/graph/traced.rs +++ b/nexus-common/src/db/graph/traced.rs @@ -60,7 +60,7 @@ impl GraphExec for Graph { /// A stream wrapper that measures total query time and logs slow queries when dropped. struct TracedStream { inner: BoxStream<'static, Result>, - label: Option, + label: Option<&'static str>, /// Pool-acquire + Bolt RUN round-trip (query planning & start of execution). execute_duration: Duration, /// Cumulative time spent inside poll_next (row fetching). @@ -128,7 +128,7 @@ impl GraphExec for TracedGraph { &self, query: Query, ) -> neo4rs::Result>> { - let label = query.label().map(str::to_owned); + let label = query.label(); let start = Instant::now(); let stream = self.inner.execute(query).await?; let execute_duration = start.elapsed(); @@ -145,7 +145,7 @@ impl GraphExec for TracedGraph { } async fn run(&self, query: Query) -> neo4rs::Result<()> { - let label = query.label().map(str::to_owned); + let label = query.label(); let start = Instant::now(); let result = self.inner.run(query).await; let elapsed = start.elapsed(); From 4a501034cce435fc7ac012ac6fd5c746e826a6f6 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Mon, 2 Mar 2026 12:25:22 +0100 Subject: [PATCH 17/34] query module private --- nexus-common/src/db/graph/mod.rs | 2 +- nexus-watcher/tests/event_processor/follows/utils.rs | 2 +- nexus-watcher/tests/event_processor/mentions/utils.rs | 2 +- nexus-watcher/tests/event_processor/mutes/utils.rs | 2 +- nexus-watcher/tests/event_processor/posts/utils.rs | 2 +- nexus-watcher/tests/event_processor/tags/utils.rs | 2 +- nexus-webapi/src/mock.rs | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/nexus-common/src/db/graph/mod.rs b/nexus-common/src/db/graph/mod.rs index 43358959c..8efb138b7 100644 --- a/nexus-common/src/db/graph/mod.rs +++ b/nexus-common/src/db/graph/mod.rs @@ -1,7 +1,7 @@ pub mod error; pub mod exec; pub mod queries; -pub mod query; +mod query; pub mod setup; mod traced; diff --git a/nexus-watcher/tests/event_processor/follows/utils.rs b/nexus-watcher/tests/event_processor/follows/utils.rs index 507df2689..c49f9c87a 100644 --- a/nexus-watcher/tests/event_processor/follows/utils.rs +++ b/nexus-watcher/tests/event_processor/follows/utils.rs @@ -1,6 +1,6 @@ use anyhow::Result; use nexus_common::db::fetch_key_from_graph; -use nexus_common::db::graph::query::{query, Query}; +use nexus_common::db::graph::{query, Query}; pub async fn find_follow_relationship(follower: &str, followee: &str) -> Result { let query = user_following_query(follower, followee); diff --git a/nexus-watcher/tests/event_processor/mentions/utils.rs b/nexus-watcher/tests/event_processor/mentions/utils.rs index 43ae85cb0..69cc901de 100644 --- a/nexus-watcher/tests/event_processor/mentions/utils.rs +++ b/nexus-watcher/tests/event_processor/mentions/utils.rs @@ -1,6 +1,6 @@ use anyhow::Result; use nexus_common::db::fetch_key_from_graph; -use nexus_common::db::graph::query::{query, Query}; +use nexus_common::db::graph::{query, Query}; pub async fn find_post_mentions(follower: &str, followee: &str) -> Result> { let query = post_mention_query(follower, followee); diff --git a/nexus-watcher/tests/event_processor/mutes/utils.rs b/nexus-watcher/tests/event_processor/mutes/utils.rs index 87301939c..7a0dcf8d7 100644 --- a/nexus-watcher/tests/event_processor/mutes/utils.rs +++ b/nexus-watcher/tests/event_processor/mutes/utils.rs @@ -1,6 +1,6 @@ use anyhow::Result; use nexus_common::db::fetch_key_from_graph; -use nexus_common::db::graph::query::query; +use nexus_common::db::graph::query; pub async fn find_mute_relationship(muter: &str, mutee: &str) -> Result { let query = diff --git a/nexus-watcher/tests/event_processor/posts/utils.rs b/nexus-watcher/tests/event_processor/posts/utils.rs index 2c0ba5ff8..74fd9691a 100644 --- a/nexus-watcher/tests/event_processor/posts/utils.rs +++ b/nexus-watcher/tests/event_processor/posts/utils.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use nexus_common::db::graph::query::{query, Query}; +use nexus_common::db::graph::{query, Query}; use nexus_common::{ db::{fetch_key_from_graph, RedisOps}, models::post::{ diff --git a/nexus-watcher/tests/event_processor/tags/utils.rs b/nexus-watcher/tests/event_processor/tags/utils.rs index 351f53ad0..47e286470 100644 --- a/nexus-watcher/tests/event_processor/tags/utils.rs +++ b/nexus-watcher/tests/event_processor/tags/utils.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use nexus_common::db::graph::query::{query, Query}; +use nexus_common::db::graph::{query, Query}; use nexus_common::{ db::{fetch_key_from_graph, RedisOps}, models::{ diff --git a/nexus-webapi/src/mock.rs b/nexus-webapi/src/mock.rs index 87094c401..c46d9f4d9 100644 --- a/nexus-webapi/src/mock.rs +++ b/nexus-webapi/src/mock.rs @@ -1,7 +1,7 @@ use crate::{api_context::ApiContextBuilder, NexusApiBuilder}; use clap::ValueEnum; use nexus_common::{ - db::{get_neo4j_graph, get_redis_conn, graph::query::Query, reindex}, + db::{get_neo4j_graph, get_redis_conn, graph::Query, reindex}, ApiConfig, }; use std::process::Stdio; From 69e1cb5357ca44af77862fe17eb80f76be106212 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Mon, 2 Mar 2026 13:29:13 +0100 Subject: [PATCH 18/34] measure wall clock time --- nexus-common/src/db/graph/traced.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/nexus-common/src/db/graph/traced.rs b/nexus-common/src/db/graph/traced.rs index a2d7879f8..a20b9e9ed 100644 --- a/nexus-common/src/db/graph/traced.rs +++ b/nexus-common/src/db/graph/traced.rs @@ -63,8 +63,8 @@ struct TracedStream { label: Option<&'static str>, /// Pool-acquire + Bolt RUN round-trip (query planning & start of execution). execute_duration: Duration, - /// Cumulative time spent inside poll_next (row fetching). - fetch_duration: Duration, + /// Wall-clock time from stream creation to drop (row fetching & consumption). + stream_start: Instant, row_count: usize, threshold: Duration, } @@ -73,9 +73,7 @@ impl Stream for TracedStream { type Item = Result; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let poll_start = Instant::now(); let result = Pin::new(&mut self.inner).poll_next(cx); - self.fetch_duration += poll_start.elapsed(); if let Poll::Ready(Some(Ok(_))) = &result { self.row_count += 1; } @@ -86,12 +84,13 @@ impl Stream for TracedStream { impl Drop for TracedStream { fn drop(&mut self) { if let Some(label) = &self.label { - let total = self.execute_duration + self.fetch_duration; + let fetch_duration = self.stream_start.elapsed(); + let total = self.execute_duration + fetch_duration; if total > self.threshold { warn!( total_ms = total.as_millis(), execute_ms = self.execute_duration.as_millis(), - fetch_ms = self.fetch_duration.as_millis(), + fetch_ms = fetch_duration.as_millis(), rows = self.row_count, query = %label, "Slow Neo4j query" @@ -137,7 +136,7 @@ impl GraphExec for TracedGraph { inner: stream, label, execute_duration, - fetch_duration: Duration::ZERO, + stream_start: Instant::now(), row_count: 0, threshold: self.slow_query_threshold, }; From aa71221afbc6aa13054c63e1008ac1124a557982 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Tue, 3 Mar 2026 08:38:06 +0100 Subject: [PATCH 19/34] log slow queries cypher --- examples/api/api-config.toml | 2 + examples/watcher/watcher-config.toml | 2 + nexus-common/src/db/config/neo4j.rs | 5 +++ nexus-common/src/db/connectors/neo4j.rs | 6 ++- nexus-common/src/db/graph/traced.rs | 54 ++++++++++++++++++++----- 5 files changed, 59 insertions(+), 10 deletions(-) diff --git a/examples/api/api-config.toml b/examples/api/api-config.toml index d2e450061..123a377ee 100644 --- a/examples/api/api-config.toml +++ b/examples/api/api-config.toml @@ -21,3 +21,5 @@ uri = "bolt://localhost:7687" password = "12345678" # Queries taking longer than this (ms) will be logged as warnings slow_query_threshold_ms = 100 +# Include the full cypher (with interpolated params) in slow-query warnings +#log_slow_query_cypher = false diff --git a/examples/watcher/watcher-config.toml b/examples/watcher/watcher-config.toml index e2d395f64..6e12a6a4c 100644 --- a/examples/watcher/watcher-config.toml +++ b/examples/watcher/watcher-config.toml @@ -33,3 +33,5 @@ uri = "bolt://localhost:7687" password = "12345678" # Queries taking longer than this (ms) will be logged as warnings slow_query_threshold_ms = 100 +# Include the full cypher (with interpolated params) in slow-query warnings +#log_slow_query_cypher = false diff --git a/nexus-common/src/db/config/neo4j.rs b/nexus-common/src/db/config/neo4j.rs index 666f2657b..39b9d73db 100644 --- a/nexus-common/src/db/config/neo4j.rs +++ b/nexus-common/src/db/config/neo4j.rs @@ -20,6 +20,10 @@ pub struct Neo4JConfig { /// Set to false for CLI/admin commands where tracing overhead is unnecessary. #[serde(default = "default_slow_query_logging")] pub slow_query_logging: bool, + /// Include the full cypher (with interpolated params) in slow-query warnings. + /// Useful for debugging but verbose. Defaults to false. + #[serde(default)] + pub log_slow_query_cypher: bool, } fn default_neo4j_user() -> String { @@ -42,6 +46,7 @@ impl Default for Neo4JConfig { password: String::from(NEO4J_PASS), slow_query_threshold_ms: DEFAULT_SLOW_QUERY_THRESHOLD_MS, slow_query_logging: true, + log_slow_query_cypher: false, } } } diff --git a/nexus-common/src/db/connectors/neo4j.rs b/nexus-common/src/db/connectors/neo4j.rs index 66c289865..91f353d10 100644 --- a/nexus-common/src/db/connectors/neo4j.rs +++ b/nexus-common/src/db/connectors/neo4j.rs @@ -38,7 +38,11 @@ impl Neo4jConnector { let graph: Arc = if config.slow_query_logging { let threshold = Duration::from_millis(config.slow_query_threshold_ms); - Arc::new(TracedGraph::new(graph).with_slow_query_threshold(threshold)) + Arc::new( + TracedGraph::new(graph) + .with_slow_query_threshold(threshold) + .with_log_cypher(config.log_slow_query_cypher), + ) } else { Arc::new(graph) }; diff --git a/nexus-common/src/db/graph/traced.rs b/nexus-common/src/db/graph/traced.rs index a20b9e9ed..22e029bd9 100644 --- a/nexus-common/src/db/graph/traced.rs +++ b/nexus-common/src/db/graph/traced.rs @@ -61,6 +61,8 @@ impl GraphExec for Graph { struct TracedStream { inner: BoxStream<'static, Result>, label: Option<&'static str>, + /// Populated cypher text for debug logging (only set when `log_slow_query_cypher` is enabled). + cypher: Option, /// Pool-acquire + Bolt RUN round-trip (query planning & start of execution). execute_duration: Duration, /// Wall-clock time from stream creation to drop (row fetching & consumption). @@ -87,14 +89,26 @@ impl Drop for TracedStream { let fetch_duration = self.stream_start.elapsed(); let total = self.execute_duration + fetch_duration; if total > self.threshold { - warn!( - total_ms = total.as_millis(), - execute_ms = self.execute_duration.as_millis(), - fetch_ms = fetch_duration.as_millis(), - rows = self.row_count, - query = %label, - "Slow Neo4j query" - ); + if let Some(cypher) = &self.cypher { + warn!( + total_ms = total.as_millis(), + execute_ms = self.execute_duration.as_millis(), + fetch_ms = fetch_duration.as_millis(), + rows = self.row_count, + query = %label, + cypher = %cypher, + "Slow Neo4j query" + ); + } else { + warn!( + total_ms = total.as_millis(), + execute_ms = self.execute_duration.as_millis(), + fetch_ms = fetch_duration.as_millis(), + rows = self.row_count, + query = %label, + "Slow Neo4j query" + ); + } } } } @@ -105,6 +119,7 @@ impl Drop for TracedStream { pub struct TracedGraph { inner: Graph, slow_query_threshold: Duration, + log_cypher: bool, } impl TracedGraph { @@ -112,6 +127,7 @@ impl TracedGraph { Self { inner: graph, slow_query_threshold: Duration::from_millis(DEFAULT_SLOW_QUERY_THRESHOLD_MS), + log_cypher: false, } } @@ -119,6 +135,11 @@ impl TracedGraph { self.slow_query_threshold = threshold; self } + + pub fn with_log_cypher(mut self, enabled: bool) -> Self { + self.log_cypher = enabled; + self + } } #[async_trait] @@ -128,6 +149,11 @@ impl GraphExec for TracedGraph { query: Query, ) -> neo4rs::Result>> { let label = query.label(); + let cypher = if self.log_cypher { + Some(query.to_cypher_populated()) + } else { + None + }; let start = Instant::now(); let stream = self.inner.execute(query).await?; let execute_duration = start.elapsed(); @@ -135,6 +161,7 @@ impl GraphExec for TracedGraph { let traced = TracedStream { inner: stream, label, + cypher, execute_duration, stream_start: Instant::now(), row_count: 0, @@ -145,13 +172,22 @@ impl GraphExec for TracedGraph { async fn run(&self, query: Query) -> neo4rs::Result<()> { let label = query.label(); + let cypher = if self.log_cypher { + Some(query.to_cypher_populated()) + } else { + None + }; let start = Instant::now(); let result = self.inner.run(query).await; let elapsed = start.elapsed(); if let Some(label) = &label { if elapsed > self.slow_query_threshold { - warn!(elapsed_ms = elapsed.as_millis(), query = %label, "Slow Neo4j query"); + if let Some(cypher) = &cypher { + warn!(elapsed_ms = elapsed.as_millis(), query = %label, cypher = %cypher, "Slow Neo4j query"); + } else { + warn!(elapsed_ms = elapsed.as_millis(), query = %label, "Slow Neo4j query"); + } } } From 82caca3420d63b7714fc04a02d756d6a13c1da15 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Tue, 3 Mar 2026 09:19:04 +0100 Subject: [PATCH 20/34] fix clippy PI warning --- nexus-common/src/db/graph/query.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nexus-common/src/db/graph/query.rs b/nexus-common/src/db/graph/query.rs index 5f036b1a5..1394372fd 100644 --- a/nexus-common/src/db/graph/query.rs +++ b/nexus-common/src/db/graph/query.rs @@ -180,8 +180,8 @@ mod tests { #[test] fn literal_float() { - let val = BoltType::Float(neo4rs::BoltFloat::new(3.14)); - assert_eq!(bolt_to_cypher_literal(&val), "3.14"); + let val = BoltType::Float(neo4rs::BoltFloat::new(3.01)); + assert_eq!(bolt_to_cypher_literal(&val), "3.01"); } #[test] From d92071264a3ef0a942dc5ee4321ff154bd6b936a Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Wed, 4 Mar 2026 07:28:29 +0100 Subject: [PATCH 21/34] fmt --- nexus-common/src/db/config/neo4j.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nexus-common/src/db/config/neo4j.rs b/nexus-common/src/db/config/neo4j.rs index 39b9d73db..4af63a857 100644 --- a/nexus-common/src/db/config/neo4j.rs +++ b/nexus-common/src/db/config/neo4j.rs @@ -9,17 +9,22 @@ pub const DEFAULT_SLOW_QUERY_THRESHOLD_MS: u64 = 100; #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] pub struct Neo4JConfig { pub uri: String, + #[serde(default = "default_neo4j_user")] pub user: String, + pub password: String, + /// Queries exceeding this threshold (in milliseconds) are logged as warnings. /// Only used when `slow_query_logging` is enabled. #[serde(default = "default_slow_query_threshold_ms")] pub slow_query_threshold_ms: u64, + /// Enable slow-query logging. Defaults to true. /// Set to false for CLI/admin commands where tracing overhead is unnecessary. #[serde(default = "default_slow_query_logging")] pub slow_query_logging: bool, + /// Include the full cypher (with interpolated params) in slow-query warnings. /// Useful for debugging but verbose. Defaults to false. #[serde(default)] From dad6341af3a903a262eb899069e1454da26f9662 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Wed, 4 Mar 2026 08:49:30 +0100 Subject: [PATCH 22/34] extract query label and cypher to vars --- nexus-common/src/db/connectors/neo4j.rs | 4 +- nexus-common/src/db/graph/queries/del.rs | 89 ++--- nexus-common/src/db/graph/queries/get.rs | 453 +++++++++++------------ nexus-common/src/db/graph/queries/put.rs | 168 ++++----- nexus-common/src/db/graph/setup.rs | 3 +- nexus-common/src/db/reindex.rs | 11 +- nexus-webapi/src/mock.rs | 4 +- 7 files changed, 346 insertions(+), 386 deletions(-) diff --git a/nexus-common/src/db/connectors/neo4j.rs b/nexus-common/src/db/connectors/neo4j.rs index 91f353d10..4af985e61 100644 --- a/nexus-common/src/db/connectors/neo4j.rs +++ b/nexus-common/src/db/connectors/neo4j.rs @@ -53,7 +53,9 @@ impl Neo4jConnector { /// Perform a health-check PING over the Bolt protocol to the Neo4j server async fn ping(&self, neo4j_uri: &str) -> Result<(), DynError> { - if let Err(neo4j_err) = self.graph.run(Query::new("ping", "RETURN 1")).await { + let label = "ping"; + let cypher = "RETURN 1"; + if let Err(neo4j_err) = self.graph.run(Query::new(label, cypher)).await { return Err(format!("Failed to PING to Neo4j at {neo4j_uri}, {neo4j_err}").into()); } diff --git a/nexus-common/src/db/graph/queries/del.rs b/nexus-common/src/db/graph/queries/del.rs index 72cfae446..0ffc2377d 100644 --- a/nexus-common/src/db/graph/queries/del.rs +++ b/nexus-common/src/db/graph/queries/del.rs @@ -4,12 +4,10 @@ use crate::db::graph::Query; /// # Arguments /// * `user_id` - The unique identifier of the user to be deleted pub fn delete_user(user_id: &str) -> Query { - Query::new( - "delete_user", - "MATCH (u:User {id: $id}) - DETACH DELETE u;", - ) - .param("id", user_id.to_string()) + let label = "delete_user"; + let cypher = "MATCH (u:User {id: $id}) + DETACH DELETE u;"; + Query::new(label, cypher).param("id", user_id.to_string()) } /// Deletes a post node authored by a specific user, along with all its relationships @@ -17,13 +15,12 @@ pub fn delete_user(user_id: &str) -> Query { /// * `author_id` - The unique identifier of the user who authored the post. /// * `post_id` - The unique identifier of the post to be deleted. pub fn delete_post(author_id: &str, post_id: &str) -> Query { - Query::new( - "delete_post", - "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) - DETACH DELETE p;", - ) - .param("author_id", author_id.to_string()) - .param("post_id", post_id.to_string()) + let label = "delete_post"; + let cypher = "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) + DETACH DELETE p;"; + Query::new(label, cypher) + .param("author_id", author_id.to_string()) + .param("post_id", post_id.to_string()) } /// Deletes a "follows" relationship between two users @@ -31,18 +28,17 @@ pub fn delete_post(author_id: &str, post_id: &str) -> Query { /// * `follower_id` - The unique identifier of the user who is following another user. /// * `followee_id` - The unique identifier of the user being followed pub fn delete_follow(follower_id: &str, followee_id: &str) -> Query { - Query::new( - "delete_follow", - "// Important that MATCH to check if both users are in the graph + let label = "delete_follow"; + let cypher = "// Important that MATCH to check if both users are in the graph MATCH (follower:User {id: $follower_id}), (followee:User {id: $followee_id}) // Check if follow already exist - OPTIONAL MATCH (follower)-[existing:FOLLOWS]->(followee) + OPTIONAL MATCH (follower)-[existing:FOLLOWS]->(followee) DELETE existing // Returns true if the relationship does not exist as 'flag' - RETURN existing IS NULL AS flag;", - ) - .param("follower_id", follower_id.to_string()) - .param("followee_id", followee_id.to_string()) + RETURN existing IS NULL AS flag;"; + Query::new(label, cypher) + .param("follower_id", follower_id.to_string()) + .param("followee_id", followee_id.to_string()) } /// Deletes a "muted" relationship between two users @@ -50,17 +46,16 @@ pub fn delete_follow(follower_id: &str, followee_id: &str) -> Query { /// * `user_id` - The unique identifier of the user who muted another user /// * `muted_id` - The unique identifier of the user who was muted pub fn delete_mute(user_id: &str, muted_id: &str) -> Query { - Query::new( - "delete_mute", - "// Important that MATCH to check if both users are in the graph + let label = "delete_mute"; + let cypher = "// Important that MATCH to check if both users are in the graph MATCH (user:User {id: $user_id}), (muted:User {id: $muted_id}) OPTIONAL MATCH (user)-[existing:MUTED]->(muted) DELETE existing // Returns true if the relationship does not exist as 'flag' - RETURN existing IS NULL AS flag;", - ) - .param("user_id", user_id.to_string()) - .param("muted_id", muted_id.to_string()) + RETURN existing IS NULL AS flag;"; + Query::new(label, cypher) + .param("user_id", user_id.to_string()) + .param("muted_id", muted_id.to_string()) } /// Deletes a bookmark relationship between a user and a post @@ -68,14 +63,14 @@ pub fn delete_mute(user_id: &str, muted_id: &str) -> Query { /// * `user_id` - The unique identifier of the user who created the bookmark. /// * `bookmark_id` - The unique identifier of the bookmark relationship to be deleted. pub fn delete_bookmark(user_id: &str, bookmark_id: &str) -> Query { - Query::new("delete_bookmark", - "MATCH (u:User {id: $user_id})-[b:BOOKMARKED {id: $bookmark_id}]->(post:Post)<-[:AUTHORED]-(author:User) + let label = "delete_bookmark"; + let cypher = "MATCH (u:User {id: $user_id})-[b:BOOKMARKED {id: $bookmark_id}]->(post:Post)<-[:AUTHORED]-(author:User) WITH post.id as post_id, author.id as author_id, b DELETE b - RETURN post_id, author_id", - ) - .param("user_id", user_id) - .param("bookmark_id", bookmark_id) + RETURN post_id, author_id"; + Query::new(label, cypher) + .param("user_id", user_id) + .param("bookmark_id", bookmark_id) } /// Deletes a tag relationship created by a user and retrieves relevant details about the tag's target @@ -83,9 +78,8 @@ pub fn delete_bookmark(user_id: &str, bookmark_id: &str) -> Query { /// * `user_id` - The unique identifier of the user who created the tag. /// * `tag_id` - The unique identifier of the `TAGGED` relationship to be deleted. pub fn delete_tag(user_id: &str, tag_id: &str) -> Query { - Query::new( - "delete_tag", - "MATCH (user:User {id: $user_id})-[tag:TAGGED {id: $tag_id}]->(target) + let label = "delete_tag"; + let cypher = "MATCH (user:User {id: $user_id})-[tag:TAGGED {id: $tag_id}]->(target) OPTIONAL MATCH (target)<-[:AUTHORED]-(author:User) WITH CASE WHEN target:User THEN target.id ELSE null END AS user_id, CASE WHEN target:Post THEN target.id ELSE null END AS post_id, @@ -93,10 +87,10 @@ pub fn delete_tag(user_id: &str, tag_id: &str) -> Query { tag.label AS label, tag DELETE tag - RETURN user_id, post_id, author_id, label", - ) - .param("user_id", user_id) - .param("tag_id", tag_id) + RETURN user_id, post_id, author_id, label"; + Query::new(label, cypher) + .param("user_id", user_id) + .param("tag_id", tag_id) } /// Deletes a file node and all its relationships @@ -104,11 +98,10 @@ pub fn delete_tag(user_id: &str, tag_id: &str) -> Query { /// * `owner_id` - The unique identifier of the user who owns the file /// * `file_id` - The unique identifier of the file to be deleted pub fn delete_file(owner_id: &str, file_id: &str) -> Query { - Query::new( - "delete_file", - "MATCH (f:File {id: $id, owner_id: $owner_id}) - DETACH DELETE f;", - ) - .param("id", file_id.to_string()) - .param("owner_id", owner_id.to_string()) + let label = "delete_file"; + let cypher = "MATCH (f:File {id: $id, owner_id: $owner_id}) + DETACH DELETE f;"; + Query::new(label, cypher) + .param("id", file_id.to_string()) + .param("owner_id", owner_id.to_string()) } diff --git a/nexus-common/src/db/graph/queries/get.rs b/nexus-common/src/db/graph/queries/get.rs index 007d34099..fc7227123 100644 --- a/nexus-common/src/db/graph/queries/get.rs +++ b/nexus-common/src/db/graph/queries/get.rs @@ -9,9 +9,8 @@ use pubky_app_specs::PubkyAppPostKind; // Retrieve post node by post id and author id pub fn get_post_by_id(author_id: &str, post_id: &str) -> Query { - Query::new( - "get_post_by_id", - " + let label = "get_post_by_id"; + let cypher = " MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) OPTIONAL MATCH (p)-[replied:REPLIED]->(parent_post:Post)<-[:AUTHORED]-(author:User) WITH u, p, parent_post, author @@ -28,147 +27,139 @@ pub fn get_post_by_id(author_id: &str, post_id: &str) -> Query { } as details, COLLECT([author.id, parent_post.id]) AS reply - ", - ) - .param("author_id", author_id) - .param("post_id", post_id) + "; + Query::new(label, cypher) + .param("author_id", author_id) + .param("post_id", post_id) } pub fn post_counts(author_id: &str, post_id: &str) -> Query { - Query::new( - "post_counts", - " - MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) - WITH p - OPTIONAL MATCH (p)<-[t:TAGGED]-() - WITH p, COUNT (t) AS tags_count, COUNT(DISTINCT t.label) AS unique_tags_count - RETURN p IS NOT NULL AS exists, - { - tags: tags_count, - unique_tags: unique_tags_count, - replies: COUNT { (p)<-[:REPLIED]-() }, - reposts: COUNT { (p)<-[:REPOSTED]-() } - } AS counts, + let label = "post_counts"; + let cypher = " + MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) + WITH p + OPTIONAL MATCH (p)<-[t:TAGGED]-() + WITH p, COUNT (t) AS tags_count, COUNT(DISTINCT t.label) AS unique_tags_count + RETURN p IS NOT NULL AS exists, + { + tags: tags_count, + unique_tags: unique_tags_count, + replies: COUNT { (p)<-[:REPLIED]-() }, + reposts: COUNT { (p)<-[:REPOSTED]-() } + } AS counts, EXISTS { (p)-[:REPLIED]->(:Post) } AS is_reply - ", - ) - .param("author_id", author_id) - .param("post_id", post_id) + "; + Query::new(label, cypher) + .param("author_id", author_id) + .param("post_id", post_id) } // Check if the viewer_id has a bookmark in the post pub fn post_bookmark(author_id: &str, post_id: &str, viewer_id: &str) -> Query { - Query::new( - "post_bookmark", - "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) + let label = "post_bookmark"; + let cypher = "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) MATCH (viewer:User {id: $viewer_id})-[b:BOOKMARKED]->(p) - RETURN b", - ) - .param("author_id", author_id) - .param("post_id", post_id) - .param("viewer_id", viewer_id) + RETURN b"; + Query::new(label, cypher) + .param("author_id", author_id) + .param("post_id", post_id) + .param("viewer_id", viewer_id) } // Check all the bookmarks that user creates pub fn user_bookmarks(user_id: &str) -> Query { - Query::new( - "user_bookmarks", - "MATCH (u:User {id: $user_id})-[b:BOOKMARKED]->(p:Post)<-[:AUTHORED]-(author:User) - RETURN b, p.id AS post_id, author.id AS author_id", - ) - .param("user_id", user_id) + let label = "user_bookmarks"; + let cypher = "MATCH (u:User {id: $user_id})-[b:BOOKMARKED]->(p:Post)<-[:AUTHORED]-(author:User) + RETURN b, p.id AS post_id, author.id AS author_id"; + Query::new(label, cypher).param("user_id", user_id) } // Get all the bookmarks that a post has received (used for edit/delete notifications) pub fn get_post_bookmarks(author_id: &str, post_id: &str) -> Query { - Query::new("get_post_bookmarks", - "MATCH (bookmarker:User)-[b:BOOKMARKED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) - RETURN b.id AS bookmark_id, bookmarker.id AS bookmarker_id", - ) - .param("author_id", author_id) - .param("post_id", post_id) + let label = "get_post_bookmarks"; + let cypher = "MATCH (bookmarker:User)-[b:BOOKMARKED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) + RETURN b.id AS bookmark_id, bookmarker.id AS bookmarker_id"; + Query::new(label, cypher) + .param("author_id", author_id) + .param("post_id", post_id) } // Get all the reposts that a post has received (used for edit/delete notifications) pub fn get_post_reposts(author_id: &str, post_id: &str) -> Query { - Query::new("get_post_reposts", - "MATCH (reposter:User)-[:AUTHORED]->(repost:Post)-[:REPOSTED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) - RETURN reposter.id AS reposter_id, repost.id AS repost_id", - ) - .param("author_id", author_id) - .param("post_id", post_id) + let label = "get_post_reposts"; + let cypher = "MATCH (reposter:User)-[:AUTHORED]->(repost:Post)-[:REPOSTED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) + RETURN reposter.id AS reposter_id, repost.id AS repost_id"; + Query::new(label, cypher) + .param("author_id", author_id) + .param("post_id", post_id) } // Get all the replies that a post has received (used for edit/delete notifications) pub fn get_post_replies(author_id: &str, post_id: &str) -> Query { - Query::new("get_post_replies", - "MATCH (replier:User)-[:AUTHORED]->(reply:Post)-[:REPLIED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) - RETURN replier.id AS replier_id, reply.id AS reply_id", - ) - .param("author_id", author_id) - .param("post_id", post_id) + let label = "get_post_replies"; + let cypher = "MATCH (replier:User)-[:AUTHORED]->(reply:Post)-[:REPLIED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) + RETURN replier.id AS replier_id, reply.id AS reply_id"; + Query::new(label, cypher) + .param("author_id", author_id) + .param("post_id", post_id) } // Get all the tags/taggers that a post has received (used for edit/delete notifications) pub fn get_post_tags(author_id: &str, post_id: &str) -> Query { - Query::new("get_post_tags", - "MATCH (tagger:User)-[t:TAGGED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) - RETURN tagger.id AS tagger_id, t.id AS tag_id", - ) - .param("author_id", author_id) - .param("post_id", post_id) + let label = "get_post_tags"; + let cypher = "MATCH (tagger:User)-[t:TAGGED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) + RETURN tagger.id AS tagger_id, t.id AS tag_id"; + Query::new(label, cypher) + .param("author_id", author_id) + .param("post_id", post_id) } pub fn post_relationships(author_id: &str, post_id: &str) -> Query { - Query::new( - "post_relationships", - "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) + let label = "post_relationships"; + let cypher = "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) OPTIONAL MATCH (p)-[:REPLIED]->(replied_post:Post)<-[:AUTHORED]-(replied_author:User) OPTIONAL MATCH (p)-[:REPOSTED]->(reposted_post:Post)<-[:AUTHORED]-(reposted_author:User) OPTIONAL MATCH (p)-[:MENTIONED]->(mentioned_user:User) - RETURN - replied_post.id AS replied_post_id, + RETURN + replied_post.id AS replied_post_id, replied_author.id AS replied_author_id, - reposted_post.id AS reposted_post_id, + reposted_post.id AS reposted_post_id, reposted_author.id AS reposted_author_id, - COLLECT(mentioned_user.id) AS mentioned_user_ids", - ) - .param("author_id", author_id) - .param("post_id", post_id) + COLLECT(mentioned_user.id) AS mentioned_user_ids"; + Query::new(label, cypher) + .param("author_id", author_id) + .param("post_id", post_id) } // Retrieve many users by id // We return also id if not we will not get not found users pub fn get_users_details_by_ids(user_ids: &[&str]) -> Query { - Query::new( - "get_users_details_by_ids", - " + let label = "get_users_details_by_ids"; + let cypher = " UNWIND $ids AS id OPTIONAL MATCH (record:User {id: id}) - RETURN + RETURN id, - CASE - WHEN record IS NOT NULL + CASE + WHEN record IS NOT NULL THEN record ELSE null END AS record - ", - ) - .param("ids", user_ids) + "; + Query::new(label, cypher).param("ids", user_ids) } /// Retrieves unique global tags for posts, returning a list of `post_ids` and `timestamp` pairs for each tag label. pub fn global_tags_by_post() -> Query { - Query::new( - "global_tags_by_post", - " + let label = "global_tags_by_post"; + let cypher = " MATCH (tagger:User)-[t:TAGGED]->(post:Post)<-[:AUTHORED]-(author:User) WITH t.label AS label, author.id + ':' + post.id AS post_id, post.indexed_at AS score WITH DISTINCT post_id, label, score WITH label, COLLECT([toFloat(score), post_id ]) AS sorted_set RETURN label, sorted_set - ", - ) + "; + Query::new(label, cypher) } // TODO: Do not traverse all the graph again to get the engagement score. Rethink how to share that info in the indexer @@ -176,8 +167,8 @@ pub fn global_tags_by_post() -> Query { /// replies, reposts and mentions. The query returns a `key` by combining author's ID /// and post's ID, along with a sorted set of engagement scores for each tag label. pub fn global_tags_by_post_engagement() -> Query { - Query::new("global_tags_by_post_engagement", - " + let label = "global_tags_by_post_engagement"; + let cypher = " MATCH (author:User)-[:AUTHORED]->(post:Post)<-[tag:TAGGED]-(tagger:User) WITH post, COUNT(tag) AS tags_count, tag.label AS label, author.id + ':' + post.id AS key WITH DISTINCT key, label, post, tags_count @@ -190,15 +181,14 @@ pub fn global_tags_by_post_engagement() -> Query { WITH label, COLLECT([toFloat(taggers + replies_count + reposts_count + mention_count), key ]) AS sorted_set RETURN label, sorted_set order by label - " - ) + "; + Query::new(label, cypher) } // Retrieve all the tags of the post pub fn post_tags(user_id: &str, post_id: &str) -> Query { - Query::new( - "post_tags", - " + let label = "post_tags"; + let cypher = " MATCH (u:User {id: $user_id})-[:AUTHORED]->(p:Post {id: $post_id}) CALL { WITH p @@ -210,20 +200,19 @@ pub fn post_tags(user_id: &str, post_id: &str) -> Query { taggers_count: SIZE(tagger_ids) }) AS tags } - RETURN + RETURN u IS NOT NULL AS exists, tags - ", - ) - .param("user_id", user_id) - .param("post_id", post_id) + "; + Query::new(label, cypher) + .param("user_id", user_id) + .param("post_id", post_id) } // Retrieve all the tags of the user pub fn user_tags(user_id: &str) -> Query { - Query::new( - "user_tags", - " + let label = "user_tags"; + let cypher = " MATCH (u:User {id: $user_id}) CALL { WITH u @@ -235,33 +224,29 @@ pub fn user_tags(user_id: &str) -> Query { taggers_count: SIZE(tagger_ids) }) AS tags } - RETURN + RETURN u IS NOT NULL AS exists, tags - ", - ) - .param("user_id", user_id) + "; + Query::new(label, cypher).param("user_id", user_id) } /// Retrieve a homeserver by ID pub fn get_homeserver_by_id(id: &str) -> Query { - Query::new( - "get_homeserver_by_id", - "MATCH (hs:Homeserver {id: $id}) + let label = "get_homeserver_by_id"; + let cypher = "MATCH (hs:Homeserver {id: $id}) WITH hs.id AS id - RETURN id", - ) - .param("id", id) + RETURN id"; + Query::new(label, cypher).param("id", id) } /// Retrieves all homeserver IDs pub fn get_all_homeservers() -> Query { - Query::new( - "get_all_homeservers", - "MATCH (hs:Homeserver) + let label = "get_all_homeservers"; + let cypher = "MATCH (hs:Homeserver) WITH collect(hs.id) AS homeservers_list - RETURN homeservers_list", - ) + RETURN homeservers_list"; + Query::new(label, cypher) } /// Retrieve tags for a user within the viewer's trusted network @@ -308,16 +293,16 @@ pub fn get_viewer_trusted_network_tags(user_id: &str, viewer_id: &str, depth: u8 ); // Add to the query the params - Query::new("get_viewer_trusted_network_tags", graph_query.as_str()) + let label = "get_viewer_trusted_network_tags"; + Query::new(label, graph_query.as_str()) .param("user_id", user_id) .param("viewer_id", viewer_id) } pub fn user_counts(user_id: &str) -> Query { - Query::new( - "user_counts", - " - MATCH (u:User {id: $user_id}) + let label = "user_counts"; + let cypher = " + MATCH (u:User {id: $user_id}) // tags that reference this user OPTIONAL MATCH (u)<-[t:TAGGED]-(:User) WITH u, COUNT(DISTINCT t.label) AS unique_tags, @@ -337,7 +322,7 @@ pub fn user_counts(user_id: &str) -> Query { COUNT { (u)-[:TAGGED]->(:Post) } AS post_tags, COUNT { (:User)-[:TAGGED]->(u) } AS tags - RETURN + RETURN u IS NOT NULL AS exists, { following: following, @@ -350,9 +335,8 @@ pub fn user_counts(user_id: &str) -> Query { unique_tags: unique_tags, bookmarks: bookmarks } AS counts; - ", - ) - .param("user_id", user_id) + "; + Query::new(label, cypher).param("user_id", user_id) } pub fn get_user_followers(user_id: &str, skip: Option, limit: Option) -> Query { @@ -368,7 +352,8 @@ pub fn get_user_followers(user_id: &str, skip: Option, limit: Option, limit: Option) -> Query { @@ -384,7 +369,8 @@ pub fn get_user_following(user_id: &str, skip: Option, limit: Option, limit: Option) -> Query { @@ -400,7 +386,8 @@ pub fn get_user_muted(user_id: &str, skip: Option, limit: Option) if let Some(limit_value) = limit { query_string.push_str(&format!(" LIMIT {limit_value}")); } - Query::new("get_user_muted", &query_string).param("user_id", user_id) + let label = "get_user_muted"; + Query::new(label, &query_string).param("user_id", user_id) } fn stream_reach_to_graph_subquery(reach: &StreamReach) -> String { @@ -417,25 +404,22 @@ fn stream_reach_to_graph_subquery(reach: &StreamReach) -> String { } pub fn get_tags_by_label_prefix(label_prefix: &str) -> Query { - Query::new( - "get_tags_by_label_prefix", - " + let label = "get_tags_by_label_prefix"; + let cypher = " MATCH ()-[t:TAGGED]->() WHERE t.label STARTS WITH $label_prefix RETURN COLLECT(DISTINCT t.label) AS tag_labels - ", - ) - .param("label_prefix", label_prefix) + "; + Query::new(label, cypher).param("label_prefix", label_prefix) } pub fn get_tags() -> Query { - Query::new( - "get_tags", - " + let label = "get_tags"; + let cypher = " MATCH ()-[t:TAGGED]->() RETURN COLLECT(DISTINCT t.label) AS tag_labels - ", - ) + "; + Query::new(label, cypher) } pub fn get_tag_taggers_by_reach( @@ -445,10 +429,9 @@ pub fn get_tag_taggers_by_reach( skip: usize, limit: usize, ) -> Query { - Query::new( - "get_tag_taggers_by_reach", - format!( - " + let query_label = "get_tag_taggers_by_reach"; + let cypher = format!( + " {} // The tagged node can be generic, representing either a Post, a User, or both. // For now, it will be a Post to align with UX requirements. @@ -465,14 +448,13 @@ pub fn get_tag_taggers_by_reach( RETURN COLLECT(row.reach_id) AS tagger_ids ", - stream_reach_to_graph_subquery(&reach) - ) - .as_str(), - ) - .param("label", label) - .param("user_id", user_id) - .param("skip", skip as i64) - .param("limit", limit as i64) + stream_reach_to_graph_subquery(&reach) + ); + Query::new(query_label, &cypher) + .param("label", label) + .param("user_id", user_id) + .param("skip", skip as i64) + .param("limit", limit as i64) } pub fn get_hot_tags_by_reach( @@ -486,14 +468,13 @@ pub fn get_hot_tags_by_reach( }; let (from, to) = tags_query.timeframe.to_timestamp_range(); - Query::new( - "get_hot_tags_by_reach", - format!( - " + let query_label = "get_hot_tags_by_reach"; + let cypher = format!( + " {} MATCH (reach)-[tag:TAGGED]->(tagged:{}) WHERE user.id = $user_id AND tag.indexed_at >= $from AND tag.indexed_at < $to - WITH + WITH tag.label AS label, COLLECT(DISTINCT reach.id)[..{}] AS taggers, COUNT(DISTINCT tagged) AS uniqueTaggedCount, @@ -508,17 +489,16 @@ pub fn get_hot_tags_by_reach( SKIP $skip LIMIT $limit RETURN COLLECT(hot_tag) as hot_tags ", - stream_reach_to_graph_subquery(&reach), - input_tagged_type, - tags_query.taggers_limit - ) - .as_str(), - ) - .param("user_id", user_id) - .param("skip", tags_query.skip as i64) - .param("limit", tags_query.limit as i64) - .param("from", from) - .param("to", to) + stream_reach_to_graph_subquery(&reach), + input_tagged_type, + tags_query.taggers_limit + ); + Query::new(query_label, &cypher) + .param("user_id", user_id) + .param("skip", tags_query.skip as i64) + .param("limit", tags_query.limit as i64) + .param("from", from) + .param("to", to) } pub fn get_global_hot_tags(tags_query: &HotTagsInputDTO) -> Query { @@ -527,13 +507,12 @@ pub fn get_global_hot_tags(tags_query: &HotTagsInputDTO) -> Query { None => String::from("Post|User"), }; let (from, to) = tags_query.timeframe.to_timestamp_range(); - Query::new( - "get_global_hot_tags", - format!( - " - MATCH (user: User)-[tag:TAGGED]->(tagged:{}) + let label = "get_global_hot_tags"; + let cypher = format!( + " + MATCH (user: User)-[tag:TAGGED]->(tagged:{}) WHERE tag.indexed_at >= $from AND tag.indexed_at < $to - WITH + WITH tag.label AS label, COLLECT(DISTINCT user.id)[..{}] AS taggers, COUNT(DISTINCT tagged) AS uniqueTaggedCount, @@ -548,14 +527,13 @@ pub fn get_global_hot_tags(tags_query: &HotTagsInputDTO) -> Query { SKIP $skip LIMIT $limit RETURN COLLECT(hot_tag) as hot_tags ", - input_tagged_type, tags_query.taggers_limit - ) - .as_str(), - ) - .param("skip", tags_query.skip as i64) - .param("limit", tags_query.limit as i64) - .param("from", from) - .param("to", to) + input_tagged_type, tags_query.taggers_limit + ); + Query::new(label, &cypher) + .param("skip", tags_query.skip as i64) + .param("limit", tags_query.limit as i64) + .param("from", from) + .param("to", to) } pub fn get_influencers_by_reach( @@ -566,10 +544,9 @@ pub fn get_influencers_by_reach( timeframe: &Timeframe, ) -> Query { let (from, to) = timeframe.to_timestamp_range(); - Query::new( - "get_influencers_by_reach", - format!( - " + let label = "get_influencers_by_reach"; + let cypher = format!( + " {} WHERE user.id = $user_id WITH DISTINCT reach @@ -596,26 +573,24 @@ pub fn get_influencers_by_reach( score: (tags_count + posts_count) * sqrt(followers_count) }} AS influencer ORDER BY influencer.score DESC - SKIP $skip + SKIP $skip LIMIT $limit RETURN COLLECT([influencer.id, influencer.score]) as influencers ", - stream_reach_to_graph_subquery(&reach), - ) - .as_str(), - ) - .param("user_id", user_id) - .param("skip", skip as i64) - .param("limit", limit as i64) - .param("from", from) - .param("to", to) + stream_reach_to_graph_subquery(&reach), + ); + Query::new(label, &cypher) + .param("user_id", user_id) + .param("skip", skip as i64) + .param("limit", limit as i64) + .param("from", from) + .param("to", to) } pub fn get_global_influencers(skip: usize, limit: usize, timeframe: &Timeframe) -> Query { let (from, to) = timeframe.to_timestamp_range(); - Query::new( - "get_global_influencers", - " + let label = "get_global_influencers"; + let cypher = " MATCH (user:User) WHERE user.name <> '[DELETED]' WITH DISTINCT user @@ -625,7 +600,7 @@ pub fn get_global_influencers(skip: usize, limit: usize, timeframe: &Timeframe) OPTIONAL MATCH (user)-[tag:TAGGED]->(tagged:Post) WHERE tag.indexed_at >= $from AND tag.indexed_at < $to - + OPTIONAL MATCH (user)-[authored:AUTHORED]->(post:Post) WHERE authored.indexed_at >= $from AND authored.indexed_at < $to @@ -636,29 +611,27 @@ pub fn get_global_influencers(skip: usize, limit: usize, timeframe: &Timeframe) score: (tags_count + posts_count) * sqrt(followers_count) } AS influencer WHERE influencer.id IS NOT NULL - + ORDER BY influencer.score DESC, influencer.id ASC - SKIP $skip + SKIP $skip LIMIT $limit RETURN COLLECT([influencer.id, influencer.score]) as influencers - ", - ) - .param("skip", skip as i64) - .param("limit", limit as i64) - .param("from", from) - .param("to", to) + "; + Query::new(label, cypher) + .param("skip", skip as i64) + .param("limit", limit as i64) + .param("from", from) + .param("to", to) } pub fn get_files_by_ids(key_pair: &[&[&str]]) -> Query { - Query::new( - "get_files_by_ids", - " + let label = "get_files_by_ids"; + let cypher = " UNWIND $pairs AS pair OPTIONAL MATCH (record:File {owner_id: pair[0], id: pair[1]}) RETURN record - ", - ) - .param("pairs", key_pair) + "; + Query::new(label, cypher).param("pairs", key_pair) } // Build the graph query based on parameters @@ -855,7 +828,8 @@ fn build_query_with_params( kind: Option, pagination: &Pagination, ) -> Query { - let mut query = Query::new("post_stream", cypher); + let label = "post_stream"; + let mut query = Query::new(label, cypher); if let Some(observer_id) = source.get_observer() { query = query.param("observer_id", observer_id.to_string()); @@ -883,18 +857,16 @@ fn build_query_with_params( /// # Arguments /// * `user_id` - The unique identifier of the user pub fn user_is_safe_to_delete(user_id: &str) -> Query { - Query::new( - "user_is_safe_to_delete", - " + let label = "user_is_safe_to_delete"; + let cypher = " MATCH (u:User {id: $user_id}) // Ensures all relationships to the user (u) are checked, counting as 0 if none exist OPTIONAL MATCH (u)-[r]-() // Checks if the user has any relationships WITH u, NOT (COUNT(r) = 0) AS flag RETURN flag - ", - ) - .param("user_id", user_id) + "; + Query::new(label, cypher).param("user_id", user_id) } /// Checks if a post has any relationships that aren't in the set of allowed @@ -905,9 +877,8 @@ pub fn user_is_safe_to_delete(user_id: &str) -> Query { /// * `author_id` - The unique identifier of the user who authored the post /// * `post_id` - The unique identifier of the post pub fn post_is_safe_to_delete(author_id: &str, post_id: &str) -> Query { - Query::new( - "post_is_safe_to_delete", - " + let label = "post_is_safe_to_delete"; + let cypher = " MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) // Ensures all relationships to the post (p) are checked, counting as 0 if none exist OPTIONAL MATCH (p)-[r]-() @@ -925,18 +896,17 @@ pub fn post_is_safe_to_delete(author_id: &str, post_id: &str) -> Query { // Checks if any disallowed relationships exist for the post WITH p, NOT (COUNT(r) = 0) AS flag RETURN flag - ", - ) - .param("author_id", author_id) - .param("post_id", post_id) + "; + Query::new(label, cypher) + .param("author_id", author_id) + .param("post_id", post_id) } /// Find user recommendations: active users (with 5+ posts) who are 1-3 degrees of separation away /// from the given user, but not directly followed by them pub fn recommend_users(user_id: &str, limit: usize) -> Query { - Query::new( - "recommend_users", - " + let label = "recommend_users"; + let cypher = " MATCH (user:User {id: $user_id}) MATCH (user)-[:FOLLOWS*1..3]->(potential:User) WHERE NOT (user)-[:FOLLOWS]->(potential) @@ -947,17 +917,16 @@ pub fn recommend_users(user_id: &str, limit: usize) -> Query { WHERE post_count >= 5 RETURN potential.id AS recommended_user_id, potential.name AS recommended_user_name LIMIT $limit - ", - ) - .param("user_id", user_id.to_string()) - .param("limit", limit as i64) + "; + Query::new(label, cypher) + .param("user_id", user_id.to_string()) + .param("limit", limit as i64) } /// Retrieve specific tag created by the user pub fn get_tag_by_tagger_and_id(tagger_id: &str, tag_id: &str) -> Query { - Query::new( - "get_tag_by_tagger_and_id", - " + let label = "get_tag_by_tagger_and_id"; + let cypher = " MATCH (tagger:User { id: $tagger_id})-[tag:TAGGED {id: $tag_id }]->(tagged) OPTIONAL MATCH (author:User)-[:AUTHORED]->(tagged) RETURN @@ -967,8 +936,8 @@ pub fn get_tag_by_tagger_and_id(tagger_id: &str, tag_id: &str) -> Query { tag.id as id, tag.indexed_at as indexed_at, tag.label as label - ", - ) - .param("tagger_id", tagger_id) - .param("tag_id", tag_id) + "; + Query::new(label, cypher) + .param("tagger_id", tagger_id) + .param("tag_id", tag_id) } diff --git a/nexus-common/src/db/graph/queries/put.rs b/nexus-common/src/db/graph/queries/put.rs index 637420f71..eb647bbf0 100644 --- a/nexus-common/src/db/graph/queries/put.rs +++ b/nexus-common/src/db/graph/queries/put.rs @@ -9,17 +9,17 @@ pub fn create_user(user: &UserDetails) -> GraphResult { let links = serde_json::to_string(&user.links) .map_err(|e| GraphError::SerializationFailed(Box::new(e)))?; - let query = Query::new("create_user", - "MERGE (u:User {id: $id}) - SET u.name = $name, u.bio = $bio, u.status = $status, u.links = $links, u.image = $image, u.indexed_at = $indexed_at;", - ) - .param("id", user.id.to_string()) - .param("name", user.name.clone()) - .param("bio", user.bio.clone()) - .param("status", user.status.clone()) - .param("links", links) - .param("image", user.image.clone()) - .param("indexed_at", user.indexed_at); + let label = "create_user"; + let cypher = "MERGE (u:User {id: $id}) + SET u.name = $name, u.bio = $bio, u.status = $status, u.links = $links, u.image = $image, u.indexed_at = $indexed_at;"; + let query = Query::new(label, cypher) + .param("id", user.id.to_string()) + .param("name", user.name.clone()) + .param("bio", user.bio.clone()) + .param("status", user.status.clone()) + .param("links", links) + .param("image", user.image.clone()) + .param("indexed_at", user.indexed_at); Ok(query) } @@ -75,7 +75,8 @@ pub fn create_post( let kind = serde_json::to_string(&post.kind) .map_err(|e| GraphError::SerializationFailed(Box::new(e)))?; - let mut cypher_query = Query::new("create_post", &cypher) + let label = "create_post"; + let mut cypher_query = Query::new(label, &cypher) .param("author_id", post.author.to_string()) .param("post_id", post.id.to_string()) .param("content", post.content.to_string()) @@ -143,15 +144,14 @@ pub fn create_mention_relationship( post_id: &str, mentioned_user_id: &str, ) -> Query { - Query::new( - "create_mention_relationship", - "MATCH (author:User {id: $author_id})-[:AUTHORED]->(post:Post {id: $post_id}), + let label = "create_mention_relationship"; + let cypher = "MATCH (author:User {id: $author_id})-[:AUTHORED]->(post:Post {id: $post_id}), (mentioned_user:User {id: $mentioned_user_id}) - MERGE (post)-[:MENTIONED]->(mentioned_user)", - ) - .param("author_id", author_id) - .param("post_id", post_id) - .param("mentioned_user_id", mentioned_user_id) + MERGE (post)-[:MENTIONED]->(mentioned_user)"; + Query::new(label, cypher) + .param("author_id", author_id) + .param("post_id", post_id) + .param("mentioned_user_id", mentioned_user_id) } /// Create a follows relationship between two users. Before creating the relationship, @@ -162,19 +162,18 @@ pub fn create_mention_relationship( /// * `followee_id` - The unique identifier of the user to be followed. /// * `indexed_at` - A timestamp representing when the relationship was indexed or updated. pub fn create_follow(follower_id: &str, followee_id: &str, indexed_at: i64) -> Query { - Query::new( - "create_follow", - "MATCH (follower:User {id: $follower_id}), (followee:User {id: $followee_id}) + let label = "create_follow"; + let cypher = "MATCH (follower:User {id: $follower_id}), (followee:User {id: $followee_id}) // Check if follow already existed OPTIONAL MATCH (follower)-[existing:FOLLOWS]->(followee) MERGE (follower)-[r:FOLLOWS]->(followee) SET r.indexed_at = $indexed_at // Returns true if the follow relationship already existed - RETURN existing IS NOT NULL AS flag;", - ) - .param("follower_id", follower_id.to_string()) - .param("followee_id", followee_id.to_string()) - .param("indexed_at", indexed_at) + RETURN existing IS NOT NULL AS flag;"; + Query::new(label, cypher) + .param("follower_id", follower_id.to_string()) + .param("followee_id", followee_id.to_string()) + .param("indexed_at", indexed_at) } /// Creates a `MUTED` relationship between a user and another user they wish to mute @@ -183,19 +182,18 @@ pub fn create_follow(follower_id: &str, followee_id: &str, indexed_at: i64) -> Q /// * `muted_id` - The unique identifier of the user to be muted. /// * `indexed_at` - A timestamp indicating when the relationship was created or last updated. pub fn create_mute(user_id: &str, muted_id: &str, indexed_at: i64) -> Query { - Query::new( - "create_mute", - "MATCH (user:User {id: $user_id}), (muted:User {id: $muted_id}) + let label = "create_mute"; + let cypher = "MATCH (user:User {id: $user_id}), (muted:User {id: $muted_id}) // Check if follow already existed OPTIONAL MATCH (user)-[existing:MUTED]->(muted) MERGE (user)-[r:MUTED]->(muted) SET r.indexed_at = $indexed_at // Returns true if the mute relationship already existed - RETURN existing IS NOT NULL AS flag;", - ) - .param("user_id", user_id.to_string()) - .param("muted_id", muted_id.to_string()) - .param("indexed_at", indexed_at) + RETURN existing IS NOT NULL AS flag;"; + Query::new(label, cypher) + .param("user_id", user_id.to_string()) + .param("muted_id", muted_id.to_string()) + .param("indexed_at", indexed_at) } /// Creates a "BOOKMARKED" relationship between a user and a post authored by another user @@ -212,9 +210,8 @@ pub fn create_post_bookmark( bookmark_id: &str, indexed_at: i64, ) -> Query { - Query::new( - "create_post_bookmark", - "MATCH (u:User {id: $user_id}) + let label = "create_post_bookmark"; + let cypher = "MATCH (u:User {id: $user_id}) // We assume these nodes are already created. If not we would not be able to add a bookmark MATCH (author:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) // Check if bookmark already existed @@ -223,13 +220,13 @@ pub fn create_post_bookmark( SET b.indexed_at = $indexed_at, b.id = $bookmark_id // Returns true if the bookmark relationship already existed - RETURN existing IS NOT NULL AS flag;", - ) - .param("user_id", user_id) - .param("author_id", author_id) - .param("post_id", post_id) - .param("bookmark_id", bookmark_id) - .param("indexed_at", indexed_at) + RETURN existing IS NOT NULL AS flag;"; + Query::new(label, cypher) + .param("user_id", user_id) + .param("author_id", author_id) + .param("post_id", post_id) + .param("bookmark_id", bookmark_id) + .param("indexed_at", indexed_at) } /// Creates a `TAGGED` relationship between a user and a post authored by another user. The tag is uniquely @@ -250,9 +247,8 @@ pub fn create_post_tag( label: &str, indexed_at: i64, ) -> Query { - Query::new( - "create_post_tag", - "MATCH (user:User {id: $user_id}) + let query_label = "create_post_tag"; + let cypher = "MATCH (user:User {id: $user_id}) // We assume these nodes are already created. If not we would not be able to add a tag MATCH (author:User {id: $author_id})-[:AUTHORED]->(post:Post {id: $post_id}) // Check if tag already existed @@ -261,14 +257,14 @@ pub fn create_post_tag( ON CREATE SET t.indexed_at = $indexed_at, t.id = $tag_id // Returns true if the post tag relationship already existed - RETURN existing IS NOT NULL AS flag;", - ) - .param("user_id", user_id) - .param("author_id", author_id) - .param("post_id", post_id) - .param("tag_id", tag_id) - .param("label", label) - .param("indexed_at", indexed_at) + RETURN existing IS NOT NULL AS flag;"; + Query::new(query_label, cypher) + .param("user_id", user_id) + .param("author_id", author_id) + .param("post_id", post_id) + .param("tag_id", tag_id) + .param("label", label) + .param("indexed_at", indexed_at) } /// Creates a `TAGGED` relationship between two users. The relationship is uniquely identified by a `label` @@ -285,9 +281,8 @@ pub fn create_user_tag( label: &str, indexed_at: i64, ) -> Query { - Query::new( - "create_user_tag", - "MATCH (tagged_used:User {id: $tagged_user_id}) + let query_label = "create_user_tag"; + let cypher = "MATCH (tagged_used:User {id: $tagged_user_id}) MATCH (tagger:User {id: $tagger_user_id}) // Check if tag already existed OPTIONAL MATCH (tagger)-[existing:TAGGED {label: $label}]->(tagged_used) @@ -295,13 +290,13 @@ pub fn create_user_tag( ON CREATE SET t.indexed_at = $indexed_at, t.id = $tag_id // Returns true if the user tag relationship already existed - RETURN existing IS NOT NULL AS flag;", - ) - .param("tagger_user_id", tagger_user_id) - .param("tagged_user_id", tagged_user_id) - .param("tag_id", tag_id) - .param("label", label) - .param("indexed_at", indexed_at) + RETURN existing IS NOT NULL AS flag;"; + Query::new(query_label, cypher) + .param("tagger_user_id", tagger_user_id) + .param("tagged_user_id", tagged_user_id) + .param("tag_id", tag_id) + .param("label", label) + .param("indexed_at", indexed_at) } /// Create a file node @@ -309,34 +304,31 @@ pub fn create_file(file: &FileDetails) -> GraphResult { let urls = serde_json::to_string(&file.urls) .map_err(|e| GraphError::SerializationFailed(Box::new(e)))?; - let query = Query::new( - "create_file", - "MERGE (f:File {id: $id, owner_id: $owner_id}) + let label = "create_file"; + let cypher = "MERGE (f:File {id: $id, owner_id: $owner_id}) SET f.uri = $uri, f.indexed_at = $indexed_at, f.created_at = $created_at, f.size = $size, - f.src = $src, f.name = $name, f.content_type = $content_type, f.urls = $urls;", - ) - .param("id", file.id.to_string()) - .param("owner_id", file.owner_id.to_string()) - .param("uri", file.uri.to_string()) - .param("indexed_at", file.indexed_at) - .param("created_at", file.created_at) - .param("size", file.size) - .param("src", file.src.to_string()) - .param("name", file.name.to_string()) - .param("content_type", file.content_type.to_string()) - .param("urls", urls); + f.src = $src, f.name = $name, f.content_type = $content_type, f.urls = $urls;"; + let query = Query::new(label, cypher) + .param("id", file.id.to_string()) + .param("owner_id", file.owner_id.to_string()) + .param("uri", file.uri.to_string()) + .param("indexed_at", file.indexed_at) + .param("created_at", file.created_at) + .param("size", file.size) + .param("src", file.src.to_string()) + .param("name", file.name.to_string()) + .param("content_type", file.content_type.to_string()) + .param("urls", urls); Ok(query) } /// Create a homeserver pub fn create_homeserver(homeserver_id: &str) -> Query { - Query::new( - "create_homeserver", - "MERGE (hs:Homeserver { + let label = "create_homeserver"; + let cypher = "MERGE (hs:Homeserver { id: $id }) - RETURN hs;", - ) - .param("id", homeserver_id) + RETURN hs;"; + Query::new(label, cypher).param("id", homeserver_id) } diff --git a/nexus-common/src/db/graph/setup.rs b/nexus-common/src/db/graph/setup.rs index 090a9cbbd..97c2f0912 100644 --- a/nexus-common/src/db/graph/setup.rs +++ b/nexus-common/src/db/graph/setup.rs @@ -30,7 +30,8 @@ pub async fn setup_graph() -> GraphResult<()> { let graph = get_neo4j_graph()?; for &ddl in queries { - graph.run(Query::new("setup_ddl", ddl)).await.map_err(|e| { + let label = "setup_ddl"; + graph.run(Query::new(label, ddl)).await.map_err(|e| { GraphError::Generic(format!( "Failed to apply graph constraint/index '{ddl}': {e}" )) diff --git a/nexus-common/src/db/reindex.rs b/nexus-common/src/db/reindex.rs index e028a69e5..87b7aa12d 100644 --- a/nexus-common/src/db/reindex.rs +++ b/nexus-common/src/db/reindex.rs @@ -101,7 +101,9 @@ pub async fn reindex_post(author_id: &str, post_id: &str) -> Result<(), DynError } pub async fn get_all_user_ids() -> Result, DynError> { - let query = Query::new("get_all_user_ids", "MATCH (u:User) RETURN u.id AS id"); + let label = "get_all_user_ids"; + let cypher = "MATCH (u:User) RETURN u.id AS id"; + let query = Query::new(label, cypher); let rows = fetch_all_rows_from_graph(query).await?; let mut user_ids = Vec::new(); @@ -115,10 +117,9 @@ pub async fn get_all_user_ids() -> Result, DynError> { } async fn get_all_post_ids() -> Result, DynError> { - let query = Query::new( - "get_all_post_ids", - "MATCH (u:User)-[:AUTHORED]->(p:Post) RETURN u.id AS author_id, p.id AS post_id", - ); + let label = "get_all_post_ids"; + let cypher = "MATCH (u:User)-[:AUTHORED]->(p:Post) RETURN u.id AS author_id, p.id AS post_id"; + let query = Query::new(label, cypher); let rows = fetch_all_rows_from_graph(query).await?; let mut post_ids = Vec::new(); diff --git a/nexus-webapi/src/mock.rs b/nexus-webapi/src/mock.rs index c46d9f4d9..8bdeb4012 100644 --- a/nexus-webapi/src/mock.rs +++ b/nexus-webapi/src/mock.rs @@ -56,7 +56,9 @@ impl MockDb { let graph = get_neo4j_graph().expect("Failed to get Neo4j graph connection"); // drop and run the queries again - let drop_all_query = Query::new("drop_graph", "MATCH (n) DETACH DELETE n;"); + let label = "drop_graph"; + let cypher = "MATCH (n) DETACH DELETE n;"; + let drop_all_query = Query::new(label, cypher); graph .run(drop_all_query) .await From ff0ae39da01232de435141e74d7a067322a87ac8 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Wed, 4 Mar 2026 09:01:07 +0100 Subject: [PATCH 23/34] GraphExec -> GraphOps --- nexus-common/src/db/connectors/neo4j.rs | 10 +++++----- nexus-common/src/db/graph/mod.rs | 2 +- nexus-common/src/db/graph/traced.rs | 8 ++++---- nexus-common/src/db/mod.rs | 2 +- nexusd/src/migrations/manager.rs | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/nexus-common/src/db/connectors/neo4j.rs b/nexus-common/src/db/connectors/neo4j.rs index 4af985e61..d2a994a26 100644 --- a/nexus-common/src/db/connectors/neo4j.rs +++ b/nexus-common/src/db/connectors/neo4j.rs @@ -5,13 +5,13 @@ use std::time::Duration; use tracing::{debug, info}; use crate::db::graph::error::{GraphError, GraphResult}; -use crate::db::graph::{Graph, GraphExec, TracedGraph}; +use crate::db::graph::{Graph, GraphOps, TracedGraph}; use crate::db::setup::setup_graph; use crate::db::Neo4JConfig; use crate::types::DynError; pub struct Neo4jConnector { - graph: Arc, + graph: Arc, } impl Neo4jConnector { @@ -36,7 +36,7 @@ impl Neo4jConnector { let neo4j_graph = neo4rs::Graph::new(&config.uri, &config.user, &config.password).await?; let graph = Graph::new(neo4j_graph); - let graph: Arc = if config.slow_query_logging { + let graph: Arc = if config.slow_query_logging { let threshold = Duration::from_millis(config.slow_query_threshold_ms); Arc::new( TracedGraph::new(graph) @@ -67,13 +67,13 @@ impl Neo4jConnector { impl fmt::Debug for Neo4jConnector { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Neo4jConnector") - .field("graph", &"GraphExec instance") + .field("graph", &"GraphOps instance") .finish() } } /// Helper to retrieve a Neo4j graph connection. -pub fn get_neo4j_graph() -> GraphResult> { +pub fn get_neo4j_graph() -> GraphResult> { NEO4J_CONNECTOR .get() .ok_or(GraphError::ConnectionNotInitialized) diff --git a/nexus-common/src/db/graph/mod.rs b/nexus-common/src/db/graph/mod.rs index 8efb138b7..d8860d3cd 100644 --- a/nexus-common/src/db/graph/mod.rs +++ b/nexus-common/src/db/graph/mod.rs @@ -7,5 +7,5 @@ mod traced; pub use error::{GraphError, GraphResult}; pub use query::{query, Query}; -pub use traced::GraphExec; +pub use traced::GraphOps; pub(crate) use traced::{Graph, TracedGraph}; diff --git a/nexus-common/src/db/graph/traced.rs b/nexus-common/src/db/graph/traced.rs index 22e029bd9..65005bb68 100644 --- a/nexus-common/src/db/graph/traced.rs +++ b/nexus-common/src/db/graph/traced.rs @@ -13,7 +13,7 @@ use crate::db::config::DEFAULT_SLOW_QUERY_THRESHOLD_MS; /// Abstraction over graph database operations. /// Callers depend on this trait, not the concrete implementations. #[async_trait] -pub trait GraphExec: Send + Sync { +pub trait GraphOps: Send + Sync { /// Execute query, return boxed row stream. async fn execute( &self, @@ -24,7 +24,7 @@ pub trait GraphExec: Send + Sync { async fn run(&self, query: Query) -> neo4rs::Result<()>; } -/// Thin wrapper around `neo4rs::Graph` implementing `GraphExec` without tracing. +/// Thin wrapper around `neo4rs::Graph` implementing `GraphOps` without tracing. #[derive(Clone)] pub struct Graph { inner: neo4rs::Graph, @@ -37,7 +37,7 @@ impl Graph { } #[async_trait] -impl GraphExec for Graph { +impl GraphOps for Graph { async fn execute( &self, query: Query, @@ -143,7 +143,7 @@ impl TracedGraph { } #[async_trait] -impl GraphExec for TracedGraph { +impl GraphOps for TracedGraph { async fn execute( &self, query: Query, diff --git a/nexus-common/src/db/mod.rs b/nexus-common/src/db/mod.rs index 977ae983d..604e14961 100644 --- a/nexus-common/src/db/mod.rs +++ b/nexus-common/src/db/mod.rs @@ -13,5 +13,5 @@ pub use graph::error::{GraphError, GraphResult}; pub use graph::exec::*; pub use graph::queries; pub use graph::setup; -pub use graph::GraphExec; +pub use graph::GraphOps; pub use kv::RedisOps; diff --git a/nexusd/src/migrations/manager.rs b/nexusd/src/migrations/manager.rs index 779441960..aa6f4705b 100644 --- a/nexusd/src/migrations/manager.rs +++ b/nexusd/src/migrations/manager.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use chrono::Utc; use futures::TryStreamExt; use nexus_common::{ - db::{get_neo4j_graph, graph::Query, GraphExec}, + db::{get_neo4j_graph, graph::Query, GraphOps}, types::DynError, }; use serde::{Deserialize, Serialize}; @@ -90,7 +90,7 @@ pub struct MigrationNode { const MIGRATION_PATH: &str = "nexusd/src/migrations/migrations_list/"; pub struct MigrationManager { - graph: Arc, + graph: Arc, migrations: Vec>, } From 7ed22b6675ff16d55af7159c387592cd9badbfd6 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Wed, 4 Mar 2026 09:04:12 +0100 Subject: [PATCH 24/34] add comment --- nexus-common/src/db/graph/query.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nexus-common/src/db/graph/query.rs b/nexus-common/src/db/graph/query.rs index 1394372fd..c8ba395c9 100644 --- a/nexus-common/src/db/graph/query.rs +++ b/nexus-common/src/db/graph/query.rs @@ -60,6 +60,12 @@ pub fn populate_cypher(cypher: &str, params: &BoltMap) -> String { let mut out = cypher.to_owned(); // Sort keys by length descending so `$skip` is replaced before a // hypothetical `$s`, avoiding partial substitutions. + // + // NOTE: There is still a potential issue with this approach: if a + // parameter *value* happens to contain text matching another parameter + // name (e.g. param "a" has value "$b"), a later replacement pass will + // substitute inside the already-replaced value. A proper fix would + // require single-pass replacement or placeholder-based substitution. let mut entries: Vec<_> = params.value.iter().collect(); entries.sort_by(|a, b| b.0.value.len().cmp(&a.0.value.len())); for (k, v) in entries { From 5e622c7c628dcc0d8c504edf31a7f649ad9e7ea7 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Wed, 4 Mar 2026 09:12:27 +0100 Subject: [PATCH 25/34] dynamic query post stream label --- nexus-common/src/db/graph/queries/get.rs | 26 ++++++++++++++---------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/nexus-common/src/db/graph/queries/get.rs b/nexus-common/src/db/graph/queries/get.rs index fc7227123..f5e630e8f 100644 --- a/nexus-common/src/db/graph/queries/get.rs +++ b/nexus-common/src/db/graph/queries/get.rs @@ -787,7 +787,18 @@ pub fn post_stream( } // Build the query and apply parameters using `param` method - build_query_with_params(&cypher, &source, tags, kind, &pagination) + let label = match &source { + StreamSource::Following { .. } => "post_stream_following", + StreamSource::Followers { .. } => "post_stream_followers", + StreamSource::Friends { .. } => "post_stream_friends", + StreamSource::Bookmarks { .. } => "post_stream_bookmarks", + StreamSource::Author { .. } => "post_stream_author", + StreamSource::AuthorReplies { .. } => "post_stream_author_replies", + StreamSource::PostReplies { .. } => "post_stream_post_replies", + StreamSource::All => "post_stream_all", + }; + let query = Query::new(label, &cypher); + build_query_with_params(query, &source, tags, kind, &pagination) } /// Appends a condition to the Cypher query, using `WHERE` if no `WHERE` clause @@ -808,29 +819,22 @@ fn append_condition(cypher: &mut String, condition: &str, where_clause_applied: } } -/// Builds a `Query` object by applying the necessary parameters to the Cypher query string. -/// -/// This function takes the constructed Cypher query string and applies all the relevant parameters -/// based on the provided `source`, `tags`, `kind`, and `pagination`. It ensures that all parameters -/// used in the query are properly set with their corresponding values. +/// Applies the necessary parameters to an already-constructed `Query`. /// /// # Arguments /// -/// * `cypher` - The Cypher query string that has been constructed. +/// * `query` - A `Query` already constructed with its label and cypher string. /// * `source` - The `StreamSource` specifying the origin of the posts (e.g., Following, Followers). /// * `tags` - An optional list of tag labels to filter the posts. /// * `kind` - An optional `PubkyAppPostKind` to filter the posts by their kind. /// * `pagination` - The `Pagination` object containing pagination parameters like `start`, `end`, `skip`, and `limit`. fn build_query_with_params( - cypher: &str, + mut query: Query, source: &StreamSource, tags: &Option>, kind: Option, pagination: &Pagination, ) -> Query { - let label = "post_stream"; - let mut query = Query::new(label, cypher); - if let Some(observer_id) = source.get_observer() { query = query.param("observer_id", observer_id.to_string()); } From b405147eccbe1ecac7a012d360b5654917eac80b Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Wed, 4 Mar 2026 09:21:48 +0100 Subject: [PATCH 26/34] reused start --- nexus-common/src/db/graph/traced.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus-common/src/db/graph/traced.rs b/nexus-common/src/db/graph/traced.rs index 65005bb68..a66e7da0f 100644 --- a/nexus-common/src/db/graph/traced.rs +++ b/nexus-common/src/db/graph/traced.rs @@ -163,7 +163,7 @@ impl GraphOps for TracedGraph { label, cypher, execute_duration, - stream_start: Instant::now(), + stream_start: start, row_count: 0, threshold: self.slow_query_threshold, }; From e7d36b034f6f1294179e56d69e2d9a07607a80ad Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Wed, 4 Mar 2026 09:32:46 +0100 Subject: [PATCH 27/34] rename config var names --- examples/api/api-config.toml | 4 ++-- examples/watcher/watcher-config.toml | 4 ++-- nexus-common/default.config.toml | 2 +- nexus-common/src/db/config/neo4j.rs | 22 +++++++++++----------- nexus-common/src/db/connectors/neo4j.rs | 6 +++--- nexus-common/src/db/graph/traced.rs | 2 +- nexus-webapi/src/mock.rs | 2 +- nexusd/src/migrations/default.config.toml | 4 ++-- 8 files changed, 23 insertions(+), 23 deletions(-) diff --git a/examples/api/api-config.toml b/examples/api/api-config.toml index 123a377ee..823c82b6b 100644 --- a/examples/api/api-config.toml +++ b/examples/api/api-config.toml @@ -20,6 +20,6 @@ uri = "bolt://localhost:7687" #user = "neo4j" password = "12345678" # Queries taking longer than this (ms) will be logged as warnings -slow_query_threshold_ms = 100 +slow_query_logging_threshold_ms = 100 # Include the full cypher (with interpolated params) in slow-query warnings -#log_slow_query_cypher = false +#slow_query_logging_include_cypher = false diff --git a/examples/watcher/watcher-config.toml b/examples/watcher/watcher-config.toml index 6e12a6a4c..a94796fc9 100644 --- a/examples/watcher/watcher-config.toml +++ b/examples/watcher/watcher-config.toml @@ -32,6 +32,6 @@ uri = "bolt://localhost:7687" #user = "neo4j" password = "12345678" # Queries taking longer than this (ms) will be logged as warnings -slow_query_threshold_ms = 100 +slow_query_logging_threshold_ms = 100 # Include the full cypher (with interpolated params) in slow-query warnings -#log_slow_query_cypher = false +#slow_query_logging_include_cypher = false diff --git a/nexus-common/default.config.toml b/nexus-common/default.config.toml index ed4ff7d05..48b6e3249 100644 --- a/nexus-common/default.config.toml +++ b/nexus-common/default.config.toml @@ -52,4 +52,4 @@ uri = "bolt://localhost:7687" #user = "neo4j" password = "12345678" # Queries taking longer than this (ms) will be logged as warnings -slow_query_threshold_ms = 100 +slow_query_logging_threshold_ms = 100 diff --git a/nexus-common/src/db/config/neo4j.rs b/nexus-common/src/db/config/neo4j.rs index 4af63a857..ef15950f3 100644 --- a/nexus-common/src/db/config/neo4j.rs +++ b/nexus-common/src/db/config/neo4j.rs @@ -16,30 +16,30 @@ pub struct Neo4JConfig { pub password: String, /// Queries exceeding this threshold (in milliseconds) are logged as warnings. - /// Only used when `slow_query_logging` is enabled. - #[serde(default = "default_slow_query_threshold_ms")] - pub slow_query_threshold_ms: u64, + /// Only used when `slow_query_logging_enabled` is true. + #[serde(default = "default_slow_query_logging_threshold_ms")] + pub slow_query_logging_threshold_ms: u64, /// Enable slow-query logging. Defaults to true. /// Set to false for CLI/admin commands where tracing overhead is unnecessary. - #[serde(default = "default_slow_query_logging")] - pub slow_query_logging: bool, + #[serde(default = "default_slow_query_logging_enabled")] + pub slow_query_logging_enabled: bool, /// Include the full cypher (with interpolated params) in slow-query warnings. /// Useful for debugging but verbose. Defaults to false. #[serde(default)] - pub log_slow_query_cypher: bool, + pub slow_query_logging_include_cypher: bool, } fn default_neo4j_user() -> String { String::from("neo4j") } -fn default_slow_query_threshold_ms() -> u64 { +fn default_slow_query_logging_threshold_ms() -> u64 { DEFAULT_SLOW_QUERY_THRESHOLD_MS } -fn default_slow_query_logging() -> bool { +fn default_slow_query_logging_enabled() -> bool { true } @@ -49,9 +49,9 @@ impl Default for Neo4JConfig { uri: String::from(NEO4J_URI), user: String::from(NEO4J_USER), password: String::from(NEO4J_PASS), - slow_query_threshold_ms: DEFAULT_SLOW_QUERY_THRESHOLD_MS, - slow_query_logging: true, - log_slow_query_cypher: false, + slow_query_logging_threshold_ms: DEFAULT_SLOW_QUERY_THRESHOLD_MS, + slow_query_logging_enabled: true, + slow_query_logging_include_cypher: false, } } } diff --git a/nexus-common/src/db/connectors/neo4j.rs b/nexus-common/src/db/connectors/neo4j.rs index d2a994a26..d0fea2ed6 100644 --- a/nexus-common/src/db/connectors/neo4j.rs +++ b/nexus-common/src/db/connectors/neo4j.rs @@ -36,12 +36,12 @@ impl Neo4jConnector { let neo4j_graph = neo4rs::Graph::new(&config.uri, &config.user, &config.password).await?; let graph = Graph::new(neo4j_graph); - let graph: Arc = if config.slow_query_logging { - let threshold = Duration::from_millis(config.slow_query_threshold_ms); + let graph: Arc = if config.slow_query_logging_enabled { + let threshold = Duration::from_millis(config.slow_query_logging_threshold_ms); Arc::new( TracedGraph::new(graph) .with_slow_query_threshold(threshold) - .with_log_cypher(config.log_slow_query_cypher), + .with_log_cypher(config.slow_query_logging_include_cypher), ) } else { Arc::new(graph) diff --git a/nexus-common/src/db/graph/traced.rs b/nexus-common/src/db/graph/traced.rs index a66e7da0f..e5f36e8d0 100644 --- a/nexus-common/src/db/graph/traced.rs +++ b/nexus-common/src/db/graph/traced.rs @@ -61,7 +61,7 @@ impl GraphOps for Graph { struct TracedStream { inner: BoxStream<'static, Result>, label: Option<&'static str>, - /// Populated cypher text for debug logging (only set when `log_slow_query_cypher` is enabled). + /// Populated cypher text for debug logging (only set when `slow_query_logging_include_cypher` is enabled). cypher: Option, /// Pool-acquire + Bolt RUN round-trip (query planning & start of execution). execute_duration: Duration, diff --git a/nexus-webapi/src/mock.rs b/nexus-webapi/src/mock.rs index 8bdeb4012..f245364e1 100644 --- a/nexus-webapi/src/mock.rs +++ b/nexus-webapi/src/mock.rs @@ -21,7 +21,7 @@ impl MockDb { /// Initialize the database stack for CLI db commands (no slow-query logging). async fn init_stack() { let mut api_config = ApiConfig::default(); - api_config.stack.db.neo4j.slow_query_logging = false; + api_config.stack.db.neo4j.slow_query_logging_enabled = false; let api_context = ApiContextBuilder::from_default_config_dir() .api_config(api_config) .try_build() diff --git a/nexusd/src/migrations/default.config.toml b/nexusd/src/migrations/default.config.toml index c4aed3b5b..371c06955 100644 --- a/nexusd/src/migrations/default.config.toml +++ b/nexusd/src/migrations/default.config.toml @@ -21,6 +21,6 @@ uri = "bolt://localhost:7687" #user = "neo4j" password = "12345678" # Queries taking longer than this (ms) will be logged as warnings -slow_query_threshold_ms = 100 +slow_query_logging_threshold_ms = 100 # Disable slow query logging for CLI migration commands -slow_query_logging = false +slow_query_logging_enabled = false From 390f12d5b2c949f563b35ed9f3612caa54cb08d5 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Wed, 4 Mar 2026 10:07:57 +0100 Subject: [PATCH 28/34] missing default config --- examples/api/api-config.toml | 2 ++ examples/watcher/watcher-config.toml | 2 ++ nexus-common/default.config.toml | 4 ++++ nexusd/src/migrations/default.config.toml | 2 ++ 4 files changed, 10 insertions(+) diff --git a/examples/api/api-config.toml b/examples/api/api-config.toml index 823c82b6b..467ce884d 100644 --- a/examples/api/api-config.toml +++ b/examples/api/api-config.toml @@ -21,5 +21,7 @@ uri = "bolt://localhost:7687" password = "12345678" # Queries taking longer than this (ms) will be logged as warnings slow_query_logging_threshold_ms = 100 +# Enable or disable slow query logging +#slow_query_logging_enabled = true # Include the full cypher (with interpolated params) in slow-query warnings #slow_query_logging_include_cypher = false diff --git a/examples/watcher/watcher-config.toml b/examples/watcher/watcher-config.toml index a94796fc9..7fd99abf9 100644 --- a/examples/watcher/watcher-config.toml +++ b/examples/watcher/watcher-config.toml @@ -33,5 +33,7 @@ uri = "bolt://localhost:7687" password = "12345678" # Queries taking longer than this (ms) will be logged as warnings slow_query_logging_threshold_ms = 100 +# Enable or disable slow query logging +#slow_query_logging_enabled = true # Include the full cypher (with interpolated params) in slow-query warnings #slow_query_logging_include_cypher = false diff --git a/nexus-common/default.config.toml b/nexus-common/default.config.toml index 48b6e3249..dfbb40520 100644 --- a/nexus-common/default.config.toml +++ b/nexus-common/default.config.toml @@ -53,3 +53,7 @@ uri = "bolt://localhost:7687" password = "12345678" # Queries taking longer than this (ms) will be logged as warnings slow_query_logging_threshold_ms = 100 +# Enable or disable slow query logging +slow_query_logging_enabled = true +# Include the Cypher query text in slow query log entries +#slow_query_logging_include_cypher = false diff --git a/nexusd/src/migrations/default.config.toml b/nexusd/src/migrations/default.config.toml index 371c06955..523cadd78 100644 --- a/nexusd/src/migrations/default.config.toml +++ b/nexusd/src/migrations/default.config.toml @@ -24,3 +24,5 @@ password = "12345678" slow_query_logging_threshold_ms = 100 # Disable slow query logging for CLI migration commands slow_query_logging_enabled = false +# Include the Cypher query text in slow query log entries +#slow_query_logging_include_cypher = false From a45a4d9d638af095d5e224b747bf1671292d78a6 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Wed, 4 Mar 2026 10:58:46 +0100 Subject: [PATCH 29/34] query fn test only --- nexus-common/src/db/graph/mod.rs | 2 +- nexus-common/src/db/graph/query.rs | 5 +-- .../tests/event_processor/follows/utils.rs | 7 +++- .../tests/event_processor/mentions/utils.rs | 14 +++---- .../tests/event_processor/mutes/utils.rs | 11 +++--- .../tests/event_processor/posts/utils.rs | 38 +++++++++---------- .../tests/event_processor/tags/utils.rs | 28 +++++++------- 7 files changed, 54 insertions(+), 51 deletions(-) diff --git a/nexus-common/src/db/graph/mod.rs b/nexus-common/src/db/graph/mod.rs index d8860d3cd..7133cb7f6 100644 --- a/nexus-common/src/db/graph/mod.rs +++ b/nexus-common/src/db/graph/mod.rs @@ -6,6 +6,6 @@ pub mod setup; mod traced; pub use error::{GraphError, GraphResult}; -pub use query::{query, Query}; +pub use query::Query; pub use traced::GraphOps; pub(crate) use traced::{Graph, TracedGraph}; diff --git a/nexus-common/src/db/graph/query.rs b/nexus-common/src/db/graph/query.rs index c8ba395c9..e19946885 100644 --- a/nexus-common/src/db/graph/query.rs +++ b/nexus-common/src/db/graph/query.rs @@ -132,9 +132,8 @@ impl From for neo4rs::Query { } } -/// Convenience constructor – creates a `Query` without a label. -/// Production code should prefer `Query::new(label, cypher)` for explicit labels. -pub fn query(cypher: impl Into) -> Query { +#[cfg(test)] +fn query(cypher: impl Into) -> Query { Query { label: None, cypher: cypher.into(), diff --git a/nexus-watcher/tests/event_processor/follows/utils.rs b/nexus-watcher/tests/event_processor/follows/utils.rs index c49f9c87a..f62ae1b80 100644 --- a/nexus-watcher/tests/event_processor/follows/utils.rs +++ b/nexus-watcher/tests/event_processor/follows/utils.rs @@ -1,6 +1,6 @@ use anyhow::Result; use nexus_common::db::fetch_key_from_graph; -use nexus_common::db::graph::{query, Query}; +use nexus_common::db::graph::Query; pub async fn find_follow_relationship(follower: &str, followee: &str) -> Result { let query = user_following_query(follower, followee); @@ -14,7 +14,10 @@ pub async fn find_follow_relationship(follower: &str, followee: &str) -> Result< } fn user_following_query(follower: &str, followee: &str) -> Query { - query(" RETURN EXISTS((:User {id: $follower})-[:FOLLOWS]->(:User {id: $followee})) AS exist") + let label = "user_following_query"; + let cypher = + "RETURN EXISTS((:User {id: $follower})-[:FOLLOWS]->(:User {id: $followee})) AS exist"; + Query::new(label, cypher) .param("followee", followee) .param("follower", follower) } diff --git a/nexus-watcher/tests/event_processor/mentions/utils.rs b/nexus-watcher/tests/event_processor/mentions/utils.rs index 69cc901de..aa1e86614 100644 --- a/nexus-watcher/tests/event_processor/mentions/utils.rs +++ b/nexus-watcher/tests/event_processor/mentions/utils.rs @@ -1,6 +1,6 @@ use anyhow::Result; use nexus_common::db::fetch_key_from_graph; -use nexus_common::db::graph::{query, Query}; +use nexus_common::db::graph::Query; pub async fn find_post_mentions(follower: &str, followee: &str) -> Result> { let query = post_mention_query(follower, followee); @@ -13,15 +13,15 @@ pub async fn find_post_mentions(follower: &str, followee: &str) -> Result Query { - query( - " + let label = "post_mention_query"; + let cypher = " MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) OPTIONAL MATCH (p)-[:MENTIONED]->(mentioned_user:User) RETURN COLLECT( mentioned_user.id ) as mentioned_list - ", - ) - .param("author_id", user_id) - .param("post_id", post_id) + "; + Query::new(label, cypher) + .param("author_id", user_id) + .param("post_id", post_id) } diff --git a/nexus-watcher/tests/event_processor/mutes/utils.rs b/nexus-watcher/tests/event_processor/mutes/utils.rs index 7a0dcf8d7..0ea12a488 100644 --- a/nexus-watcher/tests/event_processor/mutes/utils.rs +++ b/nexus-watcher/tests/event_processor/mutes/utils.rs @@ -1,12 +1,13 @@ use anyhow::Result; use nexus_common::db::fetch_key_from_graph; -use nexus_common::db::graph::query; +use nexus_common::db::graph::Query; pub async fn find_mute_relationship(muter: &str, mutee: &str) -> Result { - let query = - query("RETURN EXISTS((:User {id: $muter})-[:MUTED]->(:User {id: $mutee})) AS exist") - .param("muter", muter) - .param("mutee", mutee); + let label = "find_mute_relationship"; + let cypher = "RETURN EXISTS((:User {id: $muter})-[:MUTED]->(:User {id: $mutee})) AS exist"; + let query = Query::new(label, cypher) + .param("muter", muter) + .param("mutee", mutee); let maybe_exists = fetch_key_from_graph(query, "exist").await.unwrap(); diff --git a/nexus-watcher/tests/event_processor/posts/utils.rs b/nexus-watcher/tests/event_processor/posts/utils.rs index 74fd9691a..5a0a537a7 100644 --- a/nexus-watcher/tests/event_processor/posts/utils.rs +++ b/nexus-watcher/tests/event_processor/posts/utils.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use nexus_common::db::graph::{query, Query}; +use nexus_common::db::graph::Query; use nexus_common::{ db::{fetch_key_from_graph, RedisOps}, models::post::{ @@ -119,33 +119,33 @@ pub async fn find_repost_relationship_parent_uri(user_id: &str, post_id: &str) - } pub fn post_reply_relationships(author_id: &str, post_id: &str) -> Query { - query( - "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) + let label = "post_reply_relationships"; + let cypher = "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) OPTIONAL MATCH (p)-[:REPLIED]->(reply:Post)<-[:AUTHORED]-(reply_author:User) RETURN COLLECT([ reply_author.id, - reply.id ]) as details", - ) - .param("author_id", author_id) - .param("post_id", post_id) + reply.id ]) as details"; + Query::new(label, cypher) + .param("author_id", author_id) + .param("post_id", post_id) } pub fn post_repost_relationships(author_id: &str, post_id: &str) -> Query { - query( - "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) + let label = "post_repost_relationships"; + let cypher = "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) OPTIONAL MATCH (p)-[:REPOSTED]->(repost:Post)<-[:AUTHORED]-(repost_author:User) RETURN collect([ repost_author.id, - repost.id]) as details", - ) - .param("author_id", author_id) - .param("post_id", post_id) + repost.id]) as details"; + Query::new(label, cypher) + .param("author_id", author_id) + .param("post_id", post_id) } // Retrieve a post by id pub fn get_post_details_by_id(user_id: &str, post_id: &str) -> Query { - query( - " + let label = "get_post_details_by_id"; + let cypher = " MATCH (user:User {id: $user_id})-[:AUTHORED]->(post:Post {id: $post_id}) RETURN { id: post.id, @@ -156,8 +156,8 @@ pub fn get_post_details_by_id(user_id: &str, post_id: &str) -> Query { author: user.id, attachments: post.attachments } AS details - ", - ) - .param("user_id", user_id) - .param("post_id", post_id) + "; + Query::new(label, cypher) + .param("user_id", user_id) + .param("post_id", post_id) } diff --git a/nexus-watcher/tests/event_processor/tags/utils.rs b/nexus-watcher/tests/event_processor/tags/utils.rs index 47e286470..fa1905c7d 100644 --- a/nexus-watcher/tests/event_processor/tags/utils.rs +++ b/nexus-watcher/tests/event_processor/tags/utils.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use nexus_common::db::graph::{query, Query}; +use nexus_common::db::graph::Query; use nexus_common::{ db::{fetch_key_from_graph, RedisOps}, models::{ @@ -56,8 +56,8 @@ pub async fn check_member_post_tag_global_timeline( // Retrieve post related tag fn post_tag_query(user_id: &str, post_id: &str, tag_name: &str) -> Query { - query( - " + let label = "post_tag_query"; + let cypher = " MATCH (u:User {id: $user_id})-[:AUTHORED]->(p:Post {id: $post_id})<-[t:TAGGED {label: $tag_name}]-(tagger:User) WITH COUNT(tagger) as count, COLLECT(tagger.id) as list, t.label as label RETURN { @@ -65,17 +65,17 @@ fn post_tag_query(user_id: &str, post_id: &str, tag_name: &str) -> Query { taggers: list, label: label } AS tag_details - ", - ) - .param("user_id", user_id) - .param("post_id", post_id) - .param("tag_name", tag_name) + "; + Query::new(label, cypher) + .param("user_id", user_id) + .param("post_id", post_id) + .param("tag_name", tag_name) } // Retrieve post related tag fn user_tag_query(tagged_user_id: &str, tag_name: &str) -> Query { - query( - " + let label = "user_tag_query"; + let cypher = " MATCH (u:User {id: $tagged_user_id})<-[t:TAGGED {label: $tag_name}]-(tagger:User) WITH COUNT(tagger) as count, COLLECT(tagger.id) as list, t.label as label RETURN { @@ -83,8 +83,8 @@ fn user_tag_query(tagged_user_id: &str, tag_name: &str) -> Query { taggers: list, label: label } AS tag_details - ", - ) - .param("tagged_user_id", tagged_user_id) - .param("tag_name", tag_name) + "; + Query::new(label, cypher) + .param("tagged_user_id", tagged_user_id) + .param("tag_name", tag_name) } From a538b94940d054f53926548d3af4011ce6df1bee Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Wed, 4 Mar 2026 11:36:16 +0100 Subject: [PATCH 30/34] consistent cypher logging --- nexus-common/src/db/graph/traced.rs | 35 +++++++++-------------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/nexus-common/src/db/graph/traced.rs b/nexus-common/src/db/graph/traced.rs index e5f36e8d0..b1e91c2ed 100644 --- a/nexus-common/src/db/graph/traced.rs +++ b/nexus-common/src/db/graph/traced.rs @@ -89,26 +89,15 @@ impl Drop for TracedStream { let fetch_duration = self.stream_start.elapsed(); let total = self.execute_duration + fetch_duration; if total > self.threshold { - if let Some(cypher) = &self.cypher { - warn!( - total_ms = total.as_millis(), - execute_ms = self.execute_duration.as_millis(), - fetch_ms = fetch_duration.as_millis(), - rows = self.row_count, - query = %label, - cypher = %cypher, - "Slow Neo4j query" - ); - } else { - warn!( - total_ms = total.as_millis(), - execute_ms = self.execute_duration.as_millis(), - fetch_ms = fetch_duration.as_millis(), - rows = self.row_count, - query = %label, - "Slow Neo4j query" - ); - } + warn!( + total_ms = total.as_millis(), + execute_ms = self.execute_duration.as_millis(), + fetch_ms = fetch_duration.as_millis(), + rows = self.row_count, + query = %label, + cypher = self.cypher.as_deref().unwrap_or(""), + "Slow Neo4j query" + ); } } } @@ -183,11 +172,7 @@ impl GraphOps for TracedGraph { if let Some(label) = &label { if elapsed > self.slow_query_threshold { - if let Some(cypher) = &cypher { - warn!(elapsed_ms = elapsed.as_millis(), query = %label, cypher = %cypher, "Slow Neo4j query"); - } else { - warn!(elapsed_ms = elapsed.as_millis(), query = %label, "Slow Neo4j query"); - } + warn!(elapsed_ms = elapsed.as_millis(), query = %label, cypher = cypher.as_deref().unwrap_or(""), "Slow Neo4j query"); } } From d2c12d1b1a179e2db3f49eb6f0c6c0b24c6e6d7e Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Wed, 4 Mar 2026 16:33:27 +0100 Subject: [PATCH 31/34] revert label and cypher vars --- nexus-common/src/db/connectors/neo4j.rs | 4 +- nexus-common/src/db/graph/queries/del.rs | 88 ++--- nexus-common/src/db/graph/queries/get.rs | 327 ++++++++++-------- nexus-common/src/db/graph/queries/put.rs | 155 +++++---- nexus-common/src/db/graph/setup.rs | 3 +- nexus-common/src/db/reindex.rs | 11 +- .../tests/event_processor/follows/utils.rs | 12 +- .../tests/event_processor/mentions/utils.rs | 13 +- .../tests/event_processor/mutes/utils.rs | 11 +- .../tests/event_processor/posts/utils.rs | 39 ++- .../tests/event_processor/tags/utils.rs | 22 +- nexus-webapi/src/mock.rs | 4 +- 12 files changed, 367 insertions(+), 322 deletions(-) diff --git a/nexus-common/src/db/connectors/neo4j.rs b/nexus-common/src/db/connectors/neo4j.rs index d0fea2ed6..72ed4a721 100644 --- a/nexus-common/src/db/connectors/neo4j.rs +++ b/nexus-common/src/db/connectors/neo4j.rs @@ -53,9 +53,7 @@ impl Neo4jConnector { /// Perform a health-check PING over the Bolt protocol to the Neo4j server async fn ping(&self, neo4j_uri: &str) -> Result<(), DynError> { - let label = "ping"; - let cypher = "RETURN 1"; - if let Err(neo4j_err) = self.graph.run(Query::new(label, cypher)).await { + if let Err(neo4j_err) = self.graph.run(Query::new("ping", "RETURN 1")).await { return Err(format!("Failed to PING to Neo4j at {neo4j_uri}, {neo4j_err}").into()); } diff --git a/nexus-common/src/db/graph/queries/del.rs b/nexus-common/src/db/graph/queries/del.rs index 0ffc2377d..55da4ba3d 100644 --- a/nexus-common/src/db/graph/queries/del.rs +++ b/nexus-common/src/db/graph/queries/del.rs @@ -4,10 +4,12 @@ use crate::db::graph::Query; /// # Arguments /// * `user_id` - The unique identifier of the user to be deleted pub fn delete_user(user_id: &str) -> Query { - let label = "delete_user"; - let cypher = "MATCH (u:User {id: $id}) - DETACH DELETE u;"; - Query::new(label, cypher).param("id", user_id.to_string()) + Query::new( + "delete_user", + "MATCH (u:User {id: $id}) + DETACH DELETE u;", + ) + .param("id", user_id.to_string()) } /// Deletes a post node authored by a specific user, along with all its relationships @@ -15,12 +17,13 @@ pub fn delete_user(user_id: &str) -> Query { /// * `author_id` - The unique identifier of the user who authored the post. /// * `post_id` - The unique identifier of the post to be deleted. pub fn delete_post(author_id: &str, post_id: &str) -> Query { - let label = "delete_post"; - let cypher = "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) - DETACH DELETE p;"; - Query::new(label, cypher) - .param("author_id", author_id.to_string()) - .param("post_id", post_id.to_string()) + Query::new( + "delete_post", + "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) + DETACH DELETE p;", + ) + .param("author_id", author_id.to_string()) + .param("post_id", post_id.to_string()) } /// Deletes a "follows" relationship between two users @@ -28,17 +31,18 @@ pub fn delete_post(author_id: &str, post_id: &str) -> Query { /// * `follower_id` - The unique identifier of the user who is following another user. /// * `followee_id` - The unique identifier of the user being followed pub fn delete_follow(follower_id: &str, followee_id: &str) -> Query { - let label = "delete_follow"; - let cypher = "// Important that MATCH to check if both users are in the graph + Query::new( + "delete_follow", + "// Important that MATCH to check if both users are in the graph MATCH (follower:User {id: $follower_id}), (followee:User {id: $followee_id}) // Check if follow already exist OPTIONAL MATCH (follower)-[existing:FOLLOWS]->(followee) DELETE existing // Returns true if the relationship does not exist as 'flag' - RETURN existing IS NULL AS flag;"; - Query::new(label, cypher) - .param("follower_id", follower_id.to_string()) - .param("followee_id", followee_id.to_string()) + RETURN existing IS NULL AS flag;", + ) + .param("follower_id", follower_id.to_string()) + .param("followee_id", followee_id.to_string()) } /// Deletes a "muted" relationship between two users @@ -46,16 +50,17 @@ pub fn delete_follow(follower_id: &str, followee_id: &str) -> Query { /// * `user_id` - The unique identifier of the user who muted another user /// * `muted_id` - The unique identifier of the user who was muted pub fn delete_mute(user_id: &str, muted_id: &str) -> Query { - let label = "delete_mute"; - let cypher = "// Important that MATCH to check if both users are in the graph + Query::new( + "delete_mute", + "// Important that MATCH to check if both users are in the graph MATCH (user:User {id: $user_id}), (muted:User {id: $muted_id}) OPTIONAL MATCH (user)-[existing:MUTED]->(muted) DELETE existing // Returns true if the relationship does not exist as 'flag' - RETURN existing IS NULL AS flag;"; - Query::new(label, cypher) - .param("user_id", user_id.to_string()) - .param("muted_id", muted_id.to_string()) + RETURN existing IS NULL AS flag;", + ) + .param("user_id", user_id.to_string()) + .param("muted_id", muted_id.to_string()) } /// Deletes a bookmark relationship between a user and a post @@ -63,14 +68,15 @@ pub fn delete_mute(user_id: &str, muted_id: &str) -> Query { /// * `user_id` - The unique identifier of the user who created the bookmark. /// * `bookmark_id` - The unique identifier of the bookmark relationship to be deleted. pub fn delete_bookmark(user_id: &str, bookmark_id: &str) -> Query { - let label = "delete_bookmark"; - let cypher = "MATCH (u:User {id: $user_id})-[b:BOOKMARKED {id: $bookmark_id}]->(post:Post)<-[:AUTHORED]-(author:User) + Query::new( + "delete_bookmark", + "MATCH (u:User {id: $user_id})-[b:BOOKMARKED {id: $bookmark_id}]->(post:Post)<-[:AUTHORED]-(author:User) WITH post.id as post_id, author.id as author_id, b DELETE b - RETURN post_id, author_id"; - Query::new(label, cypher) - .param("user_id", user_id) - .param("bookmark_id", bookmark_id) + RETURN post_id, author_id", + ) + .param("user_id", user_id) + .param("bookmark_id", bookmark_id) } /// Deletes a tag relationship created by a user and retrieves relevant details about the tag's target @@ -78,8 +84,9 @@ pub fn delete_bookmark(user_id: &str, bookmark_id: &str) -> Query { /// * `user_id` - The unique identifier of the user who created the tag. /// * `tag_id` - The unique identifier of the `TAGGED` relationship to be deleted. pub fn delete_tag(user_id: &str, tag_id: &str) -> Query { - let label = "delete_tag"; - let cypher = "MATCH (user:User {id: $user_id})-[tag:TAGGED {id: $tag_id}]->(target) + Query::new( + "delete_tag", + "MATCH (user:User {id: $user_id})-[tag:TAGGED {id: $tag_id}]->(target) OPTIONAL MATCH (target)<-[:AUTHORED]-(author:User) WITH CASE WHEN target:User THEN target.id ELSE null END AS user_id, CASE WHEN target:Post THEN target.id ELSE null END AS post_id, @@ -87,10 +94,10 @@ pub fn delete_tag(user_id: &str, tag_id: &str) -> Query { tag.label AS label, tag DELETE tag - RETURN user_id, post_id, author_id, label"; - Query::new(label, cypher) - .param("user_id", user_id) - .param("tag_id", tag_id) + RETURN user_id, post_id, author_id, label", + ) + .param("user_id", user_id) + .param("tag_id", tag_id) } /// Deletes a file node and all its relationships @@ -98,10 +105,11 @@ pub fn delete_tag(user_id: &str, tag_id: &str) -> Query { /// * `owner_id` - The unique identifier of the user who owns the file /// * `file_id` - The unique identifier of the file to be deleted pub fn delete_file(owner_id: &str, file_id: &str) -> Query { - let label = "delete_file"; - let cypher = "MATCH (f:File {id: $id, owner_id: $owner_id}) - DETACH DELETE f;"; - Query::new(label, cypher) - .param("id", file_id.to_string()) - .param("owner_id", owner_id.to_string()) + Query::new( + "delete_file", + "MATCH (f:File {id: $id, owner_id: $owner_id}) + DETACH DELETE f;", + ) + .param("id", file_id.to_string()) + .param("owner_id", owner_id.to_string()) } diff --git a/nexus-common/src/db/graph/queries/get.rs b/nexus-common/src/db/graph/queries/get.rs index f5e630e8f..ac7066b6e 100644 --- a/nexus-common/src/db/graph/queries/get.rs +++ b/nexus-common/src/db/graph/queries/get.rs @@ -9,8 +9,9 @@ use pubky_app_specs::PubkyAppPostKind; // Retrieve post node by post id and author id pub fn get_post_by_id(author_id: &str, post_id: &str) -> Query { - let label = "get_post_by_id"; - let cypher = " + Query::new( + "get_post_by_id", + " MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) OPTIONAL MATCH (p)-[replied:REPLIED]->(parent_post:Post)<-[:AUTHORED]-(author:User) WITH u, p, parent_post, author @@ -27,15 +28,16 @@ pub fn get_post_by_id(author_id: &str, post_id: &str) -> Query { } as details, COLLECT([author.id, parent_post.id]) AS reply - "; - Query::new(label, cypher) - .param("author_id", author_id) - .param("post_id", post_id) + ", + ) + .param("author_id", author_id) + .param("post_id", post_id) } pub fn post_counts(author_id: &str, post_id: &str) -> Query { - let label = "post_counts"; - let cypher = " + Query::new( + "post_counts", + " MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) WITH p OPTIONAL MATCH (p)<-[t:TAGGED]-() @@ -48,75 +50,83 @@ pub fn post_counts(author_id: &str, post_id: &str) -> Query { reposts: COUNT { (p)<-[:REPOSTED]-() } } AS counts, EXISTS { (p)-[:REPLIED]->(:Post) } AS is_reply - "; - Query::new(label, cypher) - .param("author_id", author_id) - .param("post_id", post_id) + ", + ) + .param("author_id", author_id) + .param("post_id", post_id) } // Check if the viewer_id has a bookmark in the post pub fn post_bookmark(author_id: &str, post_id: &str, viewer_id: &str) -> Query { - let label = "post_bookmark"; - let cypher = "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) + Query::new( + "post_bookmark", + "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) MATCH (viewer:User {id: $viewer_id})-[b:BOOKMARKED]->(p) - RETURN b"; - Query::new(label, cypher) - .param("author_id", author_id) - .param("post_id", post_id) - .param("viewer_id", viewer_id) + RETURN b", + ) + .param("author_id", author_id) + .param("post_id", post_id) + .param("viewer_id", viewer_id) } // Check all the bookmarks that user creates pub fn user_bookmarks(user_id: &str) -> Query { - let label = "user_bookmarks"; - let cypher = "MATCH (u:User {id: $user_id})-[b:BOOKMARKED]->(p:Post)<-[:AUTHORED]-(author:User) - RETURN b, p.id AS post_id, author.id AS author_id"; - Query::new(label, cypher).param("user_id", user_id) + Query::new( + "user_bookmarks", + "MATCH (u:User {id: $user_id})-[b:BOOKMARKED]->(p:Post)<-[:AUTHORED]-(author:User) + RETURN b, p.id AS post_id, author.id AS author_id", + ) + .param("user_id", user_id) } // Get all the bookmarks that a post has received (used for edit/delete notifications) pub fn get_post_bookmarks(author_id: &str, post_id: &str) -> Query { - let label = "get_post_bookmarks"; - let cypher = "MATCH (bookmarker:User)-[b:BOOKMARKED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) - RETURN b.id AS bookmark_id, bookmarker.id AS bookmarker_id"; - Query::new(label, cypher) + Query::new( + "get_post_bookmarks", + "MATCH (bookmarker:User)-[b:BOOKMARKED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) + RETURN b.id AS bookmark_id, bookmarker.id AS bookmarker_id", + ) .param("author_id", author_id) .param("post_id", post_id) } // Get all the reposts that a post has received (used for edit/delete notifications) pub fn get_post_reposts(author_id: &str, post_id: &str) -> Query { - let label = "get_post_reposts"; - let cypher = "MATCH (reposter:User)-[:AUTHORED]->(repost:Post)-[:REPOSTED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) - RETURN reposter.id AS reposter_id, repost.id AS repost_id"; - Query::new(label, cypher) + Query::new( + "get_post_reposts", + "MATCH (reposter:User)-[:AUTHORED]->(repost:Post)-[:REPOSTED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) + RETURN reposter.id AS reposter_id, repost.id AS repost_id", + ) .param("author_id", author_id) .param("post_id", post_id) } // Get all the replies that a post has received (used for edit/delete notifications) pub fn get_post_replies(author_id: &str, post_id: &str) -> Query { - let label = "get_post_replies"; - let cypher = "MATCH (replier:User)-[:AUTHORED]->(reply:Post)-[:REPLIED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) - RETURN replier.id AS replier_id, reply.id AS reply_id"; - Query::new(label, cypher) + Query::new( + "get_post_replies", + "MATCH (replier:User)-[:AUTHORED]->(reply:Post)-[:REPLIED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) + RETURN replier.id AS replier_id, reply.id AS reply_id", + ) .param("author_id", author_id) .param("post_id", post_id) } // Get all the tags/taggers that a post has received (used for edit/delete notifications) pub fn get_post_tags(author_id: &str, post_id: &str) -> Query { - let label = "get_post_tags"; - let cypher = "MATCH (tagger:User)-[t:TAGGED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) - RETURN tagger.id AS tagger_id, t.id AS tag_id"; - Query::new(label, cypher) + Query::new( + "get_post_tags", + "MATCH (tagger:User)-[t:TAGGED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) + RETURN tagger.id AS tagger_id, t.id AS tag_id", + ) .param("author_id", author_id) .param("post_id", post_id) } pub fn post_relationships(author_id: &str, post_id: &str) -> Query { - let label = "post_relationships"; - let cypher = "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) + Query::new( + "post_relationships", + "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) OPTIONAL MATCH (p)-[:REPLIED]->(replied_post:Post)<-[:AUTHORED]-(replied_author:User) OPTIONAL MATCH (p)-[:REPOSTED]->(reposted_post:Post)<-[:AUTHORED]-(reposted_author:User) OPTIONAL MATCH (p)-[:MENTIONED]->(mentioned_user:User) @@ -125,17 +135,18 @@ pub fn post_relationships(author_id: &str, post_id: &str) -> Query { replied_author.id AS replied_author_id, reposted_post.id AS reposted_post_id, reposted_author.id AS reposted_author_id, - COLLECT(mentioned_user.id) AS mentioned_user_ids"; - Query::new(label, cypher) - .param("author_id", author_id) - .param("post_id", post_id) + COLLECT(mentioned_user.id) AS mentioned_user_ids", + ) + .param("author_id", author_id) + .param("post_id", post_id) } // Retrieve many users by id // We return also id if not we will not get not found users pub fn get_users_details_by_ids(user_ids: &[&str]) -> Query { - let label = "get_users_details_by_ids"; - let cypher = " + Query::new( + "get_users_details_by_ids", + " UNWIND $ids AS id OPTIONAL MATCH (record:User {id: id}) RETURN @@ -145,21 +156,23 @@ pub fn get_users_details_by_ids(user_ids: &[&str]) -> Query { THEN record ELSE null END AS record - "; - Query::new(label, cypher).param("ids", user_ids) + ", + ) + .param("ids", user_ids) } /// Retrieves unique global tags for posts, returning a list of `post_ids` and `timestamp` pairs for each tag label. pub fn global_tags_by_post() -> Query { - let label = "global_tags_by_post"; - let cypher = " + Query::new( + "global_tags_by_post", + " MATCH (tagger:User)-[t:TAGGED]->(post:Post)<-[:AUTHORED]-(author:User) WITH t.label AS label, author.id + ':' + post.id AS post_id, post.indexed_at AS score WITH DISTINCT post_id, label, score WITH label, COLLECT([toFloat(score), post_id ]) AS sorted_set RETURN label, sorted_set - "; - Query::new(label, cypher) + ", + ) } // TODO: Do not traverse all the graph again to get the engagement score. Rethink how to share that info in the indexer @@ -167,8 +180,9 @@ pub fn global_tags_by_post() -> Query { /// replies, reposts and mentions. The query returns a `key` by combining author's ID /// and post's ID, along with a sorted set of engagement scores for each tag label. pub fn global_tags_by_post_engagement() -> Query { - let label = "global_tags_by_post_engagement"; - let cypher = " + Query::new( + "global_tags_by_post_engagement", + " MATCH (author:User)-[:AUTHORED]->(post:Post)<-[tag:TAGGED]-(tagger:User) WITH post, COUNT(tag) AS tags_count, tag.label AS label, author.id + ':' + post.id AS key WITH DISTINCT key, label, post, tags_count @@ -181,14 +195,15 @@ pub fn global_tags_by_post_engagement() -> Query { WITH label, COLLECT([toFloat(taggers + replies_count + reposts_count + mention_count), key ]) AS sorted_set RETURN label, sorted_set order by label - "; - Query::new(label, cypher) + ", + ) } // Retrieve all the tags of the post pub fn post_tags(user_id: &str, post_id: &str) -> Query { - let label = "post_tags"; - let cypher = " + Query::new( + "post_tags", + " MATCH (u:User {id: $user_id})-[:AUTHORED]->(p:Post {id: $post_id}) CALL { WITH p @@ -203,16 +218,17 @@ pub fn post_tags(user_id: &str, post_id: &str) -> Query { RETURN u IS NOT NULL AS exists, tags - "; - Query::new(label, cypher) - .param("user_id", user_id) - .param("post_id", post_id) + ", + ) + .param("user_id", user_id) + .param("post_id", post_id) } // Retrieve all the tags of the user pub fn user_tags(user_id: &str) -> Query { - let label = "user_tags"; - let cypher = " + Query::new( + "user_tags", + " MATCH (u:User {id: $user_id}) CALL { WITH u @@ -227,26 +243,30 @@ pub fn user_tags(user_id: &str) -> Query { RETURN u IS NOT NULL AS exists, tags - "; - Query::new(label, cypher).param("user_id", user_id) + ", + ) + .param("user_id", user_id) } /// Retrieve a homeserver by ID pub fn get_homeserver_by_id(id: &str) -> Query { - let label = "get_homeserver_by_id"; - let cypher = "MATCH (hs:Homeserver {id: $id}) + Query::new( + "get_homeserver_by_id", + "MATCH (hs:Homeserver {id: $id}) WITH hs.id AS id - RETURN id"; - Query::new(label, cypher).param("id", id) + RETURN id", + ) + .param("id", id) } /// Retrieves all homeserver IDs pub fn get_all_homeservers() -> Query { - let label = "get_all_homeservers"; - let cypher = "MATCH (hs:Homeserver) + Query::new( + "get_all_homeservers", + "MATCH (hs:Homeserver) WITH collect(hs.id) AS homeservers_list - RETURN homeservers_list"; - Query::new(label, cypher) + RETURN homeservers_list", + ) } /// Retrieve tags for a user within the viewer's trusted network @@ -293,15 +313,15 @@ pub fn get_viewer_trusted_network_tags(user_id: &str, viewer_id: &str, depth: u8 ); // Add to the query the params - let label = "get_viewer_trusted_network_tags"; - Query::new(label, graph_query.as_str()) + Query::new("get_viewer_trusted_network_tags", graph_query.as_str()) .param("user_id", user_id) .param("viewer_id", viewer_id) } pub fn user_counts(user_id: &str) -> Query { - let label = "user_counts"; - let cypher = " + Query::new( + "user_counts", + " MATCH (u:User {id: $user_id}) // tags that reference this user OPTIONAL MATCH (u)<-[t:TAGGED]-(:User) @@ -335,8 +355,9 @@ pub fn user_counts(user_id: &str) -> Query { unique_tags: unique_tags, bookmarks: bookmarks } AS counts; - "; - Query::new(label, cypher).param("user_id", user_id) + ", + ) + .param("user_id", user_id) } pub fn get_user_followers(user_id: &str, skip: Option, limit: Option) -> Query { @@ -352,8 +373,7 @@ pub fn get_user_followers(user_id: &str, skip: Option, limit: Option, limit: Option) -> Query { @@ -369,8 +389,7 @@ pub fn get_user_following(user_id: &str, skip: Option, limit: Option, limit: Option) -> Query { @@ -386,8 +405,7 @@ pub fn get_user_muted(user_id: &str, skip: Option, limit: Option) if let Some(limit_value) = limit { query_string.push_str(&format!(" LIMIT {limit_value}")); } - let label = "get_user_muted"; - Query::new(label, &query_string).param("user_id", user_id) + Query::new("get_user_muted", &query_string).param("user_id", user_id) } fn stream_reach_to_graph_subquery(reach: &StreamReach) -> String { @@ -404,22 +422,25 @@ fn stream_reach_to_graph_subquery(reach: &StreamReach) -> String { } pub fn get_tags_by_label_prefix(label_prefix: &str) -> Query { - let label = "get_tags_by_label_prefix"; - let cypher = " + Query::new( + "get_tags_by_label_prefix", + " MATCH ()-[t:TAGGED]->() WHERE t.label STARTS WITH $label_prefix RETURN COLLECT(DISTINCT t.label) AS tag_labels - "; - Query::new(label, cypher).param("label_prefix", label_prefix) + ", + ) + .param("label_prefix", label_prefix) } pub fn get_tags() -> Query { - let label = "get_tags"; - let cypher = " + Query::new( + "get_tags", + " MATCH ()-[t:TAGGED]->() RETURN COLLECT(DISTINCT t.label) AS tag_labels - "; - Query::new(label, cypher) + ", + ) } pub fn get_tag_taggers_by_reach( @@ -429,7 +450,6 @@ pub fn get_tag_taggers_by_reach( skip: usize, limit: usize, ) -> Query { - let query_label = "get_tag_taggers_by_reach"; let cypher = format!( " {} @@ -450,7 +470,7 @@ pub fn get_tag_taggers_by_reach( ", stream_reach_to_graph_subquery(&reach) ); - Query::new(query_label, &cypher) + Query::new("get_tag_taggers_by_reach", &cypher) .param("label", label) .param("user_id", user_id) .param("skip", skip as i64) @@ -468,7 +488,6 @@ pub fn get_hot_tags_by_reach( }; let (from, to) = tags_query.timeframe.to_timestamp_range(); - let query_label = "get_hot_tags_by_reach"; let cypher = format!( " {} @@ -493,7 +512,7 @@ pub fn get_hot_tags_by_reach( input_tagged_type, tags_query.taggers_limit ); - Query::new(query_label, &cypher) + Query::new("get_hot_tags_by_reach", &cypher) .param("user_id", user_id) .param("skip", tags_query.skip as i64) .param("limit", tags_query.limit as i64) @@ -507,7 +526,6 @@ pub fn get_global_hot_tags(tags_query: &HotTagsInputDTO) -> Query { None => String::from("Post|User"), }; let (from, to) = tags_query.timeframe.to_timestamp_range(); - let label = "get_global_hot_tags"; let cypher = format!( " MATCH (user: User)-[tag:TAGGED]->(tagged:{}) @@ -529,7 +547,7 @@ pub fn get_global_hot_tags(tags_query: &HotTagsInputDTO) -> Query { ", input_tagged_type, tags_query.taggers_limit ); - Query::new(label, &cypher) + Query::new("get_global_hot_tags", &cypher) .param("skip", tags_query.skip as i64) .param("limit", tags_query.limit as i64) .param("from", from) @@ -544,7 +562,6 @@ pub fn get_influencers_by_reach( timeframe: &Timeframe, ) -> Query { let (from, to) = timeframe.to_timestamp_range(); - let label = "get_influencers_by_reach"; let cypher = format!( " {} @@ -579,7 +596,7 @@ pub fn get_influencers_by_reach( ", stream_reach_to_graph_subquery(&reach), ); - Query::new(label, &cypher) + Query::new("get_influencers_by_reach", &cypher) .param("user_id", user_id) .param("skip", skip as i64) .param("limit", limit as i64) @@ -589,8 +606,9 @@ pub fn get_influencers_by_reach( pub fn get_global_influencers(skip: usize, limit: usize, timeframe: &Timeframe) -> Query { let (from, to) = timeframe.to_timestamp_range(); - let label = "get_global_influencers"; - let cypher = " + Query::new( + "get_global_influencers", + " MATCH (user:User) WHERE user.name <> '[DELETED]' WITH DISTINCT user @@ -616,22 +634,24 @@ pub fn get_global_influencers(skip: usize, limit: usize, timeframe: &Timeframe) SKIP $skip LIMIT $limit RETURN COLLECT([influencer.id, influencer.score]) as influencers - "; - Query::new(label, cypher) - .param("skip", skip as i64) - .param("limit", limit as i64) - .param("from", from) - .param("to", to) + ", + ) + .param("skip", skip as i64) + .param("limit", limit as i64) + .param("from", from) + .param("to", to) } pub fn get_files_by_ids(key_pair: &[&[&str]]) -> Query { - let label = "get_files_by_ids"; - let cypher = " + Query::new( + "get_files_by_ids", + " UNWIND $pairs AS pair OPTIONAL MATCH (record:File {owner_id: pair[0], id: pair[1]}) RETURN record - "; - Query::new(label, cypher).param("pairs", key_pair) + ", + ) + .param("pairs", key_pair) } // Build the graph query based on parameters @@ -787,17 +807,19 @@ pub fn post_stream( } // Build the query and apply parameters using `param` method - let label = match &source { - StreamSource::Following { .. } => "post_stream_following", - StreamSource::Followers { .. } => "post_stream_followers", - StreamSource::Friends { .. } => "post_stream_friends", - StreamSource::Bookmarks { .. } => "post_stream_bookmarks", - StreamSource::Author { .. } => "post_stream_author", - StreamSource::AuthorReplies { .. } => "post_stream_author_replies", - StreamSource::PostReplies { .. } => "post_stream_post_replies", - StreamSource::All => "post_stream_all", - }; - let query = Query::new(label, &cypher); + let query = Query::new( + match &source { + StreamSource::Following { .. } => "post_stream_following", + StreamSource::Followers { .. } => "post_stream_followers", + StreamSource::Friends { .. } => "post_stream_friends", + StreamSource::Bookmarks { .. } => "post_stream_bookmarks", + StreamSource::Author { .. } => "post_stream_author", + StreamSource::AuthorReplies { .. } => "post_stream_author_replies", + StreamSource::PostReplies { .. } => "post_stream_post_replies", + StreamSource::All => "post_stream_all", + }, + &cypher, + ); build_query_with_params(query, &source, tags, kind, &pagination) } @@ -861,16 +883,18 @@ fn build_query_with_params( /// # Arguments /// * `user_id` - The unique identifier of the user pub fn user_is_safe_to_delete(user_id: &str) -> Query { - let label = "user_is_safe_to_delete"; - let cypher = " + Query::new( + "user_is_safe_to_delete", + " MATCH (u:User {id: $user_id}) // Ensures all relationships to the user (u) are checked, counting as 0 if none exist OPTIONAL MATCH (u)-[r]-() // Checks if the user has any relationships - WITH u, NOT (COUNT(r) = 0) AS flag + With u, NOT (COUNT(r) = 0) AS flag RETURN flag - "; - Query::new(label, cypher).param("user_id", user_id) + ", + ) + .param("user_id", user_id) } /// Checks if a post has any relationships that aren't in the set of allowed @@ -881,8 +905,9 @@ pub fn user_is_safe_to_delete(user_id: &str) -> Query { /// * `author_id` - The unique identifier of the user who authored the post /// * `post_id` - The unique identifier of the post pub fn post_is_safe_to_delete(author_id: &str, post_id: &str) -> Query { - let label = "post_is_safe_to_delete"; - let cypher = " + Query::new( + "post_is_safe_to_delete", + " MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) // Ensures all relationships to the post (p) are checked, counting as 0 if none exist OPTIONAL MATCH (p)-[r]-() @@ -898,19 +923,20 @@ pub fn post_is_safe_to_delete(author_id: &str, post_id: &str) -> Query { (type(r) = 'REPLIED' AND startNode(r) = p) ) // Checks if any disallowed relationships exist for the post - WITH p, NOT (COUNT(r) = 0) AS flag + With p, NOT (COUNT(r) = 0) AS flag RETURN flag - "; - Query::new(label, cypher) - .param("author_id", author_id) - .param("post_id", post_id) + ", + ) + .param("author_id", author_id) + .param("post_id", post_id) } /// Find user recommendations: active users (with 5+ posts) who are 1-3 degrees of separation away /// from the given user, but not directly followed by them pub fn recommend_users(user_id: &str, limit: usize) -> Query { - let label = "recommend_users"; - let cypher = " + Query::new( + "recommend_users", + " MATCH (user:User {id: $user_id}) MATCH (user)-[:FOLLOWS*1..3]->(potential:User) WHERE NOT (user)-[:FOLLOWS]->(potential) @@ -921,16 +947,17 @@ pub fn recommend_users(user_id: &str, limit: usize) -> Query { WHERE post_count >= 5 RETURN potential.id AS recommended_user_id, potential.name AS recommended_user_name LIMIT $limit - "; - Query::new(label, cypher) - .param("user_id", user_id.to_string()) - .param("limit", limit as i64) + ", + ) + .param("user_id", user_id.to_string()) + .param("limit", limit as i64) } /// Retrieve specific tag created by the user pub fn get_tag_by_tagger_and_id(tagger_id: &str, tag_id: &str) -> Query { - let label = "get_tag_by_tagger_and_id"; - let cypher = " + Query::new( + "get_tag_by_tagger_and_id", + " MATCH (tagger:User { id: $tagger_id})-[tag:TAGGED {id: $tag_id }]->(tagged) OPTIONAL MATCH (author:User)-[:AUTHORED]->(tagged) RETURN @@ -940,8 +967,8 @@ pub fn get_tag_by_tagger_and_id(tagger_id: &str, tag_id: &str) -> Query { tag.id as id, tag.indexed_at as indexed_at, tag.label as label - "; - Query::new(label, cypher) - .param("tagger_id", tagger_id) - .param("tag_id", tag_id) + ", + ) + .param("tagger_id", tagger_id) + .param("tag_id", tag_id) } diff --git a/nexus-common/src/db/graph/queries/put.rs b/nexus-common/src/db/graph/queries/put.rs index eb647bbf0..f53a489b8 100644 --- a/nexus-common/src/db/graph/queries/put.rs +++ b/nexus-common/src/db/graph/queries/put.rs @@ -9,10 +9,11 @@ pub fn create_user(user: &UserDetails) -> GraphResult { let links = serde_json::to_string(&user.links) .map_err(|e| GraphError::SerializationFailed(Box::new(e)))?; - let label = "create_user"; - let cypher = "MERGE (u:User {id: $id}) - SET u.name = $name, u.bio = $bio, u.status = $status, u.links = $links, u.image = $image, u.indexed_at = $indexed_at;"; - let query = Query::new(label, cypher) + let query = Query::new( + "create_user", + "MERGE (u:User {id: $id}) + SET u.name = $name, u.bio = $bio, u.status = $status, u.links = $links, u.image = $image, u.indexed_at = $indexed_at;", + ) .param("id", user.id.to_string()) .param("name", user.name.clone()) .param("bio", user.bio.clone()) @@ -75,8 +76,7 @@ pub fn create_post( let kind = serde_json::to_string(&post.kind) .map_err(|e| GraphError::SerializationFailed(Box::new(e)))?; - let label = "create_post"; - let mut cypher_query = Query::new(label, &cypher) + let mut cypher_query = Query::new("create_post", &cypher) .param("author_id", post.author.to_string()) .param("post_id", post.id.to_string()) .param("content", post.content.to_string()) @@ -144,14 +144,15 @@ pub fn create_mention_relationship( post_id: &str, mentioned_user_id: &str, ) -> Query { - let label = "create_mention_relationship"; - let cypher = "MATCH (author:User {id: $author_id})-[:AUTHORED]->(post:Post {id: $post_id}), + Query::new( + "create_mention_relationship", + "MATCH (author:User {id: $author_id})-[:AUTHORED]->(post:Post {id: $post_id}), (mentioned_user:User {id: $mentioned_user_id}) - MERGE (post)-[:MENTIONED]->(mentioned_user)"; - Query::new(label, cypher) - .param("author_id", author_id) - .param("post_id", post_id) - .param("mentioned_user_id", mentioned_user_id) + MERGE (post)-[:MENTIONED]->(mentioned_user)", + ) + .param("author_id", author_id) + .param("post_id", post_id) + .param("mentioned_user_id", mentioned_user_id) } /// Create a follows relationship between two users. Before creating the relationship, @@ -162,18 +163,19 @@ pub fn create_mention_relationship( /// * `followee_id` - The unique identifier of the user to be followed. /// * `indexed_at` - A timestamp representing when the relationship was indexed or updated. pub fn create_follow(follower_id: &str, followee_id: &str, indexed_at: i64) -> Query { - let label = "create_follow"; - let cypher = "MATCH (follower:User {id: $follower_id}), (followee:User {id: $followee_id}) + Query::new( + "create_follow", + "MATCH (follower:User {id: $follower_id}), (followee:User {id: $followee_id}) // Check if follow already existed OPTIONAL MATCH (follower)-[existing:FOLLOWS]->(followee) MERGE (follower)-[r:FOLLOWS]->(followee) SET r.indexed_at = $indexed_at // Returns true if the follow relationship already existed - RETURN existing IS NOT NULL AS flag;"; - Query::new(label, cypher) - .param("follower_id", follower_id.to_string()) - .param("followee_id", followee_id.to_string()) - .param("indexed_at", indexed_at) + RETURN existing IS NOT NULL AS flag;", + ) + .param("follower_id", follower_id.to_string()) + .param("followee_id", followee_id.to_string()) + .param("indexed_at", indexed_at) } /// Creates a `MUTED` relationship between a user and another user they wish to mute @@ -182,18 +184,19 @@ pub fn create_follow(follower_id: &str, followee_id: &str, indexed_at: i64) -> Q /// * `muted_id` - The unique identifier of the user to be muted. /// * `indexed_at` - A timestamp indicating when the relationship was created or last updated. pub fn create_mute(user_id: &str, muted_id: &str, indexed_at: i64) -> Query { - let label = "create_mute"; - let cypher = "MATCH (user:User {id: $user_id}), (muted:User {id: $muted_id}) + Query::new( + "create_mute", + "MATCH (user:User {id: $user_id}), (muted:User {id: $muted_id}) // Check if follow already existed OPTIONAL MATCH (user)-[existing:MUTED]->(muted) MERGE (user)-[r:MUTED]->(muted) SET r.indexed_at = $indexed_at // Returns true if the mute relationship already existed - RETURN existing IS NOT NULL AS flag;"; - Query::new(label, cypher) - .param("user_id", user_id.to_string()) - .param("muted_id", muted_id.to_string()) - .param("indexed_at", indexed_at) + RETURN existing IS NOT NULL AS flag;", + ) + .param("user_id", user_id.to_string()) + .param("muted_id", muted_id.to_string()) + .param("indexed_at", indexed_at) } /// Creates a "BOOKMARKED" relationship between a user and a post authored by another user @@ -210,8 +213,9 @@ pub fn create_post_bookmark( bookmark_id: &str, indexed_at: i64, ) -> Query { - let label = "create_post_bookmark"; - let cypher = "MATCH (u:User {id: $user_id}) + Query::new( + "create_post_bookmark", + "MATCH (u:User {id: $user_id}) // We assume these nodes are already created. If not we would not be able to add a bookmark MATCH (author:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) // Check if bookmark already existed @@ -220,13 +224,13 @@ pub fn create_post_bookmark( SET b.indexed_at = $indexed_at, b.id = $bookmark_id // Returns true if the bookmark relationship already existed - RETURN existing IS NOT NULL AS flag;"; - Query::new(label, cypher) - .param("user_id", user_id) - .param("author_id", author_id) - .param("post_id", post_id) - .param("bookmark_id", bookmark_id) - .param("indexed_at", indexed_at) + RETURN existing IS NOT NULL AS flag;", + ) + .param("user_id", user_id) + .param("author_id", author_id) + .param("post_id", post_id) + .param("bookmark_id", bookmark_id) + .param("indexed_at", indexed_at) } /// Creates a `TAGGED` relationship between a user and a post authored by another user. The tag is uniquely @@ -247,8 +251,9 @@ pub fn create_post_tag( label: &str, indexed_at: i64, ) -> Query { - let query_label = "create_post_tag"; - let cypher = "MATCH (user:User {id: $user_id}) + Query::new( + "create_post_tag", + "MATCH (user:User {id: $user_id}) // We assume these nodes are already created. If not we would not be able to add a tag MATCH (author:User {id: $author_id})-[:AUTHORED]->(post:Post {id: $post_id}) // Check if tag already existed @@ -257,14 +262,14 @@ pub fn create_post_tag( ON CREATE SET t.indexed_at = $indexed_at, t.id = $tag_id // Returns true if the post tag relationship already existed - RETURN existing IS NOT NULL AS flag;"; - Query::new(query_label, cypher) - .param("user_id", user_id) - .param("author_id", author_id) - .param("post_id", post_id) - .param("tag_id", tag_id) - .param("label", label) - .param("indexed_at", indexed_at) + RETURN existing IS NOT NULL AS flag;", + ) + .param("user_id", user_id) + .param("author_id", author_id) + .param("post_id", post_id) + .param("tag_id", tag_id) + .param("label", label) + .param("indexed_at", indexed_at) } /// Creates a `TAGGED` relationship between two users. The relationship is uniquely identified by a `label` @@ -281,8 +286,9 @@ pub fn create_user_tag( label: &str, indexed_at: i64, ) -> Query { - let query_label = "create_user_tag"; - let cypher = "MATCH (tagged_used:User {id: $tagged_user_id}) + Query::new( + "create_user_tag", + "MATCH (tagged_used:User {id: $tagged_user_id}) MATCH (tagger:User {id: $tagger_user_id}) // Check if tag already existed OPTIONAL MATCH (tagger)-[existing:TAGGED {label: $label}]->(tagged_used) @@ -290,13 +296,13 @@ pub fn create_user_tag( ON CREATE SET t.indexed_at = $indexed_at, t.id = $tag_id // Returns true if the user tag relationship already existed - RETURN existing IS NOT NULL AS flag;"; - Query::new(query_label, cypher) - .param("tagger_user_id", tagger_user_id) - .param("tagged_user_id", tagged_user_id) - .param("tag_id", tag_id) - .param("label", label) - .param("indexed_at", indexed_at) + RETURN existing IS NOT NULL AS flag;", + ) + .param("tagger_user_id", tagger_user_id) + .param("tagged_user_id", tagged_user_id) + .param("tag_id", tag_id) + .param("label", label) + .param("indexed_at", indexed_at) } /// Create a file node @@ -304,31 +310,34 @@ pub fn create_file(file: &FileDetails) -> GraphResult { let urls = serde_json::to_string(&file.urls) .map_err(|e| GraphError::SerializationFailed(Box::new(e)))?; - let label = "create_file"; - let cypher = "MERGE (f:File {id: $id, owner_id: $owner_id}) + let query = Query::new( + "create_file", + "MERGE (f:File {id: $id, owner_id: $owner_id}) SET f.uri = $uri, f.indexed_at = $indexed_at, f.created_at = $created_at, f.size = $size, - f.src = $src, f.name = $name, f.content_type = $content_type, f.urls = $urls;"; - let query = Query::new(label, cypher) - .param("id", file.id.to_string()) - .param("owner_id", file.owner_id.to_string()) - .param("uri", file.uri.to_string()) - .param("indexed_at", file.indexed_at) - .param("created_at", file.created_at) - .param("size", file.size) - .param("src", file.src.to_string()) - .param("name", file.name.to_string()) - .param("content_type", file.content_type.to_string()) - .param("urls", urls); + f.src = $src, f.name = $name, f.content_type = $content_type, f.urls = $urls;", + ) + .param("id", file.id.to_string()) + .param("owner_id", file.owner_id.to_string()) + .param("uri", file.uri.to_string()) + .param("indexed_at", file.indexed_at) + .param("created_at", file.created_at) + .param("size", file.size) + .param("src", file.src.to_string()) + .param("name", file.name.to_string()) + .param("content_type", file.content_type.to_string()) + .param("urls", urls); Ok(query) } /// Create a homeserver pub fn create_homeserver(homeserver_id: &str) -> Query { - let label = "create_homeserver"; - let cypher = "MERGE (hs:Homeserver { + Query::new( + "create_homeserver", + "MERGE (hs:Homeserver { id: $id }) - RETURN hs;"; - Query::new(label, cypher).param("id", homeserver_id) + RETURN hs;", + ) + .param("id", homeserver_id) } diff --git a/nexus-common/src/db/graph/setup.rs b/nexus-common/src/db/graph/setup.rs index 97c2f0912..090a9cbbd 100644 --- a/nexus-common/src/db/graph/setup.rs +++ b/nexus-common/src/db/graph/setup.rs @@ -30,8 +30,7 @@ pub async fn setup_graph() -> GraphResult<()> { let graph = get_neo4j_graph()?; for &ddl in queries { - let label = "setup_ddl"; - graph.run(Query::new(label, ddl)).await.map_err(|e| { + graph.run(Query::new("setup_ddl", ddl)).await.map_err(|e| { GraphError::Generic(format!( "Failed to apply graph constraint/index '{ddl}': {e}" )) diff --git a/nexus-common/src/db/reindex.rs b/nexus-common/src/db/reindex.rs index 87b7aa12d..e028a69e5 100644 --- a/nexus-common/src/db/reindex.rs +++ b/nexus-common/src/db/reindex.rs @@ -101,9 +101,7 @@ pub async fn reindex_post(author_id: &str, post_id: &str) -> Result<(), DynError } pub async fn get_all_user_ids() -> Result, DynError> { - let label = "get_all_user_ids"; - let cypher = "MATCH (u:User) RETURN u.id AS id"; - let query = Query::new(label, cypher); + let query = Query::new("get_all_user_ids", "MATCH (u:User) RETURN u.id AS id"); let rows = fetch_all_rows_from_graph(query).await?; let mut user_ids = Vec::new(); @@ -117,9 +115,10 @@ pub async fn get_all_user_ids() -> Result, DynError> { } async fn get_all_post_ids() -> Result, DynError> { - let label = "get_all_post_ids"; - let cypher = "MATCH (u:User)-[:AUTHORED]->(p:Post) RETURN u.id AS author_id, p.id AS post_id"; - let query = Query::new(label, cypher); + let query = Query::new( + "get_all_post_ids", + "MATCH (u:User)-[:AUTHORED]->(p:Post) RETURN u.id AS author_id, p.id AS post_id", + ); let rows = fetch_all_rows_from_graph(query).await?; let mut post_ids = Vec::new(); diff --git a/nexus-watcher/tests/event_processor/follows/utils.rs b/nexus-watcher/tests/event_processor/follows/utils.rs index f62ae1b80..a4ea7ed54 100644 --- a/nexus-watcher/tests/event_processor/follows/utils.rs +++ b/nexus-watcher/tests/event_processor/follows/utils.rs @@ -14,10 +14,10 @@ pub async fn find_follow_relationship(follower: &str, followee: &str) -> Result< } fn user_following_query(follower: &str, followee: &str) -> Query { - let label = "user_following_query"; - let cypher = - "RETURN EXISTS((:User {id: $follower})-[:FOLLOWS]->(:User {id: $followee})) AS exist"; - Query::new(label, cypher) - .param("followee", followee) - .param("follower", follower) + Query::new( + "user_following_query", + "RETURN EXISTS((:User {id: $follower})-[:FOLLOWS]->(:User {id: $followee})) AS exist", + ) + .param("followee", followee) + .param("follower", follower) } diff --git a/nexus-watcher/tests/event_processor/mentions/utils.rs b/nexus-watcher/tests/event_processor/mentions/utils.rs index aa1e86614..205c72650 100644 --- a/nexus-watcher/tests/event_processor/mentions/utils.rs +++ b/nexus-watcher/tests/event_processor/mentions/utils.rs @@ -13,15 +13,16 @@ pub async fn find_post_mentions(follower: &str, followee: &str) -> Result Query { - let label = "post_mention_query"; - let cypher = " + Query::new( + "post_mention_query", + " MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) OPTIONAL MATCH (p)-[:MENTIONED]->(mentioned_user:User) RETURN COLLECT( mentioned_user.id ) as mentioned_list - "; - Query::new(label, cypher) - .param("author_id", user_id) - .param("post_id", post_id) + ", + ) + .param("author_id", user_id) + .param("post_id", post_id) } diff --git a/nexus-watcher/tests/event_processor/mutes/utils.rs b/nexus-watcher/tests/event_processor/mutes/utils.rs index 0ea12a488..7b513ad2e 100644 --- a/nexus-watcher/tests/event_processor/mutes/utils.rs +++ b/nexus-watcher/tests/event_processor/mutes/utils.rs @@ -3,11 +3,12 @@ use nexus_common::db::fetch_key_from_graph; use nexus_common::db::graph::Query; pub async fn find_mute_relationship(muter: &str, mutee: &str) -> Result { - let label = "find_mute_relationship"; - let cypher = "RETURN EXISTS((:User {id: $muter})-[:MUTED]->(:User {id: $mutee})) AS exist"; - let query = Query::new(label, cypher) - .param("muter", muter) - .param("mutee", mutee); + let query = Query::new( + "find_mute_relationship", + "RETURN EXISTS((:User {id: $muter})-[:MUTED]->(:User {id: $mutee})) AS exist", + ) + .param("muter", muter) + .param("mutee", mutee); let maybe_exists = fetch_key_from_graph(query, "exist").await.unwrap(); diff --git a/nexus-watcher/tests/event_processor/posts/utils.rs b/nexus-watcher/tests/event_processor/posts/utils.rs index 5a0a537a7..de75f8f94 100644 --- a/nexus-watcher/tests/event_processor/posts/utils.rs +++ b/nexus-watcher/tests/event_processor/posts/utils.rs @@ -119,33 +119,36 @@ pub async fn find_repost_relationship_parent_uri(user_id: &str, post_id: &str) - } pub fn post_reply_relationships(author_id: &str, post_id: &str) -> Query { - let label = "post_reply_relationships"; - let cypher = "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) + Query::new( + "post_reply_relationships", + "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) OPTIONAL MATCH (p)-[:REPLIED]->(reply:Post)<-[:AUTHORED]-(reply_author:User) RETURN COLLECT([ reply_author.id, - reply.id ]) as details"; - Query::new(label, cypher) - .param("author_id", author_id) - .param("post_id", post_id) + reply.id ]) as details", + ) + .param("author_id", author_id) + .param("post_id", post_id) } pub fn post_repost_relationships(author_id: &str, post_id: &str) -> Query { - let label = "post_repost_relationships"; - let cypher = "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) + Query::new( + "post_repost_relationships", + "MATCH (u:User {id: $author_id})-[:AUTHORED]->(p:Post {id: $post_id}) OPTIONAL MATCH (p)-[:REPOSTED]->(repost:Post)<-[:AUTHORED]-(repost_author:User) RETURN collect([ repost_author.id, - repost.id]) as details"; - Query::new(label, cypher) - .param("author_id", author_id) - .param("post_id", post_id) + repost.id]) as details", + ) + .param("author_id", author_id) + .param("post_id", post_id) } // Retrieve a post by id pub fn get_post_details_by_id(user_id: &str, post_id: &str) -> Query { - let label = "get_post_details_by_id"; - let cypher = " + Query::new( + "get_post_details_by_id", + " MATCH (user:User {id: $user_id})-[:AUTHORED]->(post:Post {id: $post_id}) RETURN { id: post.id, @@ -156,8 +159,8 @@ pub fn get_post_details_by_id(user_id: &str, post_id: &str) -> Query { author: user.id, attachments: post.attachments } AS details - "; - Query::new(label, cypher) - .param("user_id", user_id) - .param("post_id", post_id) + ", + ) + .param("user_id", user_id) + .param("post_id", post_id) } diff --git a/nexus-watcher/tests/event_processor/tags/utils.rs b/nexus-watcher/tests/event_processor/tags/utils.rs index fa1905c7d..2847cf958 100644 --- a/nexus-watcher/tests/event_processor/tags/utils.rs +++ b/nexus-watcher/tests/event_processor/tags/utils.rs @@ -56,8 +56,9 @@ pub async fn check_member_post_tag_global_timeline( // Retrieve post related tag fn post_tag_query(user_id: &str, post_id: &str, tag_name: &str) -> Query { - let label = "post_tag_query"; - let cypher = " + Query::new( + "post_tag_query", + " MATCH (u:User {id: $user_id})-[:AUTHORED]->(p:Post {id: $post_id})<-[t:TAGGED {label: $tag_name}]-(tagger:User) WITH COUNT(tagger) as count, COLLECT(tagger.id) as list, t.label as label RETURN { @@ -65,8 +66,8 @@ fn post_tag_query(user_id: &str, post_id: &str, tag_name: &str) -> Query { taggers: list, label: label } AS tag_details - "; - Query::new(label, cypher) + ", + ) .param("user_id", user_id) .param("post_id", post_id) .param("tag_name", tag_name) @@ -74,8 +75,9 @@ fn post_tag_query(user_id: &str, post_id: &str, tag_name: &str) -> Query { // Retrieve post related tag fn user_tag_query(tagged_user_id: &str, tag_name: &str) -> Query { - let label = "user_tag_query"; - let cypher = " + Query::new( + "user_tag_query", + " MATCH (u:User {id: $tagged_user_id})<-[t:TAGGED {label: $tag_name}]-(tagger:User) WITH COUNT(tagger) as count, COLLECT(tagger.id) as list, t.label as label RETURN { @@ -83,8 +85,8 @@ fn user_tag_query(tagged_user_id: &str, tag_name: &str) -> Query { taggers: list, label: label } AS tag_details - "; - Query::new(label, cypher) - .param("tagged_user_id", tagged_user_id) - .param("tag_name", tag_name) + ", + ) + .param("tagged_user_id", tagged_user_id) + .param("tag_name", tag_name) } diff --git a/nexus-webapi/src/mock.rs b/nexus-webapi/src/mock.rs index f245364e1..e1bc1d8c3 100644 --- a/nexus-webapi/src/mock.rs +++ b/nexus-webapi/src/mock.rs @@ -56,9 +56,7 @@ impl MockDb { let graph = get_neo4j_graph().expect("Failed to get Neo4j graph connection"); // drop and run the queries again - let label = "drop_graph"; - let cypher = "MATCH (n) DETACH DELETE n;"; - let drop_all_query = Query::new(label, cypher); + let drop_all_query = Query::new("drop_graph", "MATCH (n) DETACH DELETE n;"); graph .run(drop_all_query) .await From 8684e083ec1d699ef6145228ca4ad57f055c4818 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Thu, 5 Mar 2026 09:05:00 +0100 Subject: [PATCH 32/34] add TracedGraph unit tests --- Cargo.lock | 22 ++++ Cargo.toml | 1 + nexus-common/Cargo.toml | 1 + nexus-common/src/db/graph/traced.rs | 194 +++++++++++++++++++++++++++- 4 files changed, 212 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ced479a9..e99c171e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3076,6 +3076,7 @@ dependencies = [ "tracing", "tracing-log", "tracing-subscriber", + "tracing-test", "utoipa", ] @@ -6170,6 +6171,27 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tracing-test" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" +dependencies = [ + "quote", + "syn 2.0.115", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 01a13bf5a..ac39ffdc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,4 +31,5 @@ thiserror = "2.0.17" tokio = { version = "1.49.0", features = ["full"] } tokio-shared-rt = "0.1" tracing = "0.1.44" +tracing-test = "0.2" futures = "0.3" diff --git a/nexus-common/Cargo.toml b/nexus-common/Cargo.toml index 606d48a00..101ac97ee 100644 --- a/nexus-common/Cargo.toml +++ b/nexus-common/Cargo.toml @@ -34,3 +34,4 @@ utoipa = "5.4.0" [dev-dependencies] tempfile = { workspace = true } tokio-shared-rt = { workspace = true } +tracing-test = { workspace = true } diff --git a/nexus-common/src/db/graph/traced.rs b/nexus-common/src/db/graph/traced.rs index b1e91c2ed..d2bb603ea 100644 --- a/nexus-common/src/db/graph/traced.rs +++ b/nexus-common/src/db/graph/traced.rs @@ -103,16 +103,16 @@ impl Drop for TracedStream { } } -/// Decorator around `Graph` that logs slow queries. +/// Decorator around a [`GraphOps`] implementation that logs slow queries. #[derive(Clone)] -pub struct TracedGraph { - inner: Graph, +pub struct TracedGraph { + inner: G, slow_query_threshold: Duration, log_cypher: bool, } -impl TracedGraph { - pub fn new(graph: Graph) -> Self { +impl TracedGraph { + pub fn new(graph: G) -> Self { Self { inner: graph, slow_query_threshold: Duration::from_millis(DEFAULT_SLOW_QUERY_THRESHOLD_MS), @@ -132,7 +132,7 @@ impl TracedGraph { } #[async_trait] -impl GraphOps for TracedGraph { +impl GraphOps for TracedGraph { async fn execute( &self, query: Query, @@ -179,3 +179,185 @@ impl GraphOps for TracedGraph { result } } + +#[cfg(test)] +mod tests { + use super::*; + use futures::stream; + use neo4rs::{BoltList, BoltType}; + use tracing_test::traced_test; + + /// Create a dummy `Row` with a single string field. + fn dummy_row() -> Row { + let fields = BoltList::from(vec![BoltType::String("n".into())]); + let data = BoltList::from(vec![BoltType::String("value".into())]); + Row::new(fields, data) + } + + fn make_traced_stream( + inner: BoxStream<'static, Result>, + label: Option<&'static str>, + threshold: Duration, + ) -> TracedStream { + TracedStream { + inner, + label, + cypher: None, + execute_duration: Duration::ZERO, + stream_start: Instant::now(), + row_count: 0, + threshold, + } + } + + // ── TracedStream row counting ────────────────────────────────── + + #[tokio::test] + async fn counts_rows_from_inner_stream() { + let rows = vec![Ok(dummy_row()), Ok(dummy_row()), Ok(dummy_row())]; + let inner = stream::iter(rows).boxed(); + let mut ts = make_traced_stream(inner, Some("test"), Duration::from_secs(100)); + + while ts.next().await.is_some() {} + assert_eq!(ts.row_count, 3); + } + + #[tokio::test] + async fn empty_stream_yields_none_and_zero_rows() { + let inner = stream::empty().boxed(); + let mut ts = make_traced_stream(inner, Some("test"), Duration::from_secs(100)); + assert!(ts.next().await.is_none()); + assert_eq!(ts.row_count, 0); + } + + // ── Slow-path doesn't abort the stream ───────────────────────── + + #[tokio::test] + async fn slow_query_still_yields_all_rows() { + // Threshold is 0ms — every query is "slow", but all rows must still be returned. + let rows = vec![Ok(dummy_row()), Ok(dummy_row())]; + let inner = stream::iter(rows).boxed(); + let mut ts = make_traced_stream(inner, Some("slow_q"), Duration::ZERO); + + let mut collected = Vec::new(); + while let Some(item) = ts.next().await { + collected.push(item.expect("row should be Ok")); + } + assert_eq!(collected.len(), 2); + assert_eq!(ts.row_count, 2); + } + + // ── warn! emission ───────────────────────────────────────────── + + #[tokio::test] + #[traced_test] + async fn emits_warning_when_threshold_exceeded() { + let inner = stream::iter(vec![Ok(dummy_row())]).boxed(); + // threshold = 0 → always slow + let mut ts = make_traced_stream(inner, Some("slow_label"), Duration::ZERO); + while ts.next().await.is_some() {} + drop(ts); + + assert!(logs_contain("Slow Neo4j query")); + assert!(logs_contain("slow_label")); + } + + #[tokio::test] + #[traced_test] + async fn no_warning_when_under_threshold() { + let inner = stream::empty().boxed(); + let ts = make_traced_stream(inner, Some("fast_q"), Duration::from_secs(600)); + drop(ts); + + assert!(!logs_contain("Slow Neo4j query")); + } + + #[tokio::test] + #[traced_test] + async fn no_warning_when_label_is_none() { + let inner = stream::empty().boxed(); + // threshold = 0 but no label → drop skips logging entirely + let ts = make_traced_stream(inner, None, Duration::ZERO); + drop(ts); + + assert!(!logs_contain("Slow Neo4j query")); + } + + #[tokio::test] + #[traced_test] + async fn warning_includes_cypher_when_set() { + let ts = TracedStream { + inner: stream::empty().boxed(), + label: Some("cypher_q"), + cypher: Some("MATCH (n) RETURN n".into()), + execute_duration: Duration::ZERO, + stream_start: Instant::now(), + row_count: 0, + threshold: Duration::ZERO, + }; + drop(ts); + + assert!(logs_contain("MATCH (n) RETURN n")); + } + + // ── TracedGraph ──────────────────────────────────────────────── + + /// Mock `GraphOps` for testing `TracedGraph` without a real Neo4j connection. + #[derive(Clone)] + struct MockGraph { + row_count: usize, + } + + #[async_trait] + impl GraphOps for MockGraph { + async fn execute( + &self, + _query: Query, + ) -> neo4rs::Result>> { + let rows: Vec> = + (0..self.row_count).map(|_| Ok(dummy_row())).collect(); + Ok(stream::iter(rows).boxed()) + } + + async fn run(&self, _query: Query) -> neo4rs::Result<()> { + Ok(()) + } + } + + fn test_query() -> Query { + Query::new("test_label", "MATCH (n) RETURN n") + } + + #[tokio::test] + async fn traced_graph_execute_returns_all_rows() { + let tg = TracedGraph::new(MockGraph { row_count: 3 }); + let mut stream = tg.execute(test_query()).await.unwrap(); + + let mut count = 0; + while stream.next().await.is_some() { + count += 1; + } + assert_eq!(count, 3); + } + + #[tokio::test] + #[traced_test] + async fn traced_graph_run_warns_on_slow_query() { + let tg = + TracedGraph::new(MockGraph { row_count: 0 }).with_slow_query_threshold(Duration::ZERO); + tg.run(test_query()).await.unwrap(); + + assert!(logs_contain("Slow Neo4j query")); + assert!(logs_contain("test_label")); + } + + #[tokio::test] + #[traced_test] + async fn traced_graph_run_no_warning_under_threshold() { + let tg = TracedGraph::new(MockGraph { row_count: 0 }) + .with_slow_query_threshold(Duration::from_secs(600)); + tg.run(test_query()).await.unwrap(); + + assert!(!logs_contain("Slow Neo4j query")); + } +} From a0c798e689443d5e50ac1a8885d1b87feb343efb Mon Sep 17 00:00:00 2001 From: ok300 <106775972+ok300@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:48:42 +0100 Subject: [PATCH 33/34] chore: fix query.param() indentation --- nexus-common/src/db/graph/queries/get.rs | 16 ++++++++-------- nexus-common/src/db/graph/queries/put.rs | 14 +++++++------- .../tests/event_processor/tags/utils.rs | 6 +++--- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/nexus-common/src/db/graph/queries/get.rs b/nexus-common/src/db/graph/queries/get.rs index 0880cefad..7bc7a0b8d 100644 --- a/nexus-common/src/db/graph/queries/get.rs +++ b/nexus-common/src/db/graph/queries/get.rs @@ -86,8 +86,8 @@ pub fn get_post_bookmarks(author_id: &str, post_id: &str) -> Query { "MATCH (bookmarker:User)-[b:BOOKMARKED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) RETURN b.id AS bookmark_id, bookmarker.id AS bookmarker_id", ) - .param("author_id", author_id) - .param("post_id", post_id) + .param("author_id", author_id) + .param("post_id", post_id) } // Get all the reposts that a post has received (used for edit/delete notifications) @@ -97,8 +97,8 @@ pub fn get_post_reposts(author_id: &str, post_id: &str) -> Query { "MATCH (reposter:User)-[:AUTHORED]->(repost:Post)-[:REPOSTED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) RETURN reposter.id AS reposter_id, repost.id AS repost_id", ) - .param("author_id", author_id) - .param("post_id", post_id) + .param("author_id", author_id) + .param("post_id", post_id) } // Get all the replies that a post has received (used for edit/delete notifications) @@ -108,8 +108,8 @@ pub fn get_post_replies(author_id: &str, post_id: &str) -> Query { "MATCH (replier:User)-[:AUTHORED]->(reply:Post)-[:REPLIED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) RETURN replier.id AS replier_id, reply.id AS reply_id", ) - .param("author_id", author_id) - .param("post_id", post_id) + .param("author_id", author_id) + .param("post_id", post_id) } // Get all the tags/taggers that a post has received (used for edit/delete notifications) @@ -119,8 +119,8 @@ pub fn get_post_tags(author_id: &str, post_id: &str) -> Query { "MATCH (tagger:User)-[t:TAGGED]->(p:Post {id: $post_id})<-[:AUTHORED]-(author:User {id: $author_id}) RETURN tagger.id AS tagger_id, t.id AS tag_id", ) - .param("author_id", author_id) - .param("post_id", post_id) + .param("author_id", author_id) + .param("post_id", post_id) } pub fn post_relationships(author_id: &str, post_id: &str) -> Query { diff --git a/nexus-common/src/db/graph/queries/put.rs b/nexus-common/src/db/graph/queries/put.rs index 2e55577ff..9af5123d0 100644 --- a/nexus-common/src/db/graph/queries/put.rs +++ b/nexus-common/src/db/graph/queries/put.rs @@ -14,13 +14,13 @@ pub fn create_user(user: &UserDetails) -> GraphResult { "MERGE (u:User {id: $id}) SET u.name = $name, u.bio = $bio, u.status = $status, u.links = $links, u.image = $image, u.indexed_at = $indexed_at;", ) - .param("id", user.id.to_string()) - .param("name", user.name.clone()) - .param("bio", user.bio.clone()) - .param("status", user.status.clone()) - .param("links", links) - .param("image", user.image.clone()) - .param("indexed_at", user.indexed_at); + .param("id", user.id.to_string()) + .param("name", user.name.clone()) + .param("bio", user.bio.clone()) + .param("status", user.status.clone()) + .param("links", links) + .param("image", user.image.clone()) + .param("indexed_at", user.indexed_at); Ok(query) } diff --git a/nexus-watcher/tests/event_processor/tags/utils.rs b/nexus-watcher/tests/event_processor/tags/utils.rs index 2847cf958..3f381909a 100644 --- a/nexus-watcher/tests/event_processor/tags/utils.rs +++ b/nexus-watcher/tests/event_processor/tags/utils.rs @@ -68,9 +68,9 @@ fn post_tag_query(user_id: &str, post_id: &str, tag_name: &str) -> Query { } AS tag_details ", ) - .param("user_id", user_id) - .param("post_id", post_id) - .param("tag_name", tag_name) + .param("user_id", user_id) + .param("post_id", post_id) + .param("tag_name", tag_name) } // Retrieve post related tag From 9dae547251e9f74730ee908fa9b4d5f0306fb4b2 Mon Sep 17 00:00:00 2001 From: aintnostressin Date: Thu, 5 Mar 2026 22:05:55 +0100 Subject: [PATCH 34/34] Update nexus-common/src/db/graph/traced.rs Co-authored-by: ok300 <106775972+ok300@users.noreply.github.com> --- nexus-common/src/db/graph/traced.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus-common/src/db/graph/traced.rs b/nexus-common/src/db/graph/traced.rs index d2bb603ea..e5aeccc91 100644 --- a/nexus-common/src/db/graph/traced.rs +++ b/nexus-common/src/db/graph/traced.rs @@ -152,7 +152,7 @@ impl GraphOps for TracedGraph { label, cypher, execute_duration, - stream_start: start, + stream_start: Instant::now(), // Timestamp after execute(), so it only tracks the fetch phase row_count: 0, threshold: self.slow_query_threshold, };