Skip to content

Commit 029b349

Browse files
Make trade-fetch order-hash 'all' an explicit OrderHashFilter variant
Replace the implicit "empty slice means all trades" footgun in build_fetch_order_trades_batch_stmt with an explicit OrderHashFilter enum: All drops the order-hash predicate (every order), while These(&[..]) filters to exactly those hashes. An empty These now means *none* — it emits a constant-false 'AND 1=0' predicate (SQLite rejects 'IN ()') so empty filters return zero rows instead of silently fetching all. All and These(empty) are explicit opposites. The fetch_order_trades_batch empty-input short-circuit is retained but re-documented as a perf optimization (skip the DB round-trip) rather than a correctness guard, since These(&[]) would already return no rows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fda01b2 commit 029b349

2 files changed

Lines changed: 104 additions & 27 deletions

File tree

crates/common/src/local_db/query/fetch_order_trades/mod.rs

Lines changed: 96 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -39,37 +39,76 @@ pub struct LocalDbOrderTrade {
3939

4040
const ORDER_HASH_CLAUSE: &str = "/*ORDER_HASH_CLAUSE*/";
4141
const ORDER_HASH_LIST_BODY: &str = "AND tws.order_hash IN ({list})";
42+
/// Match-NONE predicate emitted for an empty `These` filter. SQLite rejects the
43+
/// degenerate `IN ()` form, so we splice a constant-false predicate that the
44+
/// query optimizer prunes to zero rows. This makes "filter to exactly these
45+
/// (none) hashes" return no rows — the deliberate opposite of `All`.
46+
const ORDER_HASH_MATCH_NONE_BODY: &str = "AND 1=0";
4247

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

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

54+
/// Explicit selection of which orders' trades a fetch covers, so the builder
55+
/// never has to read "all" out of an empty slice.
56+
///
57+
/// - [`OrderHashFilter::All`] emits no order-hash predicate at all, so every
58+
/// order's trades for the chain/raindex (within the optional time window) are
59+
/// returned.
60+
/// - [`OrderHashFilter::These`] filters to exactly the given hashes via
61+
/// `WHERE order_hash IN (...)`. An empty slice means *none*: it emits a
62+
/// match-NONE predicate and returns zero rows. It is the deliberate opposite
63+
/// of `All`, not a synonym for it.
64+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65+
pub enum OrderHashFilter<'a> {
66+
/// No order-hash predicate: trades for every order are returned.
67+
All,
68+
/// Trades for exactly these order hashes. Empty = none (zero rows), never
69+
/// all.
70+
These(&'a [B256]),
71+
}
72+
4973
/// Builds the SQL statement for retrieving order trades within the specified
50-
/// window. Accepts a slice of order hashes and emits a single query with a
51-
/// `WHERE order_hash IN (...)` clause, so trades for one or many orders are
52-
/// fetched in a single query (eliminating the N+1 query pattern and per-query
53-
/// connection overhead). The single-order path passes a one-element slice.
74+
/// window. The `filter` explicitly selects which orders are covered, so trades
75+
/// for one or many orders are fetched in a single query (eliminating the N+1
76+
/// query pattern and per-query connection overhead). The single-order path
77+
/// passes `These(&[hash])`.
5478
///
55-
/// When `order_hashes` is empty the order-hash clause is removed entirely, so
56-
/// the query degenerates to "all trades for this chain/raindex" within the
57-
/// optional time window. Callers that want an empty result for an empty input
58-
/// should short-circuit before invoking this builder.
79+
/// The order-hash predicate rendered depends on `filter`:
80+
/// - [`OrderHashFilter::All`] => no order-hash predicate (every order).
81+
/// - [`OrderHashFilter::These`] non-empty => `AND order_hash IN (...)`.
82+
/// - [`OrderHashFilter::These`] empty => `AND 1=0` (match nothing): an empty
83+
/// `These` is *none*, never all. SQLite rejects `IN ()`, so the constant-false
84+
/// predicate stands in for it.
5985
pub fn build_fetch_order_trades_batch_stmt(
6086
raindex_id: &RaindexIdentifier,
61-
order_hashes: &[B256],
87+
filter: OrderHashFilter<'_>,
6288
start_timestamp: Option<u64>,
6389
end_timestamp: Option<u64>,
6490
) -> Result<SqlStatement, SqlBuildError> {
6591
let mut stmt = SqlStatement::new(QUERY_TEMPLATE);
6692
stmt.push(SqlValue::from(raindex_id.chain_id));
6793
stmt.push(SqlValue::from(raindex_id.raindex_address));
68-
stmt.bind_list_clause(
69-
ORDER_HASH_CLAUSE,
70-
ORDER_HASH_LIST_BODY,
71-
order_hashes.iter().copied().map(SqlValue::from),
72-
)?;
94+
match filter {
95+
OrderHashFilter::All => {
96+
// No order-hash predicate: drop the marker, keep all orders.
97+
stmt.replace(ORDER_HASH_CLAUSE, "")?;
98+
}
99+
OrderHashFilter::These([]) => {
100+
// Empty `These` means none. `IN ()` is invalid in SQLite, so splice
101+
// a constant-false predicate that yields zero rows.
102+
stmt.replace(ORDER_HASH_CLAUSE, ORDER_HASH_MATCH_NONE_BODY)?;
103+
}
104+
OrderHashFilter::These(hashes) => {
105+
stmt.bind_list_clause(
106+
ORDER_HASH_CLAUSE,
107+
ORDER_HASH_LIST_BODY,
108+
hashes.iter().copied().map(SqlValue::from),
109+
)?;
110+
}
111+
}
73112

74113
// Optional time filters
75114
let start_param = if let Some(v) = start_timestamp {
@@ -112,7 +151,7 @@ mod tests {
112151
let hash_b = b256!("0x00000000000000000000000000000000000000000000000000000000deadface");
113152
let stmt = build_fetch_order_trades_batch_stmt(
114153
&RaindexIdentifier::new(137, Address::ZERO),
115-
&[hash_a, hash_b],
154+
OrderHashFilter::These(&[hash_a, hash_b]),
116155
Some(11),
117156
Some(22),
118157
)
@@ -144,7 +183,7 @@ mod tests {
144183
let hash = b256!("0x00000000000000000000000000000000000000000000000000000000deadbeef");
145184
let stmt = build_fetch_order_trades_batch_stmt(
146185
&RaindexIdentifier::new(1, Address::ZERO),
147-
&[hash],
186+
OrderHashFilter::These(&[hash]),
148187
None,
149188
None,
150189
)
@@ -170,7 +209,7 @@ mod tests {
170209
let hash = b256!("0x00000000000000000000000000000000000000000000000000000000deadface");
171210
let stmt = build_fetch_order_trades_batch_stmt(
172211
&RaindexIdentifier::new(137, Address::ZERO),
173-
&[hash],
212+
OrderHashFilter::These(&[hash]),
174213
Some(11),
175214
Some(22),
176215
)
@@ -193,22 +232,56 @@ mod tests {
193232
}
194233

195234
#[test]
196-
fn batch_empty_hashes_drops_order_hash_clause() {
235+
fn all_emits_no_order_hash_predicate() {
236+
// `All` is the only variant that drops the order-hash predicate: no IN
237+
// list and no match-NONE constant. Every order is returned.
238+
let stmt = build_fetch_order_trades_batch_stmt(
239+
&RaindexIdentifier::new(1, Address::ZERO),
240+
OrderHashFilter::All,
241+
None,
242+
None,
243+
)
244+
.unwrap();
245+
246+
// The marker is consumed and *no* order-hash predicate is rendered (the
247+
// SELECT list still projects tws.order_hash, so only the predicate forms
248+
// are asserted absent).
249+
assert!(!stmt.sql.contains(ORDER_HASH_CLAUSE));
250+
assert!(!stmt.sql.contains("tws.order_hash IN ("));
251+
assert!(!stmt.sql.contains("tws.order_hash = "));
252+
// Crucially, `All` does NOT emit the match-NONE predicate that empty
253+
// `These` does — the two are opposites.
254+
assert!(!stmt.sql.contains("1=0"));
255+
// Only the two fixed params (chain id, raindex) remain.
256+
assert_eq!(stmt.params.len(), 2);
257+
assert_eq!(stmt.params[0], SqlValue::U64(1));
258+
assert_eq!(stmt.params[1], SqlValue::Text(Address::ZERO.to_string()));
259+
}
260+
261+
#[test]
262+
fn these_empty_emits_match_none_predicate_not_dropped_clause() {
263+
// An empty `These` means *none*: it emits the constant-false predicate
264+
// `AND 1=0` so zero rows match. This is the deliberate opposite of `All`
265+
// (which would drop the clause and return every order). It must NOT
266+
// degenerate to "all".
197267
let stmt = build_fetch_order_trades_batch_stmt(
198268
&RaindexIdentifier::new(1, Address::ZERO),
199-
&[],
269+
OrderHashFilter::These(&[]),
200270
None,
201271
None,
202272
)
203273
.unwrap();
204274

205-
// With no hashes the order-hash WHERE predicate is removed entirely
206-
// (the SELECT list still projects tws.order_hash, so only the predicate
207-
// forms are asserted absent); only the two fixed params (chain id,
208-
// raindex) remain.
275+
// The match-NONE predicate is present...
276+
assert!(stmt.sql.contains("1=0"));
277+
// ...and the marker is consumed (not left unsubstituted).
209278
assert!(!stmt.sql.contains(ORDER_HASH_CLAUSE));
279+
// No IN list / equality predicate and no bound hashes: empty These binds
280+
// zero placeholders.
210281
assert!(!stmt.sql.contains("tws.order_hash IN ("));
211282
assert!(!stmt.sql.contains("tws.order_hash = "));
283+
// Only the two fixed params (chain id, raindex) — the constant-false
284+
// predicate binds nothing.
212285
assert_eq!(stmt.params.len(), 2);
213286
assert_eq!(stmt.params[0], SqlValue::U64(1));
214287
assert_eq!(stmt.params[1], SqlValue::Text(Address::ZERO.to_string()));

crates/common/src/raindex_client/local_db/query/fetch_order_trades.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::local_db::query::fetch_order_trades::{
2-
build_fetch_order_trades_batch_stmt, LocalDbOrderTrade,
2+
build_fetch_order_trades_batch_stmt, LocalDbOrderTrade, OrderHashFilter,
33
};
44
use crate::local_db::query::{LocalDbQueryError, LocalDbQueryExecutor};
55
use crate::local_db::RaindexIdentifier;
@@ -40,12 +40,16 @@ pub async fn fetch_order_trades_batch<E: LocalDbQueryExecutor + ?Sized>(
4040
start_timestamp: Option<u64>,
4141
end_timestamp: Option<u64>,
4242
) -> Result<Vec<LocalDbOrderTrade>, LocalDbQueryError> {
43+
// Performance optimization, not a correctness guard: `These(&[])` already
44+
// builds a valid match-NONE query that returns zero rows, so this is the
45+
// same result the DB would give — we just skip the round-trip when we
46+
// already know the answer is empty.
4347
if order_hashes.is_empty() {
4448
return Ok(Vec::new());
4549
}
4650
let stmt = build_fetch_order_trades_batch_stmt(
4751
raindex_id,
48-
order_hashes,
52+
OrderHashFilter::These(order_hashes),
4953
start_timestamp,
5054
end_timestamp,
5155
)?;
@@ -151,7 +155,7 @@ mod wasm_tests {
151155

152156
let expected_stmt = build_fetch_order_trades_batch_stmt(
153157
&RaindexIdentifier::new(chain_id, raindex),
154-
&[order_hash],
158+
OrderHashFilter::These(&[order_hash]),
155159
start,
156160
end,
157161
)
@@ -192,7 +196,7 @@ mod wasm_tests {
192196

193197
let expected_stmt = build_fetch_order_trades_batch_stmt(
194198
&RaindexIdentifier::new(chain_id, raindex),
195-
&[hash_a, hash_b],
199+
OrderHashFilter::These(&[hash_a, hash_b]),
196200
start,
197201
end,
198202
)

0 commit comments

Comments
 (0)