Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/wasm-browser-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,5 @@ jobs:
wasm-pack test --headless --chrome --features browser-tests -- status::wasm::wasm_tests
wasm-pack test --headless --chrome --features browser-tests -- retry::wasm_tests
wasm-pack test --headless --chrome --features browser-tests -- raindex_client::local_db::wasm_tests
wasm-pack test --headless --chrome --features browser-tests -- raindex_client::local_db::query::fetch_order_trades::wasm_tests
'
204 changes: 175 additions & 29 deletions crates/common/src/local_db/query/fetch_order_trades/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,78 @@ pub struct LocalDbOrderTrade {
pub trade_id: String,
}

/// Builds the SQL statement for retrieving order trades within the specified window.
const ORDER_HASH_CLAUSE: &str = "/*ORDER_HASH_CLAUSE*/";
const ORDER_HASH_LIST_BODY: &str = "AND tws.order_hash IN ({list})";
/// Match-NONE predicate emitted for an empty `These` filter. SQLite rejects the
/// degenerate `IN ()` form, so we splice a constant-false predicate that the
/// query optimizer prunes to zero rows. This makes "filter to exactly these
/// (none) hashes" return no rows — the deliberate opposite of `All`.
const ORDER_HASH_MATCH_NONE_BODY: &str = "AND 1=0";

const START_TS_CLAUSE: &str = "/*START_TS_CLAUSE*/";
const START_TS_BODY: &str = "\n AND tws.block_timestamp >= {param}\n";

const END_TS_CLAUSE: &str = "/*END_TS_CLAUSE*/";
const END_TS_BODY: &str = "\n AND tws.block_timestamp <= {param}\n";

pub fn build_fetch_order_trades_stmt(
/// Explicit selection of which orders' trades a fetch covers, so the builder
/// never has to read "all" out of an empty slice.
///
/// - [`OrderHashFilter::All`] emits no order-hash predicate at all, so every
/// order's trades for the chain/raindex (within the optional time window) are
/// returned.
/// - [`OrderHashFilter::These`] filters to exactly the given hashes via
/// `WHERE order_hash IN (...)`. An empty slice means *none*: it emits a
/// match-NONE predicate and returns zero rows. It is the deliberate opposite
/// of `All`, not a synonym for it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrderHashFilter<'a> {
/// No order-hash predicate: trades for every order are returned.
All,
/// Trades for exactly these order hashes. Empty = none (zero rows), never
/// all.
These(&'a [B256]),
}

/// Builds the SQL statement for retrieving order trades within the specified
/// window. The `filter` explicitly selects which orders are covered, so trades
/// for one or many orders are fetched in a single query (eliminating the N+1
/// query pattern and per-query connection overhead). The single-order path
/// passes `These(&[hash])`.
///
/// The order-hash predicate rendered depends on `filter`:
/// - [`OrderHashFilter::All`] => no order-hash predicate (every order).
/// - [`OrderHashFilter::These`] non-empty => `AND order_hash IN (...)`.
/// - [`OrderHashFilter::These`] empty => `AND 1=0` (match nothing): an empty
/// `These` is *none*, never all. SQLite rejects `IN ()`, so the constant-false
/// predicate stands in for it.
pub fn build_fetch_order_trades_batch_stmt(
raindex_id: &RaindexIdentifier,
order_hash: B256,
filter: OrderHashFilter<'_>,
start_timestamp: Option<u64>,
end_timestamp: Option<u64>,
) -> Result<SqlStatement, SqlBuildError> {
let mut stmt = SqlStatement::new(QUERY_TEMPLATE);
stmt.push(SqlValue::from(raindex_id.chain_id));
stmt.push(SqlValue::from(raindex_id.raindex_address));
stmt.push(SqlValue::from(order_hash));
match filter {
OrderHashFilter::All => {
// No order-hash predicate: drop the marker, keep all orders.
stmt.replace(ORDER_HASH_CLAUSE, "")?;
}
OrderHashFilter::These([]) => {
// Empty `These` means none. `IN ()` is invalid in SQLite, so splice
// a constant-false predicate that yields zero rows.
stmt.replace(ORDER_HASH_CLAUSE, ORDER_HASH_MATCH_NONE_BODY)?;
}
OrderHashFilter::These(hashes) => {
stmt.bind_list_clause(
ORDER_HASH_CLAUSE,
ORDER_HASH_LIST_BODY,
hashes.iter().copied().map(SqlValue::from),
)?;
}
}

// Optional time filters
let start_param = if let Some(v) = start_timestamp {
Expand Down Expand Up @@ -91,53 +146,144 @@ mod tests {
};

#[test]
fn builds_with_chain_id_and_filters() {
let order_hash =
b256!("0x00000000000000000000000000000000000000000000000000000000deadface");
let stmt = build_fetch_order_trades_stmt(
fn batch_builds_in_clause_with_time_filters() {
let hash_a = b256!("0x00000000000000000000000000000000000000000000000000000000deadbeef");
let hash_b = b256!("0x00000000000000000000000000000000000000000000000000000000deadface");
let stmt = build_fetch_order_trades_batch_stmt(
&RaindexIdentifier::new(137, Address::ZERO),
order_hash,
OrderHashFilter::These(&[hash_a, hash_b]),
Some(11),
Some(22),
)
.unwrap();
// Dynamic param clauses inserted

// Marker replaced and an IN list (not an equality) is rendered.
assert!(!stmt.sql.contains(ORDER_HASH_CLAUSE));
assert!(stmt.sql.contains("tws.order_hash IN (?3, ?4)"));
assert!(!stmt.sql.contains("tws.order_hash = "));

// Time filters still bound after the order-hash list.
assert!(!stmt.sql.contains(START_TS_CLAUSE));
assert!(!stmt.sql.contains(END_TS_CLAUSE));
assert!(stmt.sql.contains("tws.block_timestamp >="));
assert!(stmt.sql.contains("tws.block_timestamp <="));
// First three fixed params: chain id (?1), raindex address (?2), order hash (?3)
assert_eq!(stmt.params.len(), 5); // includes start and end
assert!(stmt.sql.contains("tws.block_timestamp >= ?5"));
assert!(stmt.sql.contains("tws.block_timestamp <= ?6"));

// Params: chain id, raindex, hash_a, hash_b, start, end
assert_eq!(stmt.params.len(), 6);
assert_eq!(stmt.params[0], SqlValue::U64(137));
assert_eq!(stmt.params[1], SqlValue::Text(Address::ZERO.to_string()));
assert_eq!(
stmt.params[2],
SqlValue::Text(hex::encode_prefixed(order_hash))
);
assert_eq!(stmt.params[2], SqlValue::Text(hex::encode_prefixed(hash_a)));
assert_eq!(stmt.params[3], SqlValue::Text(hex::encode_prefixed(hash_b)));
assert_eq!(stmt.params[4], SqlValue::I64(11));
assert_eq!(stmt.params[5], SqlValue::I64(22));
}

#[test]
fn builds_without_time_filters_when_none() {
let order_hash =
b256!("0x00000000000000000000000000000000000000000000000000000000deadbeef");
let stmt = build_fetch_order_trades_stmt(
fn batch_single_hash_renders_single_placeholder_in_clause() {
let hash = b256!("0x00000000000000000000000000000000000000000000000000000000deadbeef");
let stmt = build_fetch_order_trades_batch_stmt(
&RaindexIdentifier::new(1, Address::ZERO),
order_hash,
OrderHashFilter::These(&[hash]),
None,
None,
)
.unwrap();

assert!(stmt.sql.contains("tws.order_hash IN (?3)"));
// A one-element list never collapses to the old `= ?` equality form.
assert!(!stmt.sql.contains("tws.order_hash = "));
assert!(!stmt.sql.contains("tws.block_timestamp >="));
assert!(!stmt.sql.contains("tws.block_timestamp <="));
assert_eq!(stmt.params.len(), 3);
// Fixed params: chain id (?1), raindex (?2), order hash (?3).
assert_eq!(stmt.params[0], SqlValue::U64(1));
assert_eq!(stmt.params[1], SqlValue::Text(Address::ZERO.to_string()));
assert_eq!(stmt.params[2], SqlValue::Text(hex::encode_prefixed(hash)));
}

#[test]
fn batch_single_hash_with_time_filters_binds_window() {
// The single-order path (a one-element slice) carries the same
// time-window binding semantics the removed single-order builder had:
// chain id (?1), raindex (?2), order hash (?3), start (?4), end (?5).
let hash = b256!("0x00000000000000000000000000000000000000000000000000000000deadface");
let stmt = build_fetch_order_trades_batch_stmt(
&RaindexIdentifier::new(137, Address::ZERO),
OrderHashFilter::These(&[hash]),
Some(11),
Some(22),
)
.unwrap();

assert!(!stmt.sql.contains(ORDER_HASH_CLAUSE));
assert!(!stmt.sql.contains(START_TS_CLAUSE));
assert!(!stmt.sql.contains(END_TS_CLAUSE));
assert_eq!(stmt.params.len(), 3);
// Order of fixed params: chain id (?1), raindex (?2), order hash (?3)
assert!(stmt.sql.contains("tws.order_hash IN (?3)"));
assert!(!stmt.sql.contains("tws.order_hash = "));
assert!(stmt.sql.contains("tws.block_timestamp >= ?4"));
assert!(stmt.sql.contains("tws.block_timestamp <= ?5"));

assert_eq!(stmt.params.len(), 5);
assert_eq!(stmt.params[0], SqlValue::U64(137));
assert_eq!(stmt.params[1], SqlValue::Text(Address::ZERO.to_string()));
assert_eq!(stmt.params[2], SqlValue::Text(hex::encode_prefixed(hash)));
assert_eq!(stmt.params[3], SqlValue::I64(11));
assert_eq!(stmt.params[4], SqlValue::I64(22));
}

#[test]
fn all_emits_no_order_hash_predicate() {
// `All` is the only variant that drops the order-hash predicate: no IN
// list and no match-NONE constant. Every order is returned.
let stmt = build_fetch_order_trades_batch_stmt(
&RaindexIdentifier::new(1, Address::ZERO),
OrderHashFilter::All,
None,
None,
)
.unwrap();

// The marker is consumed and *no* order-hash predicate is rendered (the
// SELECT list still projects tws.order_hash, so only the predicate forms
// are asserted absent).
assert!(!stmt.sql.contains(ORDER_HASH_CLAUSE));
assert!(!stmt.sql.contains("tws.order_hash IN ("));
assert!(!stmt.sql.contains("tws.order_hash = "));
// Crucially, `All` does NOT emit the match-NONE predicate that empty
// `These` does — the two are opposites.
assert!(!stmt.sql.contains("1=0"));
// Only the two fixed params (chain id, raindex) remain.
assert_eq!(stmt.params.len(), 2);
assert_eq!(stmt.params[0], SqlValue::U64(1));
assert_eq!(stmt.params[1], SqlValue::Text(Address::ZERO.to_string()));
}

#[test]
fn these_empty_emits_match_none_predicate_not_dropped_clause() {
// An empty `These` means *none*: it emits the constant-false predicate
// `AND 1=0` so zero rows match. This is the deliberate opposite of `All`
// (which would drop the clause and return every order). It must NOT
// degenerate to "all".
let stmt = build_fetch_order_trades_batch_stmt(
&RaindexIdentifier::new(1, Address::ZERO),
OrderHashFilter::These(&[]),
None,
None,
)
.unwrap();

// The match-NONE predicate is present...
assert!(stmt.sql.contains("1=0"));
// ...and the marker is consumed (not left unsubstituted).
assert!(!stmt.sql.contains(ORDER_HASH_CLAUSE));
// No IN list / equality predicate and no bound hashes: empty These binds
// zero placeholders.
assert!(!stmt.sql.contains("tws.order_hash IN ("));
assert!(!stmt.sql.contains("tws.order_hash = "));
// Only the two fixed params (chain id, raindex) — the constant-false
// predicate binds nothing.
assert_eq!(stmt.params.len(), 2);
assert_eq!(stmt.params[0], SqlValue::U64(1));
assert_eq!(stmt.params[1], SqlValue::Text(Address::ZERO.to_string()));
assert_eq!(
stmt.params[2],
SqlValue::Text(hex::encode_prefixed(order_hash))
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ WITH filtered_trades AS (
FROM derived_trades tws
WHERE tws.chain_id = ?1
AND tws.raindex_address = ?2
AND tws.order_hash = ?3
/*ORDER_HASH_CLAUSE*/
/*START_TS_CLAUSE*/
/*END_TS_CLAUSE*/
ORDER BY tws.block_timestamp DESC, tws.block_number DESC, tws.log_index DESC, tws.trade_kind, tws.trade_side
Expand Down
Loading
Loading