Skip to content

Commit ae3cb99

Browse files
committed
fix(sql): canonicalize WHERE predicate operand order before planning
Normalize comparisons to column-first (`literal = col` -> `col = literal`, flipping the operator) at the shared WHERE-to-filter conversion point used by both regular SELECT/DELETE planning and recursive CTE join links. Without this, a literal-first predicate bypassed the primary-key point-get rewrite and fell back to a sequential scan, letting the same logical predicate route to two different physical operators that could disagree after crash recovery or DROP/recreate cycles. Add regression coverage for the affected scenarios: point-get plan parity for both operand orders, PK index vs. scan agreement after kill -9 and after DROP/recreate on document_strict collections, and IS NULL polarity on filtered reads/deletes. Reformat an unrelated assertion in the native transaction visibility test to satisfy line-length formatting.
1 parent f679209 commit ae3cb99

8 files changed

Lines changed: 534 additions & 10 deletions

File tree

nodedb-sql/src/planner/cte/join_link.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,11 @@ pub(super) fn extract_recursive_info(expr: &SetExpr, cte_name: &str) -> Result<R
7676

7777
let mut filters = Vec::new();
7878
if let Some(where_expr) = &select.selection {
79-
let converted = crate::resolver::expr::convert_expr(where_expr)?;
80-
filters.push(Filter {
81-
expr: FilterExpr::Expr(converted),
82-
});
79+
// Route through the shared WHERE→filter converter so the predicate is
80+
// operand-order canonicalized like every other WHERE clause.
81+
filters.extend(crate::planner::select::convert_where_to_filters(
82+
where_expr,
83+
)?);
8384
}
8485

8586
Ok((filters, join_link))

nodedb-sql/src/planner/select/helpers.rs

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,79 @@ pub fn qualified_name(table: Option<&str>, name: &str) -> String {
7777

7878
/// Convert a WHERE expression into a list of Filter.
7979
pub fn convert_where_to_filters(expr: &ast::Expr) -> Result<Vec<Filter>> {
80-
let sql_expr = convert_expr(expr)?;
80+
let sql_expr = canonicalize_predicate(convert_expr(expr)?);
8181
Ok(vec![Filter {
8282
expr: FilterExpr::Expr(sql_expr),
8383
}])
8484
}
8585

86+
/// Canonicalize comparison operand order so a bare column always sits on the
87+
/// left of a comparison (`column <op> literal`), flipping the operator when a
88+
/// swap happens (`5 < id` → `id > 5`).
89+
///
90+
/// Written literal-first (`'x' = id`), a predicate is logically identical to
91+
/// its column-first spelling but reaches the planner in a shape only some
92+
/// extractors recognize: the primary-key point-get rewrite accepts
93+
/// `column = literal` but not `literal = column`, so the two spellings would
94+
/// route to different physical operators (index point-lookup vs sequential
95+
/// scan) that disagree whenever the index and the stored tuples diverge.
96+
/// Normalizing at this single WHERE→filter choke point guarantees every
97+
/// downstream extractor — point-get, index lookup, scan-filter fast path —
98+
/// sees one canonical shape.
99+
fn canonicalize_predicate(expr: SqlExpr) -> SqlExpr {
100+
match expr {
101+
SqlExpr::BinaryOp { left, op, right } => {
102+
let left = canonicalize_predicate(*left);
103+
let right = canonicalize_predicate(*right);
104+
// Only swap when the column sits on the right and the left side is
105+
// not itself a bare column — the exact shape (`literal = column`)
106+
// the point-get / fast-path extractors fail to recognize.
107+
if let Some(flipped) = flip_comparison(op)
108+
&& !matches!(left, SqlExpr::Column { .. })
109+
&& matches!(right, SqlExpr::Column { .. })
110+
{
111+
return SqlExpr::BinaryOp {
112+
left: Box::new(right),
113+
op: flipped,
114+
right: Box::new(left),
115+
};
116+
}
117+
SqlExpr::BinaryOp {
118+
left: Box::new(left),
119+
op,
120+
right: Box::new(right),
121+
}
122+
}
123+
SqlExpr::UnaryOp { op, expr } => SqlExpr::UnaryOp {
124+
op,
125+
expr: Box::new(canonicalize_predicate(*expr)),
126+
},
127+
other => other,
128+
}
129+
}
130+
131+
/// Map a comparison operator to the operator that preserves meaning when its
132+
/// two operands are swapped. Returns `None` for non-comparison operators
133+
/// (arithmetic, logical, string), which are never operand-swapped here.
134+
fn flip_comparison(op: BinaryOp) -> Option<BinaryOp> {
135+
match op {
136+
BinaryOp::Eq => Some(BinaryOp::Eq),
137+
BinaryOp::Ne => Some(BinaryOp::Ne),
138+
BinaryOp::Gt => Some(BinaryOp::Lt),
139+
BinaryOp::Ge => Some(BinaryOp::Le),
140+
BinaryOp::Lt => Some(BinaryOp::Gt),
141+
BinaryOp::Le => Some(BinaryOp::Ge),
142+
BinaryOp::Add
143+
| BinaryOp::Sub
144+
| BinaryOp::Mul
145+
| BinaryOp::Div
146+
| BinaryOp::Mod
147+
| BinaryOp::And
148+
| BinaryOp::Or
149+
| BinaryOp::Concat => None,
150+
}
151+
}
152+
86153
pub fn extract_func_args(func: &ast::Function) -> Result<Vec<ast::Expr>> {
87154
match &func.args {
88155
ast::FunctionArguments::List(args) => Ok(args
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
3+
//! Primary-key equality must be rewritten to a point-lookup regardless of
4+
//! operand order.
5+
//!
6+
//! `WHERE id = 'x'` and `WHERE 'x' = id` are logically identical. The
7+
//! point-get optimizer must produce the same `SqlPlan::PointGet` for both.
8+
//! If it rewrites only the column-first spelling and leaves the literal-first
9+
//! spelling as a `Scan`, the same predicate is answered by two different
10+
//! physical operators — an index point-lookup vs a sequential scan — which
11+
//! disagree whenever the primary-key index and the stored tuples diverge
12+
//! (the operand-direction asymmetry the ghost-tuple report observed:
13+
//! `id = 'x'` returned no row while `'x' = id` returned one).
14+
15+
use nodedb_sql::types::{CollectionInfo, EngineType};
16+
use nodedb_sql::{SqlCatalog, SqlCatalogError, SqlPlan, plan_sql};
17+
use nodedb_types::DatabaseId;
18+
19+
struct Catalog;
20+
21+
impl SqlCatalog for Catalog {
22+
fn get_collection(
23+
&self,
24+
_: DatabaseId,
25+
name: &str,
26+
) -> std::result::Result<Option<CollectionInfo>, SqlCatalogError> {
27+
let info = match name {
28+
"articles" => Some(CollectionInfo {
29+
name: "articles".into(),
30+
engine: EngineType::DocumentStrict,
31+
columns: Vec::new(),
32+
primary_key: Some("id".into()),
33+
has_auto_tier: false,
34+
indexes: Vec::new(),
35+
bitemporal: false,
36+
primary: nodedb_types::PrimaryEngine::Document,
37+
vector_primary: None,
38+
partition_strategy: nodedb_types::PartitionStrategy::CollectionHomed,
39+
}),
40+
_ => None,
41+
};
42+
Ok(info)
43+
}
44+
45+
fn lookup_array(&self, _name: &str) -> Option<nodedb_sql::types::ArrayCatalogView> {
46+
None
47+
}
48+
49+
fn array_exists(&self, _name: &str) -> bool {
50+
false
51+
}
52+
}
53+
54+
fn plan_one(sql: &str) -> SqlPlan {
55+
let mut plans = plan_sql(sql, &Catalog).expect("planning must succeed");
56+
assert_eq!(plans.len(), 1, "expected exactly one plan for: {sql}");
57+
plans.pop().unwrap()
58+
}
59+
60+
/// Control: column-first PK equality is rewritten to a point-lookup.
61+
#[test]
62+
fn column_first_pk_equality_is_point_get() {
63+
let plan = plan_one("SELECT id FROM articles WHERE id = 'x'");
64+
assert!(
65+
matches!(plan, SqlPlan::PointGet { .. }),
66+
"`id = 'x'` on the primary key must plan as a point-lookup, got {plan:?}"
67+
);
68+
}
69+
70+
/// The defect: literal-first PK equality must ALSO be rewritten to a
71+
/// point-lookup. `'x' = id` is logically identical to `id = 'x'`; leaving it a
72+
/// `Scan` routes the same predicate to a different physical operator.
73+
#[test]
74+
fn literal_first_pk_equality_is_point_get() {
75+
let plan = plan_one("SELECT id FROM articles WHERE 'x' = id");
76+
assert!(
77+
matches!(plan, SqlPlan::PointGet { .. }),
78+
"`'x' = id` on the primary key must plan as the same point-lookup as \
79+
`id = 'x'`, but operand order left it as {plan:?}"
80+
);
81+
}

nodedb/tests/crash_recovery.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,67 @@ async fn committed_write_survives_kill_9() {
4040
);
4141
}
4242

43+
/// After a hard crash and WAL replay, the primary-key point-lookup index and
44+
/// the sequential-scan surface must agree for a document_strict collection.
45+
/// The reported ghost tuples were rows the scan returned but no point-lookup
46+
/// (`WHERE id = ...`) nor `COUNT(*)` could reach — a PK-index-vs-tuple-store
47+
/// divergence that surfaced after restart cycles. A single kill -9 + reopen
48+
/// must never produce that split: every scan-visible row is reachable by its
49+
/// own primary key, and `COUNT(*)` equals the scanned row count.
50+
#[tokio::test(flavor = "multi_thread")]
51+
async fn pk_index_and_scan_agree_after_kill_9() {
52+
let mut h = CrashHarness::new();
53+
h.spawn();
54+
h.wait_ready(Duration::from_secs(20));
55+
56+
h.exec(
57+
"CREATE COLLECTION ghost_check (id TEXT PRIMARY KEY, title TEXT) \
58+
WITH (engine='document_strict')",
59+
)
60+
.await;
61+
for i in 0..7u32 {
62+
h.exec(&format!(
63+
"INSERT INTO ghost_check (id, title) VALUES ('smoke_{i}', 't{i}')"
64+
))
65+
.await;
66+
}
67+
68+
h.kill_9();
69+
h.reopen();
70+
71+
let mut scan = h.query_col("SELECT id FROM ghost_check", "id").await;
72+
scan.sort();
73+
let expected: Vec<String> = (0..7u32).map(|i| format!("smoke_{i}")).collect();
74+
assert_eq!(
75+
scan, expected,
76+
"all 7 committed rows must survive kill -9 + WAL replay (got {scan:?})"
77+
);
78+
79+
// Every scan-visible row must be reachable by its own PK point-lookup;
80+
// a scan-visible row unreachable by point-lookup is a ghost tuple.
81+
for id in &scan {
82+
let point = h
83+
.query_col(
84+
&format!("SELECT id FROM ghost_check WHERE id = '{id}'"),
85+
"id",
86+
)
87+
.await;
88+
assert_eq!(
89+
point,
90+
vec![id.clone()],
91+
"scanned row '{id}' unreachable by point-lookup after crash recovery — a ghost tuple (got {point:?})"
92+
);
93+
}
94+
95+
// COUNT(*) must agree with the scan, not exclude the recovered rows.
96+
let count = h.query_col_idx("SELECT COUNT(*) FROM ghost_check", 0).await;
97+
assert_eq!(
98+
count,
99+
vec!["7".to_string()],
100+
"COUNT(*) must equal the scanned row count after crash recovery (got {count:?})"
101+
);
102+
}
103+
43104
#[tokio::test(flavor = "multi_thread")]
44105
async fn kv_write_survives_kill_9() {
45106
let mut h = CrashHarness::new();

nodedb/tests/native_txn_commit_visibility.rs

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -153,14 +153,27 @@ async fn native_committed_txn_row_visible_to_point_and_filtered_reads() {
153153
&format!("INSERT INTO {coll} (id, name) VALUES ('a1', 'alpha')"),
154154
)
155155
.await;
156-
assert_eq!(insert.status, ResponseStatus::Ok, "in-tx INSERT: {insert:?}");
156+
assert_eq!(
157+
insert.status,
158+
ResponseStatus::Ok,
159+
"in-tx INSERT: {insert:?}"
160+
);
157161

158162
let commit = request_drain(&mut stream, 3, OpCode::Commit, TextFields::default()).await;
159163
assert_eq!(commit.status, ResponseStatus::Ok, "COMMIT op: {commit:?}");
160164

161165
// PK point lookup on the writing connection.
162-
let point = sql_drain(&mut stream, 4, &format!("SELECT id FROM {coll} WHERE id = 'a1'")).await;
163-
assert_ne!(point.status, ResponseStatus::Error, "point lookup: {point:?}");
166+
let point = sql_drain(
167+
&mut stream,
168+
4,
169+
&format!("SELECT id FROM {coll} WHERE id = 'a1'"),
170+
)
171+
.await;
172+
assert_ne!(
173+
point.status,
174+
ResponseStatus::Error,
175+
"point lookup: {point:?}"
176+
);
164177
assert_eq!(
165178
first_cell(&point),
166179
Some(Value::String("a1".into())),
@@ -174,7 +187,11 @@ async fn native_committed_txn_row_visible_to_point_and_filtered_reads() {
174187
&format!("SELECT count(*) FROM {coll} WHERE name = 'alpha'"),
175188
)
176189
.await;
177-
assert_ne!(count.status, ResponseStatus::Error, "filtered count: {count:?}");
190+
assert_ne!(
191+
count.status,
192+
ResponseStatus::Error,
193+
"filtered count: {count:?}"
194+
);
178195
assert_eq!(
179196
first_cell(&count),
180197
Some(Value::Integer(1)),
@@ -193,7 +210,12 @@ async fn native_committed_txn_row_visible_to_point_and_filtered_reads() {
193210

194211
// Fresh connection — the miss must not persist across sessions.
195212
let mut fresh = native_session(&server).await;
196-
let point2 = sql_drain(&mut fresh, 1, &format!("SELECT id FROM {coll} WHERE id = 'a1'")).await;
213+
let point2 = sql_drain(
214+
&mut fresh,
215+
1,
216+
&format!("SELECT id FROM {coll} WHERE id = 'a1'"),
217+
)
218+
.await;
197219
assert_ne!(
198220
point2.status,
199221
ResponseStatus::Error,

nodedb/tests/sql_drop_collection.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,66 @@ async fn drop_then_recreate_same_name_starts_empty() {
133133
"re-created collection must start empty; old rows resurrected: {after:?}"
134134
);
135135
}
136+
137+
/// The strict-engine twin of `drop_then_recreate_same_name_starts_empty`.
138+
/// A `document_strict` collection stores its Binary Tuples under the same
139+
/// name-derived `{db}:{tenant}:{name}:` prefix that a re-CREATE reuses —
140+
/// collection identity is not per-creation. DROP must purge every stored
141+
/// tuple (and its indexes) so a re-created same-name strict collection starts
142+
/// empty; otherwise the old tuples are inherited by the new incarnation and
143+
/// scan as all-NULL ghosts.
144+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
145+
async fn drop_then_recreate_document_strict_starts_empty() {
146+
let srv = TestServer::start().await;
147+
148+
srv.exec(
149+
"CREATE COLLECTION recycled_strict (id TEXT PRIMARY KEY, title TEXT, body TEXT) \
150+
WITH (engine='document_strict')",
151+
)
152+
.await
153+
.unwrap();
154+
for i in 0..7u32 {
155+
srv.exec(&format!(
156+
"INSERT INTO recycled_strict (id, title, body) VALUES ('smoke_{i}', 't{i}', 'b{i}')"
157+
))
158+
.await
159+
.unwrap();
160+
}
161+
let before = srv
162+
.query_rows("SELECT id FROM recycled_strict")
163+
.await
164+
.unwrap();
165+
assert_eq!(
166+
before.len(),
167+
7,
168+
"7 rows must exist before DROP, got {before:?}"
169+
);
170+
171+
srv.exec("DROP COLLECTION recycled_strict").await.unwrap();
172+
srv.exec(
173+
"CREATE COLLECTION recycled_strict (id TEXT PRIMARY KEY, title TEXT, body TEXT) \
174+
WITH (engine='document_strict')",
175+
)
176+
.await
177+
.unwrap();
178+
179+
let after = srv
180+
.query_rows("SELECT id FROM recycled_strict")
181+
.await
182+
.unwrap();
183+
assert_eq!(
184+
after.len(),
185+
0,
186+
"re-created strict collection must start empty; old tuples resurrected: {after:?}"
187+
);
188+
189+
let count = srv
190+
.query_text("SELECT COUNT(*) FROM recycled_strict")
191+
.await
192+
.unwrap();
193+
assert_eq!(
194+
count,
195+
vec!["0".to_string()],
196+
"COUNT(*) on a freshly re-created strict collection must be 0, got {count:?}"
197+
);
198+
}

0 commit comments

Comments
 (0)