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
8 changes: 3 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,7 @@ jobs:
- uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
run: rustup show active-toolchain || rustup toolchain install

- name: Cache cargo registry
uses: actions/cache@v4
Expand All @@ -47,7 +45,7 @@ jobs:
- uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
run: rustup show active-toolchain || rustup toolchain install

- name: Cache cargo registry
uses: actions/cache@v4
Expand Down Expand Up @@ -76,7 +74,7 @@ jobs:
node-version: '24'

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
run: rustup show active-toolchain || rustup toolchain install

- name: Cache cargo registry
uses: actions/cache@v4
Expand Down
46 changes: 45 additions & 1 deletion crates/grafeo-adapters/src/query/gremlin/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,8 @@ pub enum TokenKind {
RBracket,
/// Underscore (`_`) token.
Underscore,
/// Anonymous traversal (`__`).
Anon,

/// A `$name` parameter reference.
Parameter(String),
Expand Down Expand Up @@ -307,7 +309,9 @@ impl<'a> Lexer<'a> {
Some(')') => TokenKind::RParen,
Some('[') => TokenKind::LBracket,
Some(']') => TokenKind::RBracket,
Some('_') if self.peek_is(|c| !c.is_alphanumeric()) => TokenKind::Underscore,
Some('_') if self.peek_is(|c| !c.is_alphanumeric() && c != '_') => {
TokenKind::Underscore
}

Some('"') => self.read_string('"'),
Some('\'') => self.read_string('\''),
Expand Down Expand Up @@ -467,6 +471,7 @@ impl<'a> Lexer<'a> {

// Match keywords
match value.as_str() {
"__" => TokenKind::Anon,
"g" => TokenKind::G,
"V" => TokenKind::V,
"E" => TokenKind::E,
Expand Down Expand Up @@ -616,4 +621,43 @@ mod tests {
assert_eq!(tokens[1].kind, TokenKind::Dot);
assert_eq!(tokens[2].kind, TokenKind::Gt);
}

#[test]
fn test_anon_token() {
let tokens = Lexer::new("__").tokenize();
assert_eq!(tokens[0].kind, TokenKind::Anon);
assert_eq!(tokens[1].kind, TokenKind::Eof);
}

#[test]
fn test_single_underscore_still_underscore() {
// Regression guard for the tightened `_` lookahead. (A bare `_` at EOF has always
// lexed as an identifier, since `peek_is` is false on `None`.)
let tokens = Lexer::new("_ ").tokenize();
assert_eq!(tokens[0].kind, TokenKind::Underscore);
assert_eq!(tokens[1].kind, TokenKind::Eof);

let tokens = Lexer::new("_.has('a', 1)").tokenize();
assert_eq!(tokens[0].kind, TokenKind::Underscore);
assert_eq!(tokens[1].kind, TokenKind::Dot);
}

#[test]
fn test_double_underscore_prefixed_identifier() {
let tokens = Lexer::new("__foo").tokenize();
assert_eq!(tokens[0].kind, TokenKind::Identifier("__foo".to_string()));
assert_eq!(tokens[1].kind, TokenKind::Eof);

let tokens = Lexer::new("___").tokenize();
assert_eq!(tokens[0].kind, TokenKind::Identifier("___".to_string()));
}

#[test]
fn test_anon_traversal_prefix() {
let tokens = Lexer::new("__.has('a', 1)").tokenize();
assert_eq!(tokens[0].kind, TokenKind::Anon);
assert_eq!(tokens[1].kind, TokenKind::Dot);
assert_eq!(tokens[2].kind, TokenKind::Has);
assert_eq!(tokens[3].kind, TokenKind::LParen);
}
}
99 changes: 97 additions & 2 deletions crates/grafeo-adapters/src/query/gremlin/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1009,8 +1009,8 @@ impl<'a> Parser<'a> {
return Ok(FromTo::Traversal(steps));
}

// Check for bare V()/E() traversal (without 'g.' prefix)
if self.check(TokenKind::V) || self.check(TokenKind::E) {
// Check for bare V()/E() traversal (without 'g.' prefix), optionally `__.`-prefixed
if self.check(TokenKind::V) || self.check(TokenKind::E) || self.check(TokenKind::Anon) {
let steps = self.parse_bare_traversal()?;
return Ok(FromTo::Traversal(steps));
}
Expand All @@ -1023,6 +1023,7 @@ impl<'a> Parser<'a> {
/// responsible for consuming the outer delimiters.
fn parse_inner_steps(&mut self) -> Result<Vec<Step>> {
self.enter_nesting()?;
self.skip_anon_prefix()?;
let mut steps = Vec::new();
// Parse first step
let step = self.parse_step()?;
Expand Down Expand Up @@ -1053,6 +1054,7 @@ impl<'a> Parser<'a> {
/// Used inside from()/to() arguments, e.g. `from(V().has('name', 'Gus'))`.
fn parse_bare_traversal(&mut self) -> Result<Vec<Step>> {
self.enter_nesting()?;
self.skip_anon_prefix()?;

// Parse source (V, E, etc.) and convert to a step
let source = self.parse_source()?;
Expand Down Expand Up @@ -1195,6 +1197,15 @@ impl<'a> Parser<'a> {
}
}

/// Consume the optional `__.` anonymous-traversal prefix.
fn skip_anon_prefix(&mut self) -> Result<()> {
if self.check(TokenKind::Anon) {
self.advance();
self.expect(TokenKind::Dot)?;
}
Ok(())
}

fn check(&self, kind: TokenKind) -> bool {
self.current_kind() == Some(&kind)
}
Expand Down Expand Up @@ -1941,4 +1952,88 @@ mod tests {
"300 levels of or() should error, not stack overflow"
);
}

// -------------------------------------------------------------------------
// Anonymous traversal (`__`) prefix
// -------------------------------------------------------------------------

fn parse_ok(query: &str) -> Statement {
Parser::new(query)
.parse()
.unwrap_or_else(|e| panic!("{query} should parse: {e}"))
}

#[test]
fn test_anon_prefix_in_or() {
let stmt = parse_ok("g.V().or(__.has('a', 1), __.has('b', 2))");
let Step::Or(traversals) = &stmt.steps[0] else {
panic!("expected Step::Or, got {:?}", stmt.steps[0]);
};
assert_eq!(traversals.len(), 2);
assert!(matches!(traversals[0][0], Step::Has(_)));
assert!(matches!(traversals[1][0], Step::Has(_)));
}

#[test]
fn test_anon_prefix_is_optional_and_mixable() {
let prefixed = parse_ok("g.V().or(__.has('a', 1), __.has('b', 2))");
let mixed = parse_ok("g.V().or(__.has('a', 1), has('b', 2))");
let bare = parse_ok("g.V().or(has('a', 1), has('b', 2))");
assert_eq!(
format!("{:?}", prefixed.steps),
format!("{:?}", mixed.steps)
);
assert_eq!(format!("{:?}", prefixed.steps), format!("{:?}", bare.steps));
}

#[test]
fn test_anon_prefix_nested_compound() {
let stmt = parse_ok("g.V().or(__.and(__.has('a', 1), __.has('b', 2)), __.has('c', 3))");
let Step::Or(traversals) = &stmt.steps[0] else {
panic!("expected Step::Or, got {:?}", stmt.steps[0]);
};
assert_eq!(traversals.len(), 2);
let Step::And(inner) = &traversals[0][0] else {
panic!("expected nested Step::And, got {:?}", traversals[0][0]);
};
assert_eq!(inner.len(), 2);
assert!(matches!(traversals[1][0], Step::Has(_)));
}

#[test]
fn test_anon_prefix_in_not() {
let stmt = parse_ok("g.V().not(__.has('a', 1))");
let Step::Not(steps) = &stmt.steps[0] else {
panic!("expected Step::Not, got {:?}", stmt.steps[0]);
};
assert_eq!(steps.len(), 1);
}

#[test]
fn test_multi_arg_not_still_rejected() {
// TinkerPop's not() takes exactly one traversal.
assert!(
Parser::new("g.V().not(__.has('a', 1), __.has('b', 2))")
.parse()
.is_err()
);
}

#[test]
fn test_anon_prefix_in_where() {
let stmt = parse_ok("g.V().where(__.out('knows'))");
let Step::Where(WhereClause::Traversal(steps)) = &stmt.steps[0] else {
panic!("expected Step::Where(Traversal), got {:?}", stmt.steps[0]);
};
assert!(matches!(steps[0], Step::Out(_)));
}

#[test]
fn test_anon_prefix_chained_inner_steps() {
let stmt = parse_ok("g.V().where(__.out('knows').has('name', 'Gus'))");
let Step::Where(WhereClause::Traversal(steps)) = &stmt.steps[0] else {
panic!("expected Step::Where(Traversal), got {:?}", stmt.steps[0]);
};
assert_eq!(steps.len(), 2);
}
}
2 changes: 1 addition & 1 deletion crates/grafeo-common/src/types/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1384,7 +1384,7 @@ mod tests {
assert!(v1 < v2);
assert!(v2 < v_inf);
assert!(v_inf < v_nan); // NaN is greater than everything
assert!(v_nan == v_nan); // NaN equals itself for total ordering
assert_eq!(v_nan, v_nan); // NaN equals itself for total ordering
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion crates/grafeo-core/src/codec/delta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,6 @@ mod tests {
let sequential: Vec<u64> = (0..1000).collect();
let encoded = DeltaEncoding::encode(&sequential);
// Each delta is 1, so compression is minimal but base + deltas is stored
assert!(encoded.len() == 1000);
assert_eq!(encoded.len(), 1000);
}
}
8 changes: 2 additions & 6 deletions crates/grafeo-core/src/codec/selector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -374,12 +374,8 @@ impl TypeSpecificCompressor {
match data.codec {
CompressionCodec::None => {
let mut values = Vec::with_capacity(data.data.len() / 8);
for chunk in data.data.chunks_exact(8) {
values.push(u64::from_le_bytes(
chunk
.try_into()
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
));
for chunk in data.data.as_chunks::<8>().0 {
values.push(u64::from_le_bytes(*chunk));
}
Ok(values)
}
Expand Down
20 changes: 7 additions & 13 deletions crates/grafeo-core/src/execution/operators/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -926,18 +926,15 @@ impl ExpressionPredicate {
} => {
let list_val = self.eval_expr(list_expr, chunk, row)?;
// Accept both List and Vector as iterable sequences
let items: Vec<&Value>;
let vec_items: Vec<Value>;
match &list_val {
Value::List(list) => {
items = list.iter().collect();
}
let items: Vec<&Value> = match &list_val {
Value::List(list) => list.iter().collect(),
Value::Vector(vec) => {
vec_items = vec.iter().map(|&f| Value::Float64(f64::from(f))).collect();
items = vec_items.iter().collect();
vec_items.iter().collect()
}
_ => return None,
}
};

let mut match_count: u32 = 0;
for item in &items {
Expand Down Expand Up @@ -2285,10 +2282,9 @@ impl ExpressionPredicate {
let col = chunk.column(col_idx)?;
if let Some(nid) = col.get_node_id(row) {
ids.push(nid.0);
} else if let Some(eid) = col.get_edge_id(row) {
ids.push(eid.0);
} else {
return None;
let eid = col.get_edge_id(row)?;
ids.push(eid.0);
}
} else {
return None;
Expand Down Expand Up @@ -2330,10 +2326,8 @@ impl ExpressionPredicate {
let col = chunk.column(col_idx)?;
let current_id = if let Some(nid) = col.get_node_id(row) {
nid.0
} else if let Some(eid) = col.get_edge_id(row) {
eid.0
} else {
return None;
col.get_edge_id(row)?.0
};
match first_id {
None => first_id = Some(current_id),
Expand Down
4 changes: 2 additions & 2 deletions crates/grafeo-core/src/graph/projection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -657,8 +657,8 @@ mod tests {
.with_edge_types(["KNOWS"]);
let proj = GraphProjection::new(store, spec);

assert!(proj.estimate_label_cardinality("City") == 0.0);
assert!(proj.estimate_avg_degree("LIVES_IN", true) == 0.0);
assert_eq!(proj.estimate_label_cardinality("City"), 0.0);
assert_eq!(proj.estimate_avg_degree("LIVES_IN", true), 0.0);
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion crates/grafeo-core/src/index/adjacency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1153,7 +1153,7 @@ mod tests {

// Verify only odd-numbered destinations remain
for neighbor in neighbors {
assert!(neighbor.as_u64() % 2 == 0); // Original IDs were i+1, so even means odd i
assert_eq!(neighbor.as_u64() % 2, 0); // Original IDs were i+1, so even means odd i
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/grafeo-core/src/index/vector/hnsw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -753,7 +753,7 @@ impl HnswIndex {
}

// Remove bidirectional links
for (_, node) in nodes_map.iter_mut() {
for node in nodes_map.values_mut() {
for neighbors in &mut node.neighbors {
neighbors.retain(|&n| n != id);
}
Expand Down
12 changes: 4 additions & 8 deletions crates/grafeo-core/src/index/vector/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -474,14 +474,10 @@ impl VectorStorage for MmapStorage {

// Convert bytes to f32
let vector: Vec<f32> = bytes
.chunks_exact(4)
.map(|chunk| {
f32::from_le_bytes(
chunk
.try_into()
.expect("chunks_exact(4) yields 4-byte slices"),
)
})
.as_chunks::<4>()
.0
.iter()
.map(|chunk| f32::from_le_bytes(*chunk))
.collect();

let arc: Arc<[f32]> = vector.into();
Expand Down
2 changes: 1 addition & 1 deletion crates/grafeo-engine/src/query/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3029,7 +3029,7 @@ mod tests {
// Display/Equality sanity
assert_eq!(format!("{literal}"), "42");
assert_eq!(format!("{param}"), "$limit");
assert!(literal == 42usize);
assert_eq!(literal, 42usize);
}

// ==================== CountExpr ====================
Expand Down
4 changes: 2 additions & 2 deletions crates/grafeo-engine/src/query/planner/lpg/filter_hybrid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,10 +247,10 @@ impl super::Planner {
// Try vector-left + text-right, then vector-right + text-left.
let result = self
.extract_vector_predicate(left)
.and_then(|v| self.extract_text_predicate(right).map(|t| (v, t)))
.zip(self.extract_text_predicate(right))
.or_else(|| {
self.extract_vector_predicate(right)
.and_then(|v| self.extract_text_predicate(left).map(|t| (v, t)))
.zip(self.extract_text_predicate(left))
});
let Some((v, t)) = result else {
return Ok(None);
Expand Down
2 changes: 1 addition & 1 deletion crates/grafeo-engine/src/query/planner/rdf/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4190,7 +4190,7 @@ impl RdfExpressionPredicate {
{
// Regex-based replace
let regex_pattern = if flags.contains('i') {
format!("(?i){}", &pattern)
format!("(?i){}", pattern)
} else {
pattern.clone()
};
Expand Down
Loading
Loading