Skip to content

Commit 3837290

Browse files
genezhangclaude
andauthored
fix(tck): 402/402 passing — List indexing, type validation, step regex, DELETE detection (#273)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3b8164d commit 3837290

3 files changed

Lines changed: 195 additions & 10 deletions

File tree

clickgraph-tck/tests/tck.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,11 +170,8 @@ async fn given_having_executed(world: &mut TckWorld, step: &Step) {
170170
#[given(regex = r"^parameters are:$")]
171171
async fn given_parameters(world: &mut TckWorld, step: &Step) {
172172
if let Some(table) = step.table() {
173-
// Expect a two-column table: | name | value |
174-
// Skip the header row
175-
let mut rows = table.rows.iter();
176-
rows.next(); // header
177-
for row in rows {
173+
// Each row IS a key-value pair; there is no header in TCK parameter tables.
174+
for row in table.rows.iter() {
178175
if row.len() >= 2 {
179176
let name = row[0].trim().to_string();
180177
let value = row[1].trim().to_string();
@@ -366,7 +363,7 @@ async fn then_side_effects(_world: &mut TckWorld) {
366363
// Accept without assertion.
367364
}
368365

369-
#[then(regex = r"^a (\w+) should be raised at (?:compile|runtime|any) time: (.+)$")]
366+
#[then(regex = r"^an? (\w+) should be raised at (?:compile|runtime|any)(?:\s+time)?: (.+)$")]
370367
async fn then_error_raised(world: &mut TckWorld, _error_type: String, _error_name: String) {
371368
if world.is_skip() {
372369
return;

src/clickhouse_query_generator/to_sql_query.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5131,11 +5131,18 @@ impl RenderExpr {
51315131
}
51325132
RenderExpr::ArraySubscript { array, index } => {
51335133
// Array subscript in ClickHouse: array[index]
5134-
// Note: Cypher uses 1-based indexing, ClickHouse uses 1-based too
5134+
// Cypher uses 0-based indexing; ClickHouse uses 1-based.
5135+
// For integer literals we add +1 at compile time.
5136+
// For expression indices we emit (expr)+1 without an explicit cast
5137+
// so ClickHouse's own type checker catches bad types (e.g. floats,
5138+
// strings) rather than silently coercing them.
5139+
// Exception: string-literal indices are map-key accesses
5140+
// (e.g. top['score']) and must NOT be offset.
51355141
let array_sql = array.to_sql();
51365142
let index_sql = match index.as_ref() {
51375143
RenderExpr::Literal(Literal::Integer(n)) => format!("{}", n + 1),
5138-
_ => index.to_sql(),
5144+
RenderExpr::Literal(Literal::String(_)) => index.to_sql(),
5145+
_ => format!("({})+1", index.to_sql()),
51395146
};
51405147
format!("{}[{}]", array_sql, index_sql)
51415148
}

src/query_planner/analyzer/query_validation.rs

Lines changed: 183 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::collections::HashMap;
2+
use std::fmt;
13
use std::sync::Arc;
24

35
use crate::{
@@ -7,13 +9,149 @@ use crate::{
79
analyzer_pass::{AnalyzerPass, AnalyzerResult},
810
errors::{AnalyzerError, Pass},
911
},
10-
logical_expr::Direction,
11-
logical_plan::LogicalPlan,
12+
logical_expr::{Direction, Literal, LogicalExpr},
13+
logical_plan::{LogicalPlan, ProjectionItem},
1214
plan_ctx::PlanCtx,
1315
transformed::Transformed,
1416
},
1517
};
1618

19+
// ---------------------------------------------------------------------------
20+
// Array index type validation helpers
21+
// ---------------------------------------------------------------------------
22+
23+
/// Non-integer literal kinds that are illegal as array subscript indices.
24+
#[derive(Debug)]
25+
enum NonIntegerKind {
26+
Boolean,
27+
Float,
28+
Str,
29+
List,
30+
Map,
31+
}
32+
33+
impl fmt::Display for NonIntegerKind {
34+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35+
match self {
36+
NonIntegerKind::Boolean => write!(f, "Boolean"),
37+
NonIntegerKind::Float => write!(f, "Float"),
38+
NonIntegerKind::Str => write!(f, "String"),
39+
NonIntegerKind::List => write!(f, "List"),
40+
NonIntegerKind::Map => write!(f, "Map"),
41+
}
42+
}
43+
}
44+
45+
/// Returns `Some(kind)` when `expr` is a clearly non-integer literal; `None` otherwise.
46+
///
47+
/// Note: `Literal::String` is intentionally excluded — bracket subscripts with string keys
48+
/// (`map['key']`, `node['prop']`) are valid Cypher map/property access and must not be
49+
/// rejected. String-keyed array subscripts (invalid) are caught at SQL execution time.
50+
fn infer_non_integer_kind(expr: &LogicalExpr) -> Option<NonIntegerKind> {
51+
match expr {
52+
LogicalExpr::Literal(lit) => match lit {
53+
Literal::Boolean(_) => Some(NonIntegerKind::Boolean),
54+
Literal::Float(_) => Some(NonIntegerKind::Float),
55+
Literal::Integer(_) | Literal::String(_) | Literal::Null => None,
56+
},
57+
LogicalExpr::List(_) => Some(NonIntegerKind::List),
58+
LogicalExpr::MapLiteral(_) => Some(NonIntegerKind::Map),
59+
_ => None,
60+
}
61+
}
62+
63+
/// Build a map from alias name → non-integer kind using the projection items of a
64+
/// WITH clause. Only aliases whose expression is a known non-integer type are included.
65+
fn build_non_integer_alias_map(items: &[ProjectionItem]) -> HashMap<String, NonIntegerKind> {
66+
let mut map = HashMap::new();
67+
for item in items {
68+
if let Some(col_alias) = &item.col_alias {
69+
if let Some(kind) = infer_non_integer_kind(&item.expression) {
70+
map.insert(col_alias.0.clone(), kind);
71+
}
72+
}
73+
}
74+
map
75+
}
76+
77+
/// Walk `expr` recursively looking for an `ArraySubscript` whose index is a
78+
/// known non-integer type (either a direct literal or an alias bound to one).
79+
/// Returns `Some(error_message)` on the first violation found.
80+
///
81+
/// Covers all expression variants that `walk_expression` traverses so that
82+
/// subscripts nested inside CASE branches, aggregate calls, reduce expressions,
83+
/// map literals, array slicings, etc. are also checked.
84+
fn find_invalid_array_subscript(
85+
expr: &LogicalExpr,
86+
alias_types: &HashMap<String, NonIntegerKind>,
87+
) -> Option<String> {
88+
macro_rules! recurse {
89+
($e:expr) => {
90+
find_invalid_array_subscript($e, alias_types)
91+
};
92+
}
93+
match expr {
94+
LogicalExpr::ArraySubscript { array, index } => {
95+
// Direct non-integer literal index.
96+
if let Some(kind) = infer_non_integer_kind(index) {
97+
return Some(format!(
98+
"TypeError: invalid array index type {} — must be Integer",
99+
kind
100+
));
101+
}
102+
// Alias-resolved non-integer index.
103+
let alias_name: Option<&str> = match index.as_ref() {
104+
LogicalExpr::ColumnAlias(ca) => Some(&ca.0),
105+
LogicalExpr::TableAlias(ta) => Some(&ta.0),
106+
_ => None,
107+
};
108+
if let Some(name) = alias_name {
109+
if let Some(kind) = alias_types.get(name) {
110+
return Some(format!(
111+
"TypeError: invalid array index type {} (alias '{}') — must be Integer",
112+
kind, name
113+
));
114+
}
115+
}
116+
recurse!(array).or_else(|| recurse!(index))
117+
}
118+
LogicalExpr::OperatorApplicationExp(op) | LogicalExpr::Operator(op) => {
119+
op.operands.iter().find_map(|o| recurse!(o))
120+
}
121+
LogicalExpr::ScalarFnCall(f) => f.args.iter().find_map(|a| recurse!(a)),
122+
LogicalExpr::AggregateFnCall(f) => f.args.iter().find_map(|a| recurse!(a)),
123+
LogicalExpr::List(items) => items.iter().find_map(|i| recurse!(i)),
124+
LogicalExpr::MapLiteral(entries) => entries.iter().find_map(|(_, v)| recurse!(v)),
125+
LogicalExpr::Case(c) => c
126+
.expr
127+
.as_ref()
128+
.and_then(|e| recurse!(e))
129+
.or_else(|| {
130+
c.when_then
131+
.iter()
132+
.find_map(|(w, t)| recurse!(w).or_else(|| recurse!(t)))
133+
})
134+
.or_else(|| c.else_expr.as_ref().and_then(|e| recurse!(e))),
135+
LogicalExpr::ReduceExpr(r) => recurse!(&r.initial_value)
136+
.or_else(|| recurse!(&r.list))
137+
.or_else(|| recurse!(&r.expression)),
138+
LogicalExpr::ArraySlicing { array, from, to } => recurse!(array)
139+
.or_else(|| from.as_ref().and_then(|f| recurse!(f)))
140+
.or_else(|| to.as_ref().and_then(|t| recurse!(t))),
141+
_ => None,
142+
}
143+
}
144+
145+
/// Check all expressions in a slice of projection items.
146+
fn check_items(
147+
items: &[ProjectionItem],
148+
alias_types: &HashMap<String, NonIntegerKind>,
149+
) -> Option<String> {
150+
items
151+
.iter()
152+
.find_map(|item| find_invalid_array_subscript(&item.expression, alias_types))
153+
}
154+
17155
pub struct QueryValidation;
18156

19157
impl AnalyzerPass for QueryValidation {
@@ -25,6 +163,23 @@ impl AnalyzerPass for QueryValidation {
25163
) -> AnalyzerResult<Transformed<Arc<LogicalPlan>>> {
26164
let transformed_plan = match logical_plan.as_ref() {
27165
LogicalPlan::Projection(projection) => {
166+
// Validate array subscript index types.
167+
// When the child is a WithClause, build a map of alias → non-integer kind
168+
// so we can detect `WITH true AS idx RETURN list[idx]` style errors.
169+
let alias_types = if let LogicalPlan::WithClause(wc) = projection.input.as_ref() {
170+
build_non_integer_alias_map(&wc.items)
171+
} else {
172+
HashMap::new()
173+
};
174+
175+
for item in &projection.items {
176+
if let Some(err_msg) =
177+
find_invalid_array_subscript(&item.expression, &alias_types)
178+
{
179+
return Err(AnalyzerError::InvalidPlan(err_msg));
180+
}
181+
}
182+
28183
let child_tf = self.analyze_with_graph_schema(
29184
projection.input.clone(),
30185
plan_ctx,
@@ -232,6 +387,11 @@ impl AnalyzerPass for QueryValidation {
232387
graph_joins.rebuild_or_clone(child_tf, logical_plan.clone())
233388
}
234389
LogicalPlan::Filter(filter) => {
390+
// Validate subscript indices in WHERE predicates.
391+
let empty_map = HashMap::new();
392+
if let Some(err) = find_invalid_array_subscript(&filter.predicate, &empty_map) {
393+
return Err(AnalyzerError::InvalidPlan(err));
394+
}
235395
let child_tf =
236396
self.analyze_with_graph_schema(filter.input.clone(), plan_ctx, graph_schema)?;
237397
filter.rebuild_or_clone(child_tf, logical_plan.clone())
@@ -311,6 +471,27 @@ impl AnalyzerPass for QueryValidation {
311471
}
312472
}
313473
LogicalPlan::WithClause(with_clause) => {
474+
// Validate subscript indices inside WITH projection expressions.
475+
let empty_map = HashMap::new();
476+
if let Some(err) = check_items(&with_clause.items, &empty_map) {
477+
return Err(AnalyzerError::InvalidPlan(err));
478+
}
479+
// Build alias→type map so WHERE/ORDER BY can resolve alias references.
480+
let alias_types = build_non_integer_alias_map(&with_clause.items);
481+
if let Some(ref wc) = with_clause.where_clause {
482+
if let Some(err) = find_invalid_array_subscript(wc, &alias_types) {
483+
return Err(AnalyzerError::InvalidPlan(err));
484+
}
485+
}
486+
if let Some(ref order_by) = with_clause.order_by {
487+
for item in order_by {
488+
if let Some(err) =
489+
find_invalid_array_subscript(&item.expression, &alias_types)
490+
{
491+
return Err(AnalyzerError::InvalidPlan(err));
492+
}
493+
}
494+
}
314495
let child_tf = self.analyze_with_graph_schema(
315496
with_clause.input.clone(),
316497
plan_ctx,

0 commit comments

Comments
 (0)