Skip to content

Commit 8cef87f

Browse files
committed
fix(data-plane): vector string parsing, cross join, and graph tenant scoping
strict_format: handle VECTOR columns arriving as strings on the UPDATE path (sqlparser debug repr, ARRAY[...] SQL literal, and JSON-style). Extract the float conversion and dimension validation into helpers to remove duplication. hash join: add cross-join (cartesian product) support. Previously the executor had no code path for join_type="cross", causing silent empty results on any CROSS JOIN query. graph algo: pass tenant_id into execute_graph_algo() and scope source_node with the tenant prefix so path/traversal algorithms resolve correctly against CSR node keys stored under tenant scope. document read: remove a spurious filter_map-to-Some wrapping that was masking a unit type mismatch, and simplify to a plain map.
1 parent 5ca9cf9 commit 8cef87f

5 files changed

Lines changed: 137 additions & 28 deletions

File tree

nodedb/src/data/executor/dispatch/graph.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ impl CoreLoop {
8080
options.max_visited,
8181
),
8282

83-
GraphOp::Algo { algorithm, params } => self.execute_graph_algo(task, algorithm, params),
83+
GraphOp::Algo { algorithm, params } => {
84+
self.execute_graph_algo(task, tid, algorithm, params)
85+
}
8486

8587
GraphOp::Match { query } => self.execute_graph_match(task, query),
8688
}

nodedb/src/data/executor/handlers/document/read.rs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -122,15 +122,15 @@ impl CoreLoop {
122122
Ok(docs) if docs.is_empty() => {
123123
// Sparse empty — try KV / columnar engines.
124124
let fallback = self.scan_collection(tid, collection, fetch_limit);
125-
if let Ok(ref docs) = fallback {
126-
if !docs.is_empty() {
127-
warn!(
128-
core = self.core_id,
129-
%collection,
130-
count = docs.len(),
131-
"document scan fallback to scan_collection"
132-
);
133-
}
125+
if let Ok(ref docs) = fallback
126+
&& !docs.is_empty()
127+
{
128+
warn!(
129+
core = self.core_id,
130+
%collection,
131+
count = docs.len(),
132+
"document scan fallback to scan_collection"
133+
);
134134
}
135135
fallback
136136
}
@@ -217,16 +217,16 @@ impl CoreLoop {
217217
.into_iter()
218218
.skip(offset)
219219
.take(limit)
220-
.filter_map(|(doc_id, val)| {
220+
.map(|(doc_id, val)| {
221221
let data = super::super::super::strict_format::binary_tuple_to_json(
222222
&val, schema,
223223
)
224224
.unwrap_or(serde_json::Value::Null);
225225
let projected = apply_projection(data, &computed_cols, projection);
226-
Some(super::super::super::response_codec::DocumentRow {
226+
super::super::super::response_codec::DocumentRow {
227227
id: doc_id,
228228
data: projected,
229-
})
229+
}
230230
})
231231
.collect();
232232
return self.send_document_rows_transformed(task, &result, stream_chunk_size);

nodedb/src/data/executor/handlers/graph_algo.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ use tracing::debug;
88

99
use crate::bridge::envelope::{ErrorCode, Response};
1010
use crate::data::executor::core_loop::CoreLoop;
11+
use crate::data::executor::scoping::scoped_node;
1112
use crate::data::executor::task::ExecutionTask;
1213
use crate::engine::graph::algo::params::{AlgoParams, GraphAlgorithm};
1314

1415
impl CoreLoop {
1516
pub(in crate::data::executor) fn execute_graph_algo(
1617
&self,
1718
task: &ExecutionTask,
19+
tid: u32,
1820
algorithm: &GraphAlgorithm,
1921
params: &AlgoParams,
2022
) -> Response {
@@ -35,6 +37,18 @@ impl CoreLoop {
3537
);
3638
}
3739

40+
// Scope source_node with tenant_id so it matches CSR node keys.
41+
// Only clone params when source_node needs to be rewritten.
42+
let scoped_params;
43+
let params = if params.source_node.is_some() {
44+
let mut p = params.clone();
45+
p.source_node = p.source_node.map(|n| scoped_node(tid, &n));
46+
scoped_params = p;
47+
&scoped_params
48+
} else {
49+
params
50+
};
51+
3852
use crate::engine::graph::algo;
3953

4054
let result: Result<Vec<u8>, crate::Error> = match algorithm {

nodedb/src/data/executor/handlers/join/hash.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,26 @@ pub(super) fn probe_hash_index(p: &ProbeParams<'_>) -> Vec<serde_json::Value> {
110110
let is_right = p.join_type == "right" || p.join_type == "full";
111111
let is_semi = p.join_type == "semi";
112112
let is_anti = p.join_type == "anti";
113+
let is_cross = p.join_type == "cross";
114+
115+
// Cross join: cartesian product (no hash lookup needed).
116+
if is_cross {
117+
let mut results = Vec::new();
118+
for (_, left_val) in p.probe_docs {
119+
for (_, right_val) in p.index_docs {
120+
if results.len() >= p.limit {
121+
return results;
122+
}
123+
results.push(merge_join_docs_binary(
124+
left_val,
125+
Some(right_val),
126+
p.probe_collection,
127+
p.index_collection,
128+
));
129+
}
130+
}
131+
return results;
132+
}
113133

114134
let mut index_matched: std::collections::HashSet<usize> = std::collections::HashSet::new();
115135
let mut results = Vec::new();

nodedb/src/data/executor/strict_format.rs

Lines changed: 88 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -150,22 +150,17 @@ fn coerce_value(val: &Value, col_type: &ColumnType, col_name: &str) -> Result<Va
150150
ColumnType::Vector(dim) => match val {
151151
Value::Bytes(b) if b.len() == *dim as usize * 4 => Ok(val.clone()),
152152
Value::Array(arr) => {
153-
let floats: Vec<f32> = arr
154-
.iter()
155-
.filter_map(|v| match v {
156-
Value::Float(f) => Some(*f as f32),
157-
Value::Integer(n) => Some(*n as f32),
158-
_ => None,
159-
})
160-
.collect();
161-
if floats.len() != *dim as usize {
162-
return Err(format!(
163-
"column '{col_name}': expected VECTOR({dim}), got {} elements",
164-
floats.len()
165-
));
153+
let floats = extract_vector_floats(arr);
154+
validate_and_encode_vector(col_name, *dim, &floats)
155+
}
156+
Value::String(s) => {
157+
// UPDATE path may serialize ARRAY literal as string — parse it.
158+
match parse_vector_string(s) {
159+
Some(floats) => validate_and_encode_vector(col_name, *dim, &floats),
160+
None => Err(format!(
161+
"column '{col_name}': expected VECTOR array, got String({s:?})"
162+
)),
166163
}
167-
let bytes: Vec<u8> = floats.iter().flat_map(|f| f.to_le_bytes()).collect();
168-
Ok(Value::Bytes(bytes))
169164
}
170165
_ => Err(format!(
171166
"column '{col_name}': expected VECTOR array, got {val:?}"
@@ -175,6 +170,84 @@ fn coerce_value(val: &Value, col_type: &ColumnType, col_name: &str) -> Result<Va
175170
}
176171
}
177172

173+
/// Extract f32 floats from a `Value::Array`.
174+
fn extract_vector_floats(arr: &[Value]) -> Vec<f32> {
175+
arr.iter()
176+
.filter_map(|v| match v {
177+
Value::Float(f) => Some(*f as f32),
178+
Value::Integer(n) => Some(*n as f32),
179+
_ => None,
180+
})
181+
.collect()
182+
}
183+
184+
/// Validate dimension count and encode as little-endian bytes.
185+
fn validate_and_encode_vector(col_name: &str, dim: u32, floats: &[f32]) -> Result<Value, String> {
186+
if floats.len() != dim as usize {
187+
return Err(format!(
188+
"column '{col_name}': expected VECTOR({dim}), got {} elements",
189+
floats.len()
190+
));
191+
}
192+
let bytes: Vec<u8> = floats.iter().flat_map(|f| f.to_le_bytes()).collect();
193+
Ok(Value::Bytes(bytes))
194+
}
195+
196+
/// Parse a vector from string representations that may arrive via UPDATE path.
197+
///
198+
/// Handles formats like:
199+
/// - `"ArrayLiteral([Literal(Float(0.9)), Literal(Float(0.1)), ...])"` (sqlparser debug repr)
200+
/// - `"ARRAY[0.1, 0.2, 0.3]"` (SQL literal)
201+
/// - `"[0.1, 0.2, 0.3]"` (JSON-style)
202+
fn parse_vector_string(s: &str) -> Option<Vec<f32>> {
203+
// Try ARRAY[...] SQL literal format.
204+
let upper = s.to_uppercase();
205+
if upper.starts_with("ARRAY[") {
206+
let start = s.find('[')? + 1;
207+
let end = s.rfind(']')?;
208+
if end <= start {
209+
return None;
210+
}
211+
let inner = &s[start..end];
212+
let floats: Vec<f32> = inner
213+
.split(',')
214+
.filter_map(|tok| tok.trim().parse::<f32>().ok())
215+
.collect();
216+
if !floats.is_empty() {
217+
return Some(floats);
218+
}
219+
}
220+
221+
// Try JSON-style [0.1, 0.2, ...] format.
222+
if s.starts_with('[') && s.ends_with(']') {
223+
let inner = &s[1..s.len() - 1];
224+
let floats: Vec<f32> = inner
225+
.split(',')
226+
.filter_map(|tok| tok.trim().parse::<f32>().ok())
227+
.collect();
228+
if !floats.is_empty() {
229+
return Some(floats);
230+
}
231+
}
232+
233+
// Try sqlparser debug repr: "ArrayLiteral([Literal(Float(0.9)), ...])"
234+
if s.starts_with("ArrayLiteral(") {
235+
let floats: Vec<f32> = s
236+
.split("Float(")
237+
.skip(1)
238+
.filter_map(|chunk| {
239+
let end = chunk.find(')')?;
240+
chunk[..end].parse::<f32>().ok()
241+
})
242+
.collect();
243+
if !floats.is_empty() {
244+
return Some(floats);
245+
}
246+
}
247+
248+
None
249+
}
250+
178251
/// Convert a typed `Value` to JSON (for pgwire output only).
179252
fn value_to_json(val: &Value) -> serde_json::Value {
180253
match val {

0 commit comments

Comments
 (0)