diff --git a/crates/common/src/local_db/query/fetch_orders/mod.rs b/crates/common/src/local_db/query/fetch_orders/mod.rs index 474b76b675..4144f93739 100644 --- a/crates/common/src/local_db/query/fetch_orders/mod.rs +++ b/crates/common/src/local_db/query/fetch_orders/mod.rs @@ -3,6 +3,12 @@ use crate::utils::serde::bool_from_int_or_bool; use alloy::primitives::{Address, Bytes, B256}; use serde::{Deserialize, Serialize}; +use super::fetch_orders_common::{ + bind_common_order_filters, INPUT_TOKENS_CLAUSE, LATEST_ADD_CHAIN_IDS_CLAUSE, + LATEST_ADD_ORDERBOOKS_CLAUSE, MAIN_CHAIN_IDS_CLAUSE, MAIN_ORDERBOOKS_CLAUSE, ORDER_HASH_CLAUSE, + ORDER_HASH_CLAUSE_BODY, OUTPUT_TOKENS_CLAUSE, OWNERS_CLAUSE, +}; + const QUERY_TEMPLATE: &str = include_str!("query.sql"); #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -29,6 +35,8 @@ pub struct FetchOrdersArgs { pub order_hash: Option, pub tx_hash: Option, pub tokens: FetchOrdersTokensFilter, + pub page: Option, + pub page_size: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -50,59 +58,6 @@ pub struct LocalDbOrder { pub meta: Option, } -/// Builds the SQL query fetching orders from the local database based on the -/// supplied filters. -const OWNERS_CLAUSE: &str = "/*OWNERS_CLAUSE*/"; -const OWNERS_CLAUSE_BODY: &str = "AND l.order_owner IN ({list})"; - -const ORDER_HASH_CLAUSE: &str = "/*ORDER_HASH_CLAUSE*/"; -const ORDER_HASH_CLAUSE_BODY: &str = "AND COALESCE(la.order_hash, l.order_hash) = {param}"; - -const INPUT_TOKENS_CLAUSE: &str = "/*INPUT_TOKENS_CLAUSE*/"; -const INPUT_TOKENS_CLAUSE_BODY: &str = "AND EXISTS ( - SELECT 1 FROM order_ios io2 - WHERE io2.chain_id = l.chain_id - AND io2.orderbook_address = l.orderbook_address - AND io2.transaction_hash = la.transaction_hash - AND io2.log_index = la.log_index - AND lower(io2.io_type) = 'input' - AND io2.token IN ({list}) - )"; - -const OUTPUT_TOKENS_CLAUSE: &str = "/*OUTPUT_TOKENS_CLAUSE*/"; -const OUTPUT_TOKENS_CLAUSE_BODY: &str = "AND EXISTS ( - SELECT 1 FROM order_ios io2 - WHERE io2.chain_id = l.chain_id - AND io2.orderbook_address = l.orderbook_address - AND io2.transaction_hash = la.transaction_hash - AND io2.log_index = la.log_index - AND lower(io2.io_type) = 'output' - AND io2.token IN ({list}) - )"; - -const COMBINED_TOKENS_CLAUSE_BODY: &str = "AND EXISTS ( - SELECT 1 FROM order_ios io2 - WHERE io2.chain_id = l.chain_id - AND io2.orderbook_address = l.orderbook_address - AND io2.transaction_hash = la.transaction_hash - AND io2.log_index = la.log_index - AND ( - (lower(io2.io_type) = 'input' AND io2.token IN ({input_list})) - OR - (lower(io2.io_type) = 'output' AND io2.token IN ({output_list})) - ) - )"; - -const MAIN_CHAIN_IDS_CLAUSE: &str = "/*MAIN_CHAIN_IDS_CLAUSE*/"; -const MAIN_CHAIN_IDS_CLAUSE_BODY: &str = "AND oe.chain_id IN ({list})"; -const MAIN_ORDERBOOKS_CLAUSE: &str = "/*MAIN_ORDERBOOKS_CLAUSE*/"; -const MAIN_ORDERBOOKS_CLAUSE_BODY: &str = "AND oe.orderbook_address IN ({list})"; - -const LATEST_ADD_CHAIN_IDS_CLAUSE: &str = "/*LATEST_ADD_CHAIN_IDS_CLAUSE*/"; -const LATEST_ADD_CHAIN_IDS_CLAUSE_BODY: &str = "AND oe.chain_id IN ({list})"; -const LATEST_ADD_ORDERBOOKS_CLAUSE: &str = "/*LATEST_ADD_ORDERBOOKS_CLAUSE*/"; -const LATEST_ADD_ORDERBOOKS_CLAUSE_BODY: &str = "AND oe.orderbook_address IN ({list})"; - const FIRST_ADD_CHAIN_IDS_CLAUSE: &str = "/*FIRST_ADD_CHAIN_IDS_CLAUSE*/"; const FIRST_ADD_CHAIN_IDS_CLAUSE_BODY: &str = "AND oe.chain_id IN ({list})"; const FIRST_ADD_ORDERBOOKS_CLAUSE: &str = "/*FIRST_ADD_ORDERBOOKS_CLAUSE*/"; @@ -119,43 +74,16 @@ const CLEAR_EVENTS_ORDERBOOKS_CLAUSE: &str = "/*CLEAR_EVENTS_ORDERBOOKS_CLAUSE*/ const CLEAR_EVENTS_ORDERBOOKS_CLAUSE_BODY: &str = "AND entries.orderbook_address IN ({list})"; const TX_HASH_CLAUSE: &str = "/*TX_HASH_CLAUSE*/"; const TX_HASH_CLAUSE_BODY: &str = "AND oe.transaction_hash = {param}"; +const PAGINATION_CLAUSE: &str = "/*PAGINATION_CLAUSE*/"; pub fn build_fetch_orders_stmt(args: &FetchOrdersArgs) -> Result { let mut stmt = SqlStatement::new(QUERY_TEMPLATE); - // ?1 active filter - let active_str = match args.filter { - FetchOrdersActiveFilter::All => "all", - FetchOrdersActiveFilter::Active => "active", - FetchOrdersActiveFilter::Inactive => "inactive", - }; - stmt.push(SqlValue::from(active_str)); - - // Chain ids (deduplicated, sorted) - let mut chain_ids = args.chain_ids.clone(); - chain_ids.sort_unstable(); - chain_ids.dedup(); - - // Orderbook addresses (lowercase, deduplicated) - let mut orderbooks = args.orderbook_addresses.clone(); - orderbooks.sort(); - orderbooks.dedup(); - - // Helper closures to bind repeated clauses without ownership issues - let chain_ids_iter = || chain_ids.iter().cloned().map(SqlValue::from); - let orderbooks_iter = || orderbooks.iter().cloned().map(SqlValue::from); - - // Apply chain-id filters across query sections - stmt.bind_list_clause( - MAIN_CHAIN_IDS_CLAUSE, - MAIN_CHAIN_IDS_CLAUSE_BODY, - chain_ids_iter(), - )?; - stmt.bind_list_clause( - LATEST_ADD_CHAIN_IDS_CLAUSE, - LATEST_ADD_CHAIN_IDS_CLAUSE_BODY, - chain_ids_iter(), - )?; + let prepared = bind_common_order_filters(&mut stmt, args)?; + + let chain_ids_iter = || prepared.chain_ids.iter().cloned().map(SqlValue::from); + let orderbooks_iter = || prepared.orderbooks.iter().cloned().map(SqlValue::from); + stmt.bind_list_clause( FIRST_ADD_CHAIN_IDS_CLAUSE, FIRST_ADD_CHAIN_IDS_CLAUSE_BODY, @@ -172,17 +100,6 @@ pub fn build_fetch_orders_stmt(args: &FetchOrdersArgs) -> Result Result = input_tokens - .iter() - .enumerate() - .map(|(i, _)| format!("?{}", stmt.params.len() + i + 1)) - .collect(); - let input_list_str = input_placeholders.join(", "); - - // Push input token params - for token in &input_tokens { - stmt.push(SqlValue::from(*token)); - } - - // Build parameter placeholders for output tokens - let output_placeholders: Vec = output_tokens - .iter() - .enumerate() - .map(|(i, _)| format!("?{}", stmt.params.len() + i + 1)) - .collect(); - let output_list_str = output_placeholders.join(", "); - - // Push output token params - for token in &output_tokens { - stmt.push(SqlValue::from(*token)); - } - - // Build the combined clause - let combined_clause = COMBINED_TOKENS_CLAUSE_BODY - .replace("{input_list}", &input_list_str) - .replace("{output_list}", &output_list_str); - - // Replace INPUT_TOKENS_CLAUSE with combined clause, clear OUTPUT_TOKENS_CLAUSE - stmt.sql = stmt.sql.replace(INPUT_TOKENS_CLAUSE, &combined_clause); - stmt.sql = stmt.sql.replace(OUTPUT_TOKENS_CLAUSE, ""); + if let (Some(page), Some(page_size)) = (args.page, args.page_size) { + let offset = (page.saturating_sub(1) as u64) * (page_size as u64); + let limit_placeholder = format!("?{}", stmt.params.len() + 1); + let offset_placeholder = format!("?{}", stmt.params.len() + 2); + let pagination = format!("LIMIT {} OFFSET {}", limit_placeholder, offset_placeholder); + stmt.sql = stmt.sql.replace(PAGINATION_CLAUSE, &pagination); + stmt.push(SqlValue::U64(page_size as u64)); + stmt.push(SqlValue::U64(offset)); } else { - // Separate EXISTS clauses with AND logic: - // - When only inputs or only outputs specified: single-direction filtering - // - When both specified but different: directional filtering (input AND output) - stmt.bind_list_clause( - INPUT_TOKENS_CLAUSE, - INPUT_TOKENS_CLAUSE_BODY, - input_tokens.into_iter().map(SqlValue::from), - )?; - stmt.bind_list_clause( - OUTPUT_TOKENS_CLAUSE, - OUTPUT_TOKENS_CLAUSE_BODY, - output_tokens.into_iter().map(SqlValue::from), - )?; + stmt.sql = stmt.sql.replace(PAGINATION_CLAUSE, ""); } Ok(stmt) @@ -331,6 +178,8 @@ mod tests { ], outputs: vec![address!("0xF3dEe5b36E3402893e6953A8670E37D329683ABB")], }, + page: None, + page_size: None, }; let stmt = build_fetch_orders_stmt(&args).unwrap(); @@ -598,6 +447,91 @@ mod tests { ); } + #[test] + fn pagination_clause_page1() { + let args = FetchOrdersArgs { + chain_ids: vec![1], + page: Some(1), + page_size: Some(10), + ..FetchOrdersArgs::default() + }; + let stmt = build_fetch_orders_stmt(&args).unwrap(); + assert!(stmt.sql.contains("LIMIT"), "should contain LIMIT clause"); + assert!(stmt.sql.contains("OFFSET"), "should contain OFFSET clause"); + assert!(!stmt.sql.contains(PAGINATION_CLAUSE)); + let last_two: Vec<&SqlValue> = stmt.params.iter().rev().take(2).collect(); + assert_eq!(last_two[1], &SqlValue::U64(10)); + assert_eq!(last_two[0], &SqlValue::U64(0)); + } + + #[test] + fn pagination_clause_page3() { + let args = FetchOrdersArgs { + chain_ids: vec![1], + page: Some(3), + page_size: Some(25), + ..FetchOrdersArgs::default() + }; + let stmt = build_fetch_orders_stmt(&args).unwrap(); + assert!(stmt.sql.contains("LIMIT")); + assert!(stmt.sql.contains("OFFSET")); + let last_two: Vec<&SqlValue> = stmt.params.iter().rev().take(2).collect(); + assert_eq!(last_two[1], &SqlValue::U64(25)); + assert_eq!(last_two[0], &SqlValue::U64(50)); + } + + #[test] + fn pagination_clause_page0_saturates_to_zero_offset() { + let args = FetchOrdersArgs { + chain_ids: vec![1], + page: Some(0), + page_size: Some(10), + ..FetchOrdersArgs::default() + }; + let stmt = build_fetch_orders_stmt(&args).unwrap(); + assert!(stmt.sql.contains("LIMIT")); + let last_two: Vec<&SqlValue> = stmt.params.iter().rev().take(2).collect(); + assert_eq!(last_two[1], &SqlValue::U64(10)); + assert_eq!(last_two[0], &SqlValue::U64(0)); + } + + #[test] + fn pagination_clause_omitted_when_only_page_set() { + let args = FetchOrdersArgs { + chain_ids: vec![1], + page: Some(2), + page_size: None, + ..FetchOrdersArgs::default() + }; + let stmt = build_fetch_orders_stmt(&args).unwrap(); + assert!(!stmt.sql.contains("OFFSET")); + assert!(!stmt.sql.contains(PAGINATION_CLAUSE)); + } + + #[test] + fn pagination_clause_omitted_when_only_page_size_set() { + let args = FetchOrdersArgs { + chain_ids: vec![1], + page: None, + page_size: Some(10), + ..FetchOrdersArgs::default() + }; + let stmt = build_fetch_orders_stmt(&args).unwrap(); + assert!(!stmt.sql.contains("OFFSET")); + assert!(!stmt.sql.contains(PAGINATION_CLAUSE)); + } + + #[test] + fn pagination_clause_omitted_when_neither_set() { + let args = FetchOrdersArgs { + chain_ids: vec![1], + ..FetchOrdersArgs::default() + }; + let stmt = build_fetch_orders_stmt(&args).unwrap(); + assert!(!stmt.sql.contains("OFFSET")); + assert!(!stmt.sql.contains(PAGINATION_CLAUSE)); + } + #[test] fn missing_order_hash_marker_yields_error() { // Simulate the ORDER_HASH_CLAUSE marker being removed from the template. diff --git a/crates/common/src/local_db/query/fetch_orders/query.sql b/crates/common/src/local_db/query/fetch_orders/query.sql index 496f6a131a..fbf8ff3f72 100644 --- a/crates/common/src/local_db/query/fetch_orders/query.sql +++ b/crates/common/src/local_db/query/fetch_orders/query.sql @@ -264,4 +264,5 @@ GROUP BY l.order_nonce, l.event_type, la.transaction_hash -ORDER BY fa.block_timestamp DESC; +ORDER BY fa.block_timestamp DESC +/*PAGINATION_CLAUSE*/; diff --git a/crates/common/src/local_db/query/fetch_orders_common.rs b/crates/common/src/local_db/query/fetch_orders_common.rs new file mode 100644 index 0000000000..e5bda799f8 --- /dev/null +++ b/crates/common/src/local_db/query/fetch_orders_common.rs @@ -0,0 +1,177 @@ +use super::fetch_orders::FetchOrdersArgs; +use crate::local_db::query::{SqlBuildError, SqlStatement, SqlValue}; +use alloy::primitives::Address; + +use super::fetch_orders::FetchOrdersActiveFilter; + +pub(crate) const OWNERS_CLAUSE: &str = "/*OWNERS_CLAUSE*/"; +pub(crate) const OWNERS_CLAUSE_BODY: &str = "AND l.order_owner IN ({list})"; + +pub(crate) const ORDER_HASH_CLAUSE: &str = "/*ORDER_HASH_CLAUSE*/"; +pub(crate) const ORDER_HASH_CLAUSE_BODY: &str = + "AND COALESCE(la.order_hash, l.order_hash) = {param}"; + +pub(crate) const INPUT_TOKENS_CLAUSE: &str = "/*INPUT_TOKENS_CLAUSE*/"; +pub(crate) const INPUT_TOKENS_CLAUSE_BODY: &str = "AND EXISTS ( + SELECT 1 FROM order_ios io2 + WHERE io2.chain_id = l.chain_id + AND io2.orderbook_address = l.orderbook_address + AND io2.transaction_hash = la.transaction_hash + AND io2.log_index = la.log_index + AND lower(io2.io_type) = 'input' + AND io2.token IN ({list}) + )"; + +pub(crate) const OUTPUT_TOKENS_CLAUSE: &str = "/*OUTPUT_TOKENS_CLAUSE*/"; +pub(crate) const OUTPUT_TOKENS_CLAUSE_BODY: &str = "AND EXISTS ( + SELECT 1 FROM order_ios io2 + WHERE io2.chain_id = l.chain_id + AND io2.orderbook_address = l.orderbook_address + AND io2.transaction_hash = la.transaction_hash + AND io2.log_index = la.log_index + AND lower(io2.io_type) = 'output' + AND io2.token IN ({list}) + )"; + +pub(crate) const COMBINED_TOKENS_CLAUSE_BODY: &str = "AND EXISTS ( + SELECT 1 FROM order_ios io2 + WHERE io2.chain_id = l.chain_id + AND io2.orderbook_address = l.orderbook_address + AND io2.transaction_hash = la.transaction_hash + AND io2.log_index = la.log_index + AND ( + (lower(io2.io_type) = 'input' AND io2.token IN ({input_list})) + OR + (lower(io2.io_type) = 'output' AND io2.token IN ({output_list})) + ) + )"; + +pub(crate) const MAIN_CHAIN_IDS_CLAUSE: &str = "/*MAIN_CHAIN_IDS_CLAUSE*/"; +pub(crate) const MAIN_CHAIN_IDS_CLAUSE_BODY: &str = "AND oe.chain_id IN ({list})"; +pub(crate) const MAIN_ORDERBOOKS_CLAUSE: &str = "/*MAIN_ORDERBOOKS_CLAUSE*/"; +pub(crate) const MAIN_ORDERBOOKS_CLAUSE_BODY: &str = "AND oe.orderbook_address IN ({list})"; + +pub(crate) const LATEST_ADD_CHAIN_IDS_CLAUSE: &str = "/*LATEST_ADD_CHAIN_IDS_CLAUSE*/"; +pub(crate) const LATEST_ADD_CHAIN_IDS_CLAUSE_BODY: &str = "AND oe.chain_id IN ({list})"; +pub(crate) const LATEST_ADD_ORDERBOOKS_CLAUSE: &str = "/*LATEST_ADD_ORDERBOOKS_CLAUSE*/"; +pub(crate) const LATEST_ADD_ORDERBOOKS_CLAUSE_BODY: &str = "AND oe.orderbook_address IN ({list})"; + +pub(crate) struct PreparedFilters { + pub chain_ids: Vec, + pub orderbooks: Vec
, +} + +pub(crate) fn bind_common_order_filters( + stmt: &mut SqlStatement, + args: &FetchOrdersArgs, +) -> Result { + let active_str = match args.filter { + FetchOrdersActiveFilter::All => "all", + FetchOrdersActiveFilter::Active => "active", + FetchOrdersActiveFilter::Inactive => "inactive", + }; + stmt.push(SqlValue::from(active_str)); + + let mut chain_ids = args.chain_ids.clone(); + chain_ids.sort_unstable(); + chain_ids.dedup(); + + let mut orderbooks = args.orderbook_addresses.clone(); + orderbooks.sort(); + orderbooks.dedup(); + + let chain_ids_iter = || chain_ids.iter().cloned().map(SqlValue::from); + let orderbooks_iter = || orderbooks.iter().cloned().map(SqlValue::from); + + stmt.bind_list_clause( + MAIN_CHAIN_IDS_CLAUSE, + MAIN_CHAIN_IDS_CLAUSE_BODY, + chain_ids_iter(), + )?; + stmt.bind_list_clause( + LATEST_ADD_CHAIN_IDS_CLAUSE, + LATEST_ADD_CHAIN_IDS_CLAUSE_BODY, + chain_ids_iter(), + )?; + + stmt.bind_list_clause( + MAIN_ORDERBOOKS_CLAUSE, + MAIN_ORDERBOOKS_CLAUSE_BODY, + orderbooks_iter(), + )?; + stmt.bind_list_clause( + LATEST_ADD_ORDERBOOKS_CLAUSE, + LATEST_ADD_ORDERBOOKS_CLAUSE_BODY, + orderbooks_iter(), + )?; + + let mut owners = args.owners.clone(); + owners.sort(); + owners.dedup(); + stmt.bind_list_clause( + OWNERS_CLAUSE, + OWNERS_CLAUSE_BODY, + owners.into_iter().map(SqlValue::from), + )?; + + let order_hash_val = args.order_hash.as_ref().map(|hash| SqlValue::from(*hash)); + stmt.bind_param_clause(ORDER_HASH_CLAUSE, ORDER_HASH_CLAUSE_BODY, order_hash_val)?; + + let mut input_tokens = args.tokens.inputs.clone(); + input_tokens.sort(); + input_tokens.dedup(); + + let mut output_tokens = args.tokens.outputs.clone(); + output_tokens.sort(); + output_tokens.dedup(); + + let has_inputs = !input_tokens.is_empty(); + let has_outputs = !output_tokens.is_empty(); + + if has_inputs && has_outputs && input_tokens == output_tokens { + let input_placeholders: Vec = input_tokens + .iter() + .enumerate() + .map(|(i, _)| format!("?{}", stmt.params.len() + i + 1)) + .collect(); + let input_list_str = input_placeholders.join(", "); + + for token in &input_tokens { + stmt.push(SqlValue::from(*token)); + } + + let output_placeholders: Vec = output_tokens + .iter() + .enumerate() + .map(|(i, _)| format!("?{}", stmt.params.len() + i + 1)) + .collect(); + let output_list_str = output_placeholders.join(", "); + + for token in &output_tokens { + stmt.push(SqlValue::from(*token)); + } + + let combined_clause = COMBINED_TOKENS_CLAUSE_BODY + .replace("{input_list}", &input_list_str) + .replace("{output_list}", &output_list_str); + + stmt.sql = stmt.sql.replace(INPUT_TOKENS_CLAUSE, &combined_clause); + stmt.sql = stmt.sql.replace(OUTPUT_TOKENS_CLAUSE, ""); + } else { + stmt.bind_list_clause( + INPUT_TOKENS_CLAUSE, + INPUT_TOKENS_CLAUSE_BODY, + input_tokens.into_iter().map(SqlValue::from), + )?; + stmt.bind_list_clause( + OUTPUT_TOKENS_CLAUSE, + OUTPUT_TOKENS_CLAUSE_BODY, + output_tokens.into_iter().map(SqlValue::from), + )?; + } + + Ok(PreparedFilters { + chain_ids, + orderbooks, + }) +} diff --git a/crates/common/src/local_db/query/fetch_orders_count/mod.rs b/crates/common/src/local_db/query/fetch_orders_count/mod.rs new file mode 100644 index 0000000000..27e1a56919 --- /dev/null +++ b/crates/common/src/local_db/query/fetch_orders_count/mod.rs @@ -0,0 +1,185 @@ +use super::fetch_orders::{FetchOrdersActiveFilter, FetchOrdersArgs}; +use super::fetch_orders_common::{ + bind_common_order_filters, INPUT_TOKENS_CLAUSE, LATEST_ADD_CHAIN_IDS_CLAUSE, + MAIN_CHAIN_IDS_CLAUSE, ORDER_HASH_CLAUSE, OUTPUT_TOKENS_CLAUSE, OWNERS_CLAUSE, +}; +use crate::local_db::query::{SqlBuildError, SqlStatement}; +use serde::{Deserialize, Serialize}; + +const QUERY_TEMPLATE: &str = include_str!("query.sql"); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LocalDbOrdersCountRow { + pub orders_count: u32, +} + +pub fn build_fetch_orders_count_stmt( + args: &FetchOrdersArgs, +) -> Result { + let mut stmt = SqlStatement::new(QUERY_TEMPLATE); + bind_common_order_filters(&mut stmt, args)?; + Ok(stmt) +} + +pub fn extract_orders_count(rows: &[LocalDbOrdersCountRow]) -> u32 { + rows.first().map(|row| row.orders_count).unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::primitives::{address, b256, Address}; + + #[test] + fn builds_count_with_no_filters() { + let args = FetchOrdersArgs { + chain_ids: vec![1], + filter: FetchOrdersActiveFilter::All, + ..FetchOrdersArgs::default() + }; + let stmt = build_fetch_orders_count_stmt(&args).unwrap(); + assert!(stmt.sql.contains("COUNT(*)")); + assert!(stmt.sql.contains("orders_count")); + assert!(stmt.sql.contains("?1 = 'all'")); + assert!(!stmt.sql.contains(OWNERS_CLAUSE)); + assert!(!stmt.sql.contains(INPUT_TOKENS_CLAUSE)); + assert!(!stmt.sql.contains(OUTPUT_TOKENS_CLAUSE)); + assert!(!stmt.sql.contains(ORDER_HASH_CLAUSE)); + } + + #[test] + fn builds_count_with_active_filter() { + let args = FetchOrdersArgs { + chain_ids: vec![137], + filter: FetchOrdersActiveFilter::Active, + ..FetchOrdersArgs::default() + }; + let stmt = build_fetch_orders_count_stmt(&args).unwrap(); + assert!(stmt.sql.contains("?1 = 'active'")); + } + + #[test] + fn builds_count_with_owners_and_order_hash() { + let args = FetchOrdersArgs { + chain_ids: vec![1], + owners: vec![address!("0xF3dEe5b36E3402893e6953A8670E37D329683ABB")], + order_hash: Some(b256!( + "0x00000000000000000000000000000000000000000000000000000000deadbeef" + )), + ..FetchOrdersArgs::default() + }; + let stmt = build_fetch_orders_count_stmt(&args).unwrap(); + assert!(stmt.sql.contains("l.order_owner IN (")); + assert!(stmt + .sql + .contains("COALESCE(la.order_hash, l.order_hash) = ")); + } + + #[test] + fn builds_count_with_chain_ids() { + let args = FetchOrdersArgs { + chain_ids: vec![137, 1, 137], + filter: FetchOrdersActiveFilter::All, + ..FetchOrdersArgs::default() + }; + let stmt = build_fetch_orders_count_stmt(&args).unwrap(); + assert!(!stmt.sql.contains(MAIN_CHAIN_IDS_CLAUSE)); + assert!(!stmt.sql.contains(LATEST_ADD_CHAIN_IDS_CLAUSE)); + assert!(stmt.sql.contains("AND oe.chain_id IN (?")); + } + + #[test] + fn builds_count_with_orderbooks() { + let args = FetchOrdersArgs { + chain_ids: vec![1], + orderbook_addresses: vec![Address::ZERO], + ..FetchOrdersArgs::default() + }; + let stmt = build_fetch_orders_count_stmt(&args).unwrap(); + assert!(stmt.sql.contains("AND oe.orderbook_address IN (?")); + } + + #[test] + fn builds_count_with_combined_tokens_when_inputs_equal_outputs() { + use super::super::fetch_orders::FetchOrdersTokensFilter; + + let token_a = address!("0xF3dEe5b36E3402893e6953A8670E37D329683ABB"); + let token_b = address!("0x1111111111111111111111111111111111111111"); + let tokens = vec![token_a, token_b]; + + let args = FetchOrdersArgs { + chain_ids: vec![1], + tokens: FetchOrdersTokensFilter { + inputs: tokens.clone(), + outputs: tokens, + }, + ..FetchOrdersArgs::default() + }; + let stmt = build_fetch_orders_count_stmt(&args).unwrap(); + + assert!( + stmt.sql.contains("lower(io2.io_type) = 'input'"), + "should contain input check in combined clause" + ); + assert!( + stmt.sql.contains("lower(io2.io_type) = 'output'"), + "should contain output check in combined clause" + ); + assert!( + stmt.sql.contains(" OR "), + "should use OR to combine input and output checks" + ); + assert!( + !stmt.sql.contains(INPUT_TOKENS_CLAUSE), + "input tokens placeholder should be replaced" + ); + assert!( + !stmt.sql.contains(OUTPUT_TOKENS_CLAUSE), + "output tokens placeholder should be replaced" + ); + } + + #[test] + fn builds_count_with_separate_tokens_when_inputs_differ_from_outputs() { + use super::super::fetch_orders::FetchOrdersTokensFilter; + + let token_a = address!("0xF3dEe5b36E3402893e6953A8670E37D329683ABB"); + let token_b = address!("0x1111111111111111111111111111111111111111"); + + let args = FetchOrdersArgs { + chain_ids: vec![1], + tokens: FetchOrdersTokensFilter { + inputs: vec![token_a], + outputs: vec![token_b], + }, + ..FetchOrdersArgs::default() + }; + let stmt = build_fetch_orders_count_stmt(&args).unwrap(); + + assert!( + !stmt.sql.contains(INPUT_TOKENS_CLAUSE), + "input tokens placeholder should be replaced" + ); + assert!( + !stmt.sql.contains(OUTPUT_TOKENS_CLAUSE), + "output tokens placeholder should be replaced" + ); + let exists_count = stmt.sql.matches("AND EXISTS").count(); + assert_eq!( + exists_count, 2, + "should have two separate EXISTS subqueries when tokens differ" + ); + } + + #[test] + fn extract_count_returns_value() { + let rows = vec![LocalDbOrdersCountRow { orders_count: 42 }]; + assert_eq!(extract_orders_count(&rows), 42); + } + + #[test] + fn extract_count_returns_zero_for_empty() { + let rows: Vec = vec![]; + assert_eq!(extract_orders_count(&rows), 0); + } +} diff --git a/crates/common/src/local_db/query/fetch_orders_count/query.sql b/crates/common/src/local_db/query/fetch_orders_count/query.sql new file mode 100644 index 0000000000..ae0b948ec8 --- /dev/null +++ b/crates/common/src/local_db/query/fetch_orders_count/query.sql @@ -0,0 +1,87 @@ +SELECT COUNT(*) AS orders_count FROM ( + SELECT + l.chain_id + FROM ( + SELECT + latest.chain_id, + latest.orderbook_address, + latest.order_owner, + latest.order_nonce, + latest.order_hash, + latest.event_type + FROM ( + SELECT + oe.chain_id, + oe.orderbook_address, + oe.order_owner, + oe.order_nonce, + oe.order_hash, + oe.event_type, + ROW_NUMBER() OVER ( + PARTITION BY + oe.chain_id, + oe.orderbook_address, + oe.order_owner, + oe.order_nonce + ORDER BY oe.block_number DESC, oe.log_index DESC + ) AS row_rank_latest + FROM order_events oe + WHERE 1 = 1 + /*MAIN_CHAIN_IDS_CLAUSE*/ + /*MAIN_ORDERBOOKS_CLAUSE*/ + ) latest + WHERE latest.row_rank_latest = 1 + ) l + LEFT JOIN ( + SELECT + ranked.chain_id, + ranked.orderbook_address, + ranked.order_owner, + ranked.order_nonce, + ranked.order_hash, + ranked.transaction_hash, + ranked.log_index + FROM ( + SELECT + oe.chain_id, + oe.orderbook_address, + oe.order_owner, + oe.order_nonce, + oe.order_hash, + oe.transaction_hash, + oe.log_index, + ROW_NUMBER() OVER ( + PARTITION BY + oe.chain_id, + oe.orderbook_address, + oe.order_owner, + oe.order_nonce + ORDER BY oe.block_number DESC, oe.log_index DESC + ) AS row_rank_add + FROM order_events oe + WHERE oe.event_type = 'AddOrderV3' + /*LATEST_ADD_CHAIN_IDS_CLAUSE*/ + /*LATEST_ADD_ORDERBOOKS_CLAUSE*/ + ) ranked + WHERE ranked.row_rank_add = 1 + ) la + ON la.chain_id = l.chain_id + AND la.orderbook_address = l.orderbook_address + AND la.order_owner = l.order_owner + AND la.order_nonce = l.order_nonce + WHERE + ( + ?1 = 'all' + OR (?1 = 'active' AND l.event_type = 'AddOrderV3') + OR (?1 = 'inactive' AND l.event_type = 'RemoveOrderV3') + ) + /*OWNERS_CLAUSE*/ + /*ORDER_HASH_CLAUSE*/ + /*INPUT_TOKENS_CLAUSE*/ + /*OUTPUT_TOKENS_CLAUSE*/ + GROUP BY + l.chain_id, + COALESCE(la.order_hash, l.order_hash), + l.order_owner, + l.order_nonce +); diff --git a/crates/common/src/local_db/query/mod.rs b/crates/common/src/local_db/query/mod.rs index 1ddeb6cf69..0d8a989e98 100644 --- a/crates/common/src/local_db/query/mod.rs +++ b/crates/common/src/local_db/query/mod.rs @@ -11,6 +11,8 @@ pub mod fetch_order_trades; pub mod fetch_order_trades_count; pub mod fetch_order_vaults_volume; pub mod fetch_orders; +pub(crate) mod fetch_orders_common; +pub mod fetch_orders_count; pub mod fetch_store_addresses; pub mod fetch_tables; pub mod fetch_target_watermark; diff --git a/crates/common/src/raindex_client/local_db/orders.rs b/crates/common/src/raindex_client/local_db/orders.rs index 3c9fe31df7..5708713d13 100644 --- a/crates/common/src/raindex_client/local_db/orders.rs +++ b/crates/common/src/raindex_client/local_db/orders.rs @@ -1,9 +1,13 @@ -use super::super::orders::{GetOrdersFilters, OrdersDataSource, RaindexOrder}; +use super::super::orders::{ + GetOrdersFilters, OrdersDataSource, RaindexOrder, RaindexOrdersListResult, +}; use super::super::trades::RaindexTrade; use super::super::RaindexError; use super::query::fetch_order_trades::fetch_order_trades; use super::query::fetch_order_trades_count::fetch_order_trades_count; +use super::query::fetch_orders_count::fetch_orders_count; use super::LocalDb; +use crate::local_db::query::fetch_orders::LocalDbOrder; use crate::local_db::query::fetch_vaults::LocalDbVault; use crate::local_db::query::LocalDbQueryError; use crate::local_db::{query::fetch_orders::FetchOrdersArgs, OrderbookIdentifier}; @@ -19,6 +23,15 @@ pub struct LocalDbOrders<'a> { pub(crate) client: ClientRef, } +fn convert_local_db_order( + client: ClientRef, + local_db_order: LocalDbOrder, +) -> Result { + let inputs = parse_io_vaults("inputs", &local_db_order.inputs)?; + let outputs = parse_io_vaults("outputs", &local_db_order.outputs)?; + RaindexOrder::from_local_db_order(client, local_db_order, inputs, outputs) +} + impl<'a> LocalDbOrders<'a> { pub(crate) fn new(db: &'a LocalDb, client: ClientRef) -> Self { Self { db, client } @@ -40,20 +53,10 @@ impl<'a> LocalDbOrders<'a> { let local_db_orders = fetch_orders(self.db, fetch_args).await?; let client = ClientRef::clone(&self.client); - let mut orders: Vec = Vec::with_capacity(local_db_orders.len()); - for local_db_order in local_db_orders { - let inputs = parse_io_vaults("inputs", &local_db_order.inputs)?; - let outputs = parse_io_vaults("outputs", &local_db_order.outputs)?; - let order = RaindexOrder::from_local_db_order( - ClientRef::clone(&client), - local_db_order, - inputs, - outputs, - )?; - orders.push(order); - } - - Ok(orders) + local_db_orders + .into_iter() + .map(|order| convert_local_db_order(ClientRef::clone(&client), order)) + .collect() } } @@ -96,8 +99,9 @@ impl OrdersDataSource for LocalDbOrders<'_> { &self, chain_ids: Option>, filters: &GetOrdersFilters, - _page: Option, - ) -> Result, RaindexError> { + page: Option, + page_size: Option, + ) -> Result { let mut fetch_args = FetchOrdersArgs::from(filters.clone()); if let Some(ids) = chain_ids { if !ids.is_empty() { @@ -105,23 +109,32 @@ impl OrdersDataSource for LocalDbOrders<'_> { } } + let total_count = if page.is_some() { + let count_args = FetchOrdersArgs { + page: None, + page_size: None, + ..fetch_args.clone() + }; + fetch_orders_count(self.db, count_args).await? + } else { + 0 + }; + + fetch_args.page = page; + fetch_args.page_size = page_size; + let local_db_orders = fetch_orders(self.db, fetch_args).await?; - let mut orders: Vec = Vec::with_capacity(local_db_orders.len()); let client = ClientRef::clone(&self.client); - for local_db_order in local_db_orders { - let inputs = parse_io_vaults("inputs", &local_db_order.inputs)?; - let outputs = parse_io_vaults("outputs", &local_db_order.outputs)?; - let order = RaindexOrder::from_local_db_order( - ClientRef::clone(&client), - local_db_order, - inputs, - outputs, - )?; - orders.push(order); - } + let orders: Vec = local_db_orders + .into_iter() + .map(|order| convert_local_db_order(ClientRef::clone(&client), order)) + .collect::>()?; - Ok(orders) + Ok(RaindexOrdersListResult { + orders, + total_count, + }) } async fn get_by_hash( @@ -139,15 +152,11 @@ impl OrdersDataSource for LocalDbOrders<'_> { let local_db_orders = fetch_orders(self.db, fetch_args).await?; let client = ClientRef::clone(&self.client); - if let Some(local_db_order) = local_db_orders.into_iter().next() { - let inputs = parse_io_vaults("inputs", &local_db_order.inputs)?; - let outputs = parse_io_vaults("outputs", &local_db_order.outputs)?; - let order = RaindexOrder::from_local_db_order(client, local_db_order, inputs, outputs)?; - - return Ok(Some(order)); - } - - Ok(None) + local_db_orders + .into_iter() + .next() + .map(|order| convert_local_db_order(client, order)) + .transpose() } async fn get_added_by_tx_hash( @@ -222,6 +231,7 @@ mod tests { use wasm_bindgen_utils::prelude::*; fn make_local_db_callback(orders: Vec) -> js_sys::Function { + let order_count = orders.len(); let orders_json = serde_json::to_string(&orders).unwrap(); let orders_result = WasmEncodedResult::Success:: { value: orders_json, @@ -233,6 +243,17 @@ mod tests { .as_string() .unwrap(); + let count_json = format!("[{{\"orders_count\":{}}}]", order_count); + let count_result = WasmEncodedResult::Success:: { + value: count_json, + error: None, + }; + let count_payload = + js_sys::JSON::stringify(&serde_wasm_bindgen::to_value(&count_result).unwrap()) + .unwrap() + .as_string() + .unwrap(); + let empty_result = WasmEncodedResult::Success:: { value: "[]".to_string(), error: None, @@ -245,6 +266,9 @@ mod tests { let callback = Closure::wrap(Box::new(move |sql: String, _params: JsValue| -> JsValue { + if sql.contains("orders_count") { + return js_sys::JSON::parse(&count_payload).unwrap(); + } if sql.contains("FROM order_events") && sql.contains("json_group_array") { return js_sys::JSON::parse(&orders_payload).unwrap(); } @@ -363,14 +387,15 @@ mod tests { vec![42161], ); - let orders = client - .get_orders(Some(ChainIds(vec![42161])), None, None) + let result = client + .get_orders(Some(ChainIds(vec![42161])), None, None, None) .await .expect("local db query should succeed"); - assert_eq!(orders.len(), 1); + assert_eq!(result.orders().len(), 1); + assert_eq!(result.total_count(), 1); - let order = &orders[0]; + let order = &result.orders()[0]; assert_eq!(order.chain_id(), 42161); assert_eq!(order.order_hash(), order_hash_str); assert_eq!(order.order_bytes(), order_bytes_str); diff --git a/crates/common/src/raindex_client/local_db/query/fetch_orders_count.rs b/crates/common/src/raindex_client/local_db/query/fetch_orders_count.rs new file mode 100644 index 0000000000..ed2dea64d7 --- /dev/null +++ b/crates/common/src/raindex_client/local_db/query/fetch_orders_count.rs @@ -0,0 +1,14 @@ +use crate::local_db::query::fetch_orders::FetchOrdersArgs; +use crate::local_db::query::fetch_orders_count::{ + build_fetch_orders_count_stmt, extract_orders_count, LocalDbOrdersCountRow, +}; +use crate::local_db::query::{LocalDbQueryError, LocalDbQueryExecutor}; + +pub async fn fetch_orders_count( + exec: &E, + args: FetchOrdersArgs, +) -> Result { + let stmt = build_fetch_orders_count_stmt(&args)?; + let rows: Vec = exec.query_json(&stmt).await?; + Ok(extract_orders_count(&rows)) +} diff --git a/crates/common/src/raindex_client/local_db/query/mod.rs b/crates/common/src/raindex_client/local_db/query/mod.rs index d8a7921360..c57dcc15ef 100644 --- a/crates/common/src/raindex_client/local_db/query/mod.rs +++ b/crates/common/src/raindex_client/local_db/query/mod.rs @@ -7,6 +7,7 @@ pub mod fetch_order_trades; pub mod fetch_order_trades_count; pub mod fetch_order_vaults_volume; pub mod fetch_orders; +pub mod fetch_orders_count; pub mod fetch_store_addresses; pub mod fetch_tables; pub mod fetch_transaction_by_hash; diff --git a/crates/common/src/raindex_client/orders.rs b/crates/common/src/raindex_client/orders.rs index fd94f12855..80f91891cd 100644 --- a/crates/common/src/raindex_client/orders.rs +++ b/crates/common/src/raindex_client/orders.rs @@ -53,6 +53,39 @@ const DEFAULT_PAGE_SIZE: u16 = 100; // Limit concurrent dotrain source fetches to avoid overwhelming the subgraph/metaboard. const MAX_CONCURRENT_DOTRAIN_SOURCE_FETCHES: usize = 5; +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +#[wasm_bindgen] +pub struct RaindexOrdersListResult { + pub(crate) orders: Vec, + pub(crate) total_count: u32, +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen] +impl RaindexOrdersListResult { + #[wasm_bindgen(getter)] + pub fn orders(&self) -> Vec { + self.orders.clone() + } + + #[wasm_bindgen(getter, js_name = "totalCount")] + pub fn total_count(&self) -> u32 { + self.total_count + } +} + +#[cfg(not(target_family = "wasm"))] +impl RaindexOrdersListResult { + pub fn orders(&self) -> &[RaindexOrder] { + &self.orders + } + + pub fn total_count(&self) -> u32 { + self.total_count + } +} + pub(crate) struct SubgraphOrders<'a> { client: &'a RaindexClient, } @@ -70,7 +103,8 @@ pub(crate) trait OrdersDataSource { chain_ids: Option>, filters: &GetOrdersFilters, page: Option, - ) -> Result, RaindexError>; + page_size: Option, + ) -> Result; async fn get_by_hash( &self, @@ -831,13 +865,13 @@ impl RaindexClient { /// console.error("Error fetching orders:", result.error.readableMsg); /// return; /// } - /// const orders = result.value; + /// const { orders, totalCount } = result.value; /// // Do something with orders /// ``` #[wasm_export( js_name = "getOrders", - return_description = "Array of raindex order instances", - unchecked_return_type = "RaindexOrder[]", + return_description = "Orders list result with total count for pagination", + unchecked_return_type = "RaindexOrdersListResult", preserve_js_class )] pub async fn get_orders( @@ -853,33 +887,50 @@ impl RaindexClient { filters: Option, #[wasm_export(param_description = "Page number for pagination (optional, defaults to 1)")] page: Option, - ) -> Result, RaindexError> { + #[wasm_export( + js_name = "pageSize", + param_description = "Number of items per page (optional, defaults to 100)" + )] + page_size: Option, + ) -> Result { let filters = filters.unwrap_or_default(); - let page_number = page.unwrap_or(1); + let page_number = page.unwrap_or(1).max(1); + let page_size = page_size.unwrap_or(DEFAULT_PAGE_SIZE).max(1); let ids = chain_ids.map(|ChainIds(ids)| ids); let (local_db, local_ids, sg_ids) = self.classify_chains(ids)?; let mut all_orders = Vec::new(); + let mut total_count: u32 = 0; if let Some(db) = local_db { let local_source = LocalDbOrders::new(&db, ClientRef::new(self.clone())); - let orders = local_source - .list(Some(local_ids), &filters, Some(page_number)) + let result = local_source + .list( + Some(local_ids), + &filters, + Some(page_number), + Some(page_size), + ) .await?; - all_orders.extend(orders); + total_count += result.total_count; + all_orders.extend(result.orders); } if !sg_ids.is_empty() { let subgraph_source = SubgraphOrders::new(self); - let orders = subgraph_source - .list(Some(sg_ids), &filters, Some(page_number)) + let result = subgraph_source + .list(Some(sg_ids), &filters, Some(page_number), Some(page_size)) .await?; - all_orders.extend(orders); + total_count += result.total_count; + all_orders.extend(result.orders); } let orders = fetch_orders_dotrain_sources(all_orders).await?; - Ok(orders) + Ok(RaindexOrdersListResult { + orders, + total_count, + }) } /// Retrieves a specific order by its hash from a particular blockchain network @@ -945,7 +996,8 @@ impl OrdersDataSource for SubgraphOrders<'_> { chain_ids: Option>, filters: &GetOrdersFilters, page: Option, - ) -> Result, RaindexError> { + page_size: Option, + ) -> Result { let raindex_client = ClientRef::new(self.client.clone()); let multi_subgraph_args = self.client.get_multi_subgraph_args(chain_ids)?; @@ -953,12 +1005,21 @@ impl OrdersDataSource for SubgraphOrders<'_> { multi_subgraph_args.values().flatten().cloned().collect(), ); + let sg_filter_args: SgOrdersListFilterArgs = filters.clone().try_into()?; + let effective_page_size = page_size.unwrap_or(DEFAULT_PAGE_SIZE); + + let total_count = if page.is_some() { + client.orders_count(sg_filter_args.clone()).await? + } else { + 0 + }; + let orders = client .orders_list( - filters.clone().try_into()?, + sg_filter_args, SgPaginationArgs { page: page.unwrap_or(1), - page_size: DEFAULT_PAGE_SIZE, + page_size: effective_page_size, }, ) .await; @@ -983,7 +1044,10 @@ impl OrdersDataSource for SubgraphOrders<'_> { }) .collect::, RaindexError>>()?; - Ok(orders) + Ok(RaindexOrdersListResult { + orders, + total_count, + }) } async fn get_by_hash( @@ -1174,15 +1238,32 @@ impl RaindexClient { orderbook_addresses: None, }; - let orders = self - .get_orders(Some(ChainIds(vec![chain_id])), Some(filters), None) - .await?; + let ids = Some(vec![chain_id]); + let (local_db, local_ids, sg_ids) = self.classify_chains(ids)?; - if orders.is_empty() { + let mut all_orders = Vec::new(); + + if let Some(db) = local_db { + let local_source = LocalDbOrders::new(&db, ClientRef::new(self.clone())); + let result = local_source + .list(Some(local_ids), &filters, None, None) + .await?; + all_orders.extend(result.orders); + } + + if !sg_ids.is_empty() { + let subgraph_source = SubgraphOrders::new(self); + let result = subgraph_source + .list(Some(sg_ids), &filters, None, None) + .await?; + all_orders.extend(result.orders); + } + + if all_orders.is_empty() { return Err(RaindexError::NoLiquidity); } - Ok(orders) + Ok(all_orders) } } @@ -2279,11 +2360,12 @@ mod tests { .await .unwrap(); let result = raindex_client - .get_orders(None, Some(filter_args), Some(1)) + .get_orders(None, Some(filter_args), Some(1), None) .await .unwrap(); - assert_eq!(result.len(), 2); + assert_eq!(result.orders().len(), 2); + assert_eq!(result.total_count(), 2); let expected_order1 = RaindexOrder::try_from_sg_order( Arc::new(raindex_client.clone()), @@ -2293,7 +2375,7 @@ mod tests { ) .unwrap(); - let order1 = result[0].clone(); + let order1 = result.orders()[0].clone(); assert_eq!(order1.chain_id, expected_order1.chain_id); assert_eq!(order1.id, expected_order1.id); assert_eq!(order1.order_bytes, expected_order1.order_bytes); @@ -2349,7 +2431,7 @@ mod tests { assert_eq!(order1.orderbook(), expected_order1.orderbook()); assert_eq!(order1.timestamp_added(), expected_order1.timestamp_added()); - let order2 = result[1].clone(); + let order2 = result.orders()[1].clone(); assert_eq!(order2.chain_id, 137); assert_eq!( order2.id, @@ -2717,14 +2799,14 @@ mod tests { ) .await; - let orders_source = LocalDbOrders::new(&local_db, Arc::new(client.clone())); - let orders = orders_source - .list(Some(vec![137]), &GetOrdersFilters::default(), None) + let orders_source = LocalDbOrders::new(&local_db, ClientRef::new(client.clone())); + let result = orders_source + .list(Some(vec![137]), &GetOrdersFilters::default(), None, None) .await .unwrap(); - assert_eq!(orders.len(), 1); - let order = &orders[0]; + assert_eq!(result.orders().len(), 1); + let order = &result.orders()[0]; assert_eq!(order.inputs.len(), 1); assert_eq!(order.outputs.len(), 1); assert_eq!(order.trades_count, 2); @@ -2749,9 +2831,9 @@ mod tests { ) .await; - let orders_source = LocalDbOrders::new(&local_db, Arc::new(client.clone())); + let orders_source = LocalDbOrders::new(&local_db, ClientRef::new(client.clone())); let err = orders_source - .list(Some(vec![137]), &GetOrdersFilters::default(), None) + .list(Some(vec![137]), &GetOrdersFilters::default(), None, None) .await .unwrap_err(); match err { @@ -2817,12 +2899,13 @@ mod tests { Some(ChainIds(vec![137])), Some(GetOrdersFilters::default()), Some(1), + None, ) .await .unwrap(); - assert_eq!(result.len(), 1); + assert_eq!(result.orders().len(), 1); assert_eq!( - result[0].order_hash, + result.orders()[0].order_hash, b256!("0x0000000000000000000000000000000000000000000000000000000000002345") ); } diff --git a/crates/subgraph/src/multi_orderbook_client.rs b/crates/subgraph/src/multi_orderbook_client.rs index dcc17ff507..685a17d1be 100644 --- a/crates/subgraph/src/multi_orderbook_client.rs +++ b/crates/subgraph/src/multi_orderbook_client.rs @@ -70,6 +70,27 @@ impl MultiOrderbookSubgraphClient { all_orders } + pub async fn orders_count( + &self, + filter_args: SgOrdersListFilterArgs, + ) -> Result { + let futures = self.subgraphs.iter().map(|subgraph| { + let url = subgraph.url.clone(); + let filter_args = filter_args.clone(); + async move { + let client = self.get_orderbook_subgraph_client(url); + client.orders_count(filter_args).await + } + }); + + let results = join_all(futures).await; + let mut total: u32 = 0; + for result in results { + total += result?; + } + Ok(total) + } + pub async fn vaults_list( &self, filter_args: SgVaultsListFilterArgs, @@ -458,6 +479,178 @@ mod tests { assert_eq!(order_ids_sorted[4], order_d.id); } + #[tokio::test] + async fn test_orders_count_no_subgraphs() { + let client = MultiOrderbookSubgraphClient::new(vec![]); + let count = client.orders_count(default_filter_args()).await.unwrap(); + assert_eq!(count, 0); + } + + #[tokio::test] + async fn test_orders_count_one_subgraph() { + let server1 = MockServer::start_async().await; + let sg1_url = Url::parse(&server1.url("")).unwrap(); + + let orders: Vec<_> = (0..3) + .map(|i| sample_sg_order(&format!("c_{}", i), "100")) + .collect(); + server1.mock(|when, then| { + when.method(POST).path("/"); + then.status(200) + .json_body(json!({"data": {"orders": orders}})); + }); + + let client = MultiOrderbookSubgraphClient::new(vec![MultiSubgraphArgs { + url: sg1_url, + name: "sg_one".to_string(), + }]); + + let count = client.orders_count(default_filter_args()).await.unwrap(); + assert_eq!(count, 3); + } + + #[tokio::test] + async fn test_orders_count_multiple_subgraphs_sums() { + let server1 = MockServer::start_async().await; + let sg1_url = Url::parse(&server1.url("")).unwrap(); + let server2 = MockServer::start_async().await; + let sg2_url = Url::parse(&server2.url("")).unwrap(); + + let orders_s1: Vec<_> = (0..2) + .map(|i| sample_sg_order(&format!("s1_{}", i), "100")) + .collect(); + let orders_s2: Vec<_> = (0..5) + .map(|i| sample_sg_order(&format!("s2_{}", i), "200")) + .collect(); + + server1.mock(|when, then| { + when.method(POST).path("/"); + then.status(200) + .json_body(json!({"data": {"orders": orders_s1}})); + }); + server2.mock(|when, then| { + when.method(POST).path("/"); + then.status(200) + .json_body(json!({"data": {"orders": orders_s2}})); + }); + + let client = MultiOrderbookSubgraphClient::new(vec![ + MultiSubgraphArgs { + url: sg1_url, + name: "sg_one".to_string(), + }, + MultiSubgraphArgs { + url: sg2_url, + name: "sg_two".to_string(), + }, + ]); + + let count = client.orders_count(default_filter_args()).await.unwrap(); + assert_eq!(count, 7); + } + + #[tokio::test] + async fn test_orders_count_one_subgraph_errors_propagates() { + let server1 = MockServer::start_async().await; + let sg1_url = Url::parse(&server1.url("")).unwrap(); + let server2 = MockServer::start_async().await; + let sg2_url = Url::parse(&server2.url("")).unwrap(); + + let orders_s1: Vec<_> = (0..4) + .map(|i| sample_sg_order(&format!("s1_{}", i), "100")) + .collect(); + server1.mock(|when, then| { + when.method(POST).path("/"); + then.status(200) + .json_body(json!({"data": {"orders": orders_s1}})); + }); + server2.mock(|when, then| { + when.method(POST).path("/"); + then.status(500); + }); + + let client = MultiOrderbookSubgraphClient::new(vec![ + MultiSubgraphArgs { + url: sg1_url, + name: "sg_one".to_string(), + }, + MultiSubgraphArgs { + url: sg2_url, + name: "sg_two_err".to_string(), + }, + ]); + + let result = client.orders_count(default_filter_args()).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_orders_count_all_subgraphs_error() { + let server1 = MockServer::start_async().await; + let sg1_url = Url::parse(&server1.url("")).unwrap(); + let server2 = MockServer::start_async().await; + let sg2_url = Url::parse(&server2.url("")).unwrap(); + + server1.mock(|when, then| { + when.method(POST).path("/"); + then.status(500); + }); + server2.mock(|when, then| { + when.method(POST).path("/"); + then.status(500); + }); + + let client = MultiOrderbookSubgraphClient::new(vec![ + MultiSubgraphArgs { + url: sg1_url, + name: "sg_one_err".to_string(), + }, + MultiSubgraphArgs { + url: sg2_url, + name: "sg_two_err".to_string(), + }, + ]); + + let result = client.orders_count(default_filter_args()).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_orders_count_pagination_boundary() { + use crate::orderbook_client::ALL_PAGES_QUERY_PAGE_SIZE; + + let server = MockServer::start_async().await; + let sg_url = Url::parse(&server.url("")).unwrap(); + + let page1_orders: Vec<_> = (0..ALL_PAGES_QUERY_PAGE_SIZE) + .map(|i| sample_sg_order(&format!("p1_{}", i), "100")) + .collect(); + let page2_orders: Vec<_> = (0..10) + .map(|i| sample_sg_order(&format!("p2_{}", i), "100")) + .collect(); + + server.mock(|when, then| { + when.method(POST).path("/").body_contains("\"skip\":0"); + then.status(200) + .json_body(json!({"data": {"orders": page1_orders}})); + }); + server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(format!("\"skip\":{}", ALL_PAGES_QUERY_PAGE_SIZE)); + then.status(200) + .json_body(json!({"data": {"orders": page2_orders}})); + }); + + let client = MultiOrderbookSubgraphClient::new(vec![MultiSubgraphArgs { + url: sg_url, + name: "sg_one".to_string(), + }]); + + let count = client.orders_count(default_filter_args()).await.unwrap(); + assert_eq!(count, ALL_PAGES_QUERY_PAGE_SIZE as u32 + 10); + } + fn sample_sg_erc20(id_suffix: &str) -> SgErc20 { SgErc20 { id: SgBytes(format!("0xtoken_id_{}", id_suffix)), diff --git a/crates/subgraph/src/orderbook_client/order.rs b/crates/subgraph/src/orderbook_client/order.rs index 7d517dd78d..d0ee69ece8 100644 --- a/crates/subgraph/src/orderbook_client/order.rs +++ b/crates/subgraph/src/orderbook_client/order.rs @@ -111,35 +111,51 @@ impl OrderbookSubgraphClient { } /// Fetch all pages of orders_list query - pub async fn orders_list_all(&self) -> Result, OrderbookSubgraphClientError> { + async fn fetch_all_orders_pages( + &self, + filter_args: SgOrdersListFilterArgs, + ) -> Result, OrderbookSubgraphClientError> { let mut all_pages_merged = vec![]; - let mut page = 1; + let mut page: u16 = 1; loop { let page_data = self .orders_list( - SgOrdersListFilterArgs { - owners: vec![], - active: None, - order_hash: None, - tokens: None, - orderbooks: vec![], - }, + filter_args.clone(), SgPaginationArgs { page, page_size: ALL_PAGES_QUERY_PAGE_SIZE, }, ) .await?; - if page_data.is_empty() { + let batch_len = page_data.len(); + all_pages_merged.extend(page_data); + if (batch_len as u16) < ALL_PAGES_QUERY_PAGE_SIZE { break; } - all_pages_merged.extend(page_data); - page += 1 + page += 1; } Ok(all_pages_merged) } + pub async fn orders_list_all(&self) -> Result, OrderbookSubgraphClientError> { + self.fetch_all_orders_pages(SgOrdersListFilterArgs { + owners: vec![], + active: None, + order_hash: None, + tokens: None, + orderbooks: vec![], + }) + .await + } + + pub async fn orders_count( + &self, + filter_args: SgOrdersListFilterArgs, + ) -> Result { + Ok(self.fetch_all_orders_pages(filter_args).await?.len() as u32) + } + /// Fetch single order given its hash pub async fn order_detail_by_hash( &self, @@ -684,6 +700,86 @@ mod tests { )); } + fn default_filter_args() -> SgOrdersListFilterArgs { + SgOrdersListFilterArgs { + owners: vec![], + active: None, + order_hash: None, + tokens: None, + orderbooks: vec![], + } + } + + #[tokio::test] + async fn test_orders_count_single_page() { + let sg_server = MockServer::start_async().await; + let client = setup_client(&sg_server); + let orders: Vec = (0..5).map(|_| default_sg_order()).collect(); + + sg_server.mock(|when, then| { + when.method(POST).path("/"); + then.status(200) + .json_body(json!({"data": {"orders": orders}})); + }); + + let count = client.orders_count(default_filter_args()).await.unwrap(); + assert_eq!(count, 5); + } + + #[tokio::test] + async fn test_orders_count_multiple_pages() { + let sg_server = MockServer::start_async().await; + let client = setup_client(&sg_server); + let orders_page1: Vec = (0..ALL_PAGES_QUERY_PAGE_SIZE) + .map(|_| default_sg_order()) + .collect(); + let orders_page2: Vec = (0..50).map(|_| default_sg_order()).collect(); + + sg_server.mock(|when, then| { + when.method(POST).path("/").body_contains("\"skip\":0"); + then.status(200) + .json_body(json!({"data": {"orders": orders_page1}})); + }); + sg_server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(format!("\"skip\":{}", ALL_PAGES_QUERY_PAGE_SIZE)); + then.status(200) + .json_body(json!({"data": {"orders": orders_page2}})); + }); + + let count = client.orders_count(default_filter_args()).await.unwrap(); + assert_eq!(count, ALL_PAGES_QUERY_PAGE_SIZE as u32 + 50); + } + + #[tokio::test] + async fn test_orders_count_empty() { + let sg_server = MockServer::start_async().await; + let client = setup_client(&sg_server); + + sg_server.mock(|when, then| { + when.method(POST).path("/"); + then.status(200).json_body(json!({"data": {"orders": []}})); + }); + + let count = client.orders_count(default_filter_args()).await.unwrap(); + assert_eq!(count, 0); + } + + #[tokio::test] + async fn test_orders_count_network_error() { + let sg_server = MockServer::start_async().await; + let client = setup_client(&sg_server); + + sg_server.mock(|when, then| { + when.method(POST).path("/"); + then.status(500); + }); + + let result = client.orders_count(default_filter_args()).await; + assert!(result.is_err()); + } + #[tokio::test] async fn test_order_detail_by_hash_found() { let sg_server = MockServer::start_async().await; diff --git a/packages/orderbook/test/js_api/raindexClient.test.ts b/packages/orderbook/test/js_api/raindexClient.test.ts index adfd409a53..1199ae7e5d 100644 --- a/packages/orderbook/test/js_api/raindexClient.test.ts +++ b/packages/orderbook/test/js_api/raindexClient.test.ts @@ -627,14 +627,16 @@ describe('Rain Orderbook JS API Package Bindgen Tests - Raindex Client', async f const raindexClient = extractWasmEncodedData(await RaindexClient.new([YAML])); - let orders = extractWasmEncodedData(await raindexClient.getOrders()); - assert.equal(orders.length, 2); - assert.equal(orders[0].id, order1.id); - assert.equal(orders[1].id, order2.id); - - orders = extractWasmEncodedData(await raindexClient.getOrders([1])); - assert.equal(orders.length, 1); - assert.equal(orders[0].id, order1.id); + let result = extractWasmEncodedData(await raindexClient.getOrders()); + assert.equal(result.orders.length, 2); + assert.equal(result.orders[0].id, order1.id); + assert.equal(result.orders[1].id, order2.id); + assert.equal(result.totalCount, 2); + + result = extractWasmEncodedData(await raindexClient.getOrders([1])); + assert.equal(result.orders.length, 1); + assert.equal(result.orders[0].id, order1.id); + assert.equal(result.totalCount, 1); }); it('should get order by hash', async function () { diff --git a/packages/ui-components/src/__tests__/OrdersListTable.test.ts b/packages/ui-components/src/__tests__/OrdersListTable.test.ts index 62e448fd96..0bc00b39f5 100644 --- a/packages/ui-components/src/__tests__/OrdersListTable.test.ts +++ b/packages/ui-components/src/__tests__/OrdersListTable.test.ts @@ -143,7 +143,7 @@ describe('OrdersListTable', () => { error: undefined }) }); - mockGetOrders.mockResolvedValue({ value: [], error: undefined }); + mockGetOrders.mockResolvedValue({ value: { orders: [], totalCount: 0 }, error: undefined }); mockGetTokens.mockResolvedValue({ value: [], error: undefined }); }); @@ -153,7 +153,7 @@ describe('OrdersListTable', () => { mockQuery.createInfiniteQuery = vi.fn((__options, _queryClient) => ({ subscribe: (fn: (value: any) => void) => { fn({ - data: { pages: [[mockOrder]] }, + data: { pages: [{ orders: [mockOrder], totalCount: 1 }] }, status: 'success', isFetching: false, isFetched: true @@ -191,7 +191,7 @@ describe('OrdersListTable', () => { mockQuery.createInfiniteQuery = vi.fn((__options, _queryClient) => ({ subscribe: (fn: (value: any) => void) => { fn({ - data: { pages: [[mockOrder]] }, + data: { pages: [{ orders: [mockOrder], totalCount: 1 }] }, status: 'success', isFetching: false, isFetched: true @@ -271,7 +271,7 @@ describe('OrdersListTable', () => { mockQuery.createInfiniteQuery = vi.fn((__options, _queryClient) => ({ subscribe: (fn: (value: any) => void) => { fn({ - data: { pages: [[orderWithMultipleTokens]] }, + data: { pages: [{ orders: [orderWithMultipleTokens], totalCount: 1 }] }, status: 'success', isFetching: false, isFetched: true @@ -354,7 +354,7 @@ describe('OrdersListTable', () => { mockQuery.createInfiniteQuery = vi.fn((__options, _queryClient) => ({ subscribe: (fn: (value: any) => void) => { fn({ - data: { pages: [[orderWithMultipleTokens]] }, + data: { pages: [{ orders: [orderWithMultipleTokens], totalCount: 1 }] }, status: 'success', isFetching: false, isFetched: true @@ -391,7 +391,7 @@ describe('OrdersListTable', () => { mockQuery.createInfiniteQuery = vi.fn((__options, _queryClient) => ({ subscribe: (fn: (value: any) => void) => { fn({ - data: { pages: [[inactiveOrder]] }, + data: { pages: [{ orders: [inactiveOrder], totalCount: 1 }] }, status: 'success', isFetching: false, isFetched: true @@ -411,7 +411,7 @@ describe('OrdersListTable', () => { mockQuery.createInfiniteQuery = vi.fn((__options, _queryClient) => ({ subscribe: (fn: (value: any) => void) => { fn({ - data: { pages: [[]] }, + data: { pages: [{ orders: [], totalCount: 0 }] }, status: 'success', isFetching: false, isFetched: true @@ -435,7 +435,7 @@ describe('OrdersListTable', () => { mockQuery.createInfiniteQuery = vi.fn((__options, _queryClient) => ({ subscribe: (fn: (value: any) => void) => { fn({ - data: { pages: [[mockOrder]] }, + data: { pages: [{ orders: [mockOrder], totalCount: 1 }] }, status: 'success', isFetching: false, isFetched: true @@ -474,7 +474,7 @@ describe('OrdersListTable', () => { mockQuery.createInfiniteQuery = vi.fn((__options, _queryClient) => ({ subscribe: (fn: (value: any) => void) => { fn({ - data: { pages: [[orderWithManyTrades]] }, + data: { pages: [{ orders: [orderWithManyTrades], totalCount: 1 }] }, status: 'success', isFetching: false, isFetched: true @@ -493,7 +493,7 @@ describe('OrdersListTable', () => { mockQuery.createInfiniteQuery = vi.fn((__options, _queryClient) => ({ subscribe: (fn: (value: any) => void) => { fn({ - data: { pages: [[mockOrder]] }, + data: { pages: [{ orders: [mockOrder], totalCount: 1 }] }, status: 'success', isFetching: false, isFetched: true @@ -521,7 +521,7 @@ describe('OrdersListTable', () => { mockQuery.createInfiniteQuery = vi.fn((__options, _queryClient) => ({ subscribe: (fn: (value: any) => void) => { fn({ - data: { pages: [[inactiveOrder]] }, + data: { pages: [{ orders: [inactiveOrder], totalCount: 1 }] }, status: 'success', isFetching: false, isFetched: true @@ -545,7 +545,7 @@ describe('OrdersListTable', () => { mockQuery.createInfiniteQuery = vi.fn((__options, _queryClient) => ({ subscribe: (fn: (value: any) => void) => { fn({ - data: { pages: [[mockOrder]] }, + data: { pages: [{ orders: [mockOrder], totalCount: 1 }] }, status: 'success', isFetching: false, isFetched: true, @@ -582,7 +582,7 @@ describe('OrdersListTable', () => { return { subscribe: (fn: (value: any) => void) => { fn({ - data: { pages: [[]] }, + data: { pages: [{ orders: [], totalCount: 0 }] }, status: 'success', isFetching: false, isFetched: true @@ -617,7 +617,7 @@ describe('OrdersListTable', () => { return { subscribe: (fn: (value: any) => void) => { fn({ - data: { pages: [[]] }, + data: { pages: [{ orders: [], totalCount: 0 }] }, status: 'success', isFetching: false, isFetched: true diff --git a/packages/ui-components/src/lib/components/tables/OrdersListTable.svelte b/packages/ui-components/src/lib/components/tables/OrdersListTable.svelte index f546319c4f..ae5f3a3fc6 100644 --- a/packages/ui-components/src/lib/components/tables/OrdersListTable.svelte +++ b/packages/ui-components/src/lib/components/tables/OrdersListTable.svelte @@ -107,13 +107,14 @@ }, initialPageParam: 0, getNextPageParam(lastPage, _allPages, lastPageParam) { - return lastPage.length === DEFAULT_PAGE_SIZE ? lastPageParam + 1 : undefined; + return lastPage.orders.length === DEFAULT_PAGE_SIZE ? lastPageParam + 1 : undefined; }, refetchInterval: DEFAULT_REFRESH_INTERVAL, enabled: true }); - const AppTable = TanstackAppTable; + type OrdersListResult = { orders: RaindexOrder[]; totalCount: number }; + const AppTable = TanstackAppTable; page.orders} on:clickRow={(e) => { goto(`/orders/${e.detail.item.chainId}-${e.detail.item.orderbook}-${e.detail.item.orderHash}`); }}