Skip to content

Commit 313a6df

Browse files
adriangbclaude
andcommitted
test: cover LikeExpr proto hooks; add shared proto test stubs
Add direct unit tests for `LikeExpr::try_to_proto` / `try_from_proto` covering the happy path and every error branch (wrong node, missing `expr`/`pattern`, and child encode/decode failures), reaching 100% line and region coverage of the new proto hook code. Extract the generic encode/decode test stubs into a cfg-guarded `proto_test_util` module so the other `PhysicalExpr` migrations in this crate (issue #22418) can reuse them; LikeExpr keeps only its expression-specific node builder and fixture. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f8f274f commit 313a6df

3 files changed

Lines changed: 341 additions & 0 deletions

File tree

datafusion/physical-expr/src/expressions/like.rs

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,3 +346,192 @@ mod test {
346346
Ok(())
347347
}
348348
}
349+
350+
/// Tests for the `try_to_proto` / `try_from_proto` hooks (issue #22418).
351+
///
352+
/// These drive the encode/decode hooks directly with small stub
353+
/// encoder/decoders so every branch can be covered without depending on
354+
/// `datafusion-proto`: the happy path, the wrong-node and missing-child
355+
/// rejections, and child encode/decode error propagation.
356+
#[cfg(all(test, feature = "proto"))]
357+
mod proto_tests {
358+
use super::*;
359+
use crate::expressions::{Column, col};
360+
use crate::proto_test_util::{
361+
StubDecoder, StubEncoder, UnreachableDecoder, column_node,
362+
};
363+
use arrow::datatypes::Field;
364+
use datafusion_common::DataFusionError;
365+
use datafusion_physical_expr_common::physical_expr::proto_decode::PhysicalExprDecodeCtx;
366+
use datafusion_physical_expr_common::physical_expr::proto_encode::PhysicalExprEncodeCtx;
367+
use datafusion_proto_models::protobuf::{
368+
PhysicalExprNode, PhysicalLikeExprNode, physical_expr_node,
369+
};
370+
371+
/// Build a `LikeExpr` proto node with the given children.
372+
fn like_node(
373+
negated: bool,
374+
case_insensitive: bool,
375+
expr: Option<Box<PhysicalExprNode>>,
376+
pattern: Option<Box<PhysicalExprNode>>,
377+
) -> PhysicalExprNode {
378+
PhysicalExprNode {
379+
expr_id: None,
380+
expr_type: Some(physical_expr_node::ExprType::LikeExpr(Box::new(
381+
PhysicalLikeExprNode {
382+
negated,
383+
case_insensitive,
384+
expr,
385+
pattern,
386+
},
387+
))),
388+
}
389+
}
390+
391+
/// A `LikeExpr` over two `Utf8` columns with both flags set, so the
392+
/// `negated` / `case_insensitive` wiring is actually exercised.
393+
fn like_fixture() -> LikeExpr {
394+
let schema = Schema::new(vec![
395+
Field::new("a", DataType::Utf8, false),
396+
Field::new("b", DataType::Utf8, false),
397+
]);
398+
LikeExpr::new(
399+
true,
400+
true,
401+
col("a", &schema).unwrap(),
402+
col("b", &schema).unwrap(),
403+
)
404+
}
405+
406+
#[test]
407+
fn try_to_proto_encodes_like_expr() {
408+
let like = like_fixture();
409+
let encoder = StubEncoder::ok();
410+
let ctx = PhysicalExprEncodeCtx::new(&encoder);
411+
412+
let node = like
413+
.try_to_proto(&ctx)
414+
.unwrap()
415+
.expect("LikeExpr should encode to Some(node)");
416+
417+
// Built-in exprs never set expr_id; only dynamic filters do.
418+
assert!(node.expr_id.is_none());
419+
let like_node = match node.expr_type {
420+
Some(physical_expr_node::ExprType::LikeExpr(boxed)) => *boxed,
421+
other => panic!("expected a LikeExpr node, got {other:?}"),
422+
};
423+
assert!(like_node.negated);
424+
assert!(like_node.case_insensitive);
425+
assert!(like_node.expr.is_some());
426+
assert!(like_node.pattern.is_some());
427+
}
428+
429+
#[test]
430+
fn try_to_proto_propagates_expr_encode_error() {
431+
let like = like_fixture();
432+
let encoder = StubEncoder::failing_on(1);
433+
let ctx = PhysicalExprEncodeCtx::new(&encoder);
434+
let err = like.try_to_proto(&ctx).unwrap_err();
435+
assert!(matches!(err, DataFusionError::Internal(msg) if msg.contains("call 1")));
436+
}
437+
438+
#[test]
439+
fn try_to_proto_propagates_pattern_encode_error() {
440+
let like = like_fixture();
441+
let encoder = StubEncoder::failing_on(2);
442+
let ctx = PhysicalExprEncodeCtx::new(&encoder);
443+
let err = like.try_to_proto(&ctx).unwrap_err();
444+
assert!(matches!(err, DataFusionError::Internal(msg) if msg.contains("call 2")));
445+
}
446+
447+
#[test]
448+
fn try_from_proto_decodes_like_expr() {
449+
let node = like_node(
450+
true,
451+
true,
452+
Some(Box::new(column_node("a"))),
453+
Some(Box::new(column_node("b"))),
454+
);
455+
let schema = Schema::empty();
456+
let decoder = StubDecoder::ok();
457+
let ctx = PhysicalExprDecodeCtx::new(&schema, &decoder);
458+
459+
let decoded = LikeExpr::try_from_proto(&node, &ctx).unwrap();
460+
let like = decoded
461+
.downcast_ref::<LikeExpr>()
462+
.expect("decoded expr should be a LikeExpr");
463+
assert!(like.negated());
464+
assert!(like.case_insensitive());
465+
assert!(like.expr().downcast_ref::<Column>().is_some());
466+
assert!(like.pattern().downcast_ref::<Column>().is_some());
467+
}
468+
469+
#[test]
470+
fn try_from_proto_rejects_non_like_node() {
471+
let node = column_node("a");
472+
let schema = Schema::empty();
473+
let decoder = UnreachableDecoder;
474+
let ctx = PhysicalExprDecodeCtx::new(&schema, &decoder);
475+
let err = LikeExpr::try_from_proto(&node, &ctx).unwrap_err();
476+
assert!(matches!(
477+
err,
478+
DataFusionError::Internal(msg) if msg == "PhysicalExprNode is not a LikeExpr"
479+
));
480+
}
481+
482+
#[test]
483+
fn try_from_proto_rejects_missing_expr() {
484+
let node = like_node(false, false, None, Some(Box::new(column_node("b"))));
485+
let schema = Schema::empty();
486+
let decoder = UnreachableDecoder;
487+
let ctx = PhysicalExprDecodeCtx::new(&schema, &decoder);
488+
let err = LikeExpr::try_from_proto(&node, &ctx).unwrap_err();
489+
assert!(matches!(
490+
err,
491+
DataFusionError::Internal(msg) if msg == "LikeExpr is missing required field 'expr'"
492+
));
493+
}
494+
495+
#[test]
496+
fn try_from_proto_rejects_missing_pattern() {
497+
let node = like_node(false, false, Some(Box::new(column_node("a"))), None);
498+
let schema = Schema::empty();
499+
let decoder = UnreachableDecoder;
500+
let ctx = PhysicalExprDecodeCtx::new(&schema, &decoder);
501+
let err = LikeExpr::try_from_proto(&node, &ctx).unwrap_err();
502+
assert!(matches!(
503+
err,
504+
DataFusionError::Internal(msg) if msg == "LikeExpr is missing required field 'pattern'"
505+
));
506+
}
507+
508+
#[test]
509+
fn try_from_proto_propagates_expr_decode_error() {
510+
let node = like_node(
511+
false,
512+
false,
513+
Some(Box::new(column_node("a"))),
514+
Some(Box::new(column_node("b"))),
515+
);
516+
let schema = Schema::empty();
517+
let decoder = StubDecoder::failing_on(1);
518+
let ctx = PhysicalExprDecodeCtx::new(&schema, &decoder);
519+
let err = LikeExpr::try_from_proto(&node, &ctx).unwrap_err();
520+
assert!(matches!(err, DataFusionError::Internal(msg) if msg.contains("call 1")));
521+
}
522+
523+
#[test]
524+
fn try_from_proto_propagates_pattern_decode_error() {
525+
let node = like_node(
526+
false,
527+
false,
528+
Some(Box::new(column_node("a"))),
529+
Some(Box::new(column_node("b"))),
530+
);
531+
let schema = Schema::empty();
532+
let decoder = StubDecoder::failing_on(2);
533+
let ctx = PhysicalExprDecodeCtx::new(&schema, &decoder);
534+
let err = LikeExpr::try_from_proto(&node, &ctx).unwrap_err();
535+
assert!(matches!(err, DataFusionError::Internal(msg) if msg.contains("call 2")));
536+
}
537+
}

datafusion/physical-expr/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ mod partitioning;
4040
mod physical_expr;
4141
pub mod planner;
4242
pub mod projection;
43+
/// Shared test helpers for the `try_to_proto` / `try_from_proto` migration
44+
/// (issue #22418). Compiled only under `cfg(test)` with the `proto` feature.
45+
#[cfg(all(test, feature = "proto"))]
46+
pub(crate) mod proto_test_util;
4347
mod scalar_function;
4448
pub mod scalar_subquery;
4549
pub mod simplifier;
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
//! Shared test helpers for exercising the `try_to_proto` / `try_from_proto`
19+
//! hooks (issue #22418) without depending on `datafusion-proto`.
20+
//!
21+
//! As built-in expressions are ported off the central proto downcast chain,
22+
//! each one's unit tests need to drive the new encode/decode hooks in
23+
//! isolation. The generic scaffolding — stub encoder/decoders and a
24+
//! placeholder child-node builder — lives here so it is written once and
25+
//! reused across expressions. Expression-specific node builders and fixtures
26+
//! stay in each expression's own test module.
27+
28+
use std::cell::Cell;
29+
use std::sync::Arc;
30+
31+
use arrow::datatypes::Schema;
32+
use datafusion_common::{DataFusionError, Result};
33+
use datafusion_physical_expr_common::physical_expr::PhysicalExpr;
34+
use datafusion_physical_expr_common::physical_expr::proto_decode::PhysicalExprDecode;
35+
use datafusion_physical_expr_common::physical_expr::proto_encode::PhysicalExprEncode;
36+
use datafusion_proto_models::protobuf::{self, PhysicalExprNode, physical_expr_node};
37+
38+
use crate::expressions::Column;
39+
40+
/// A proto node for a `Column`, useful as a stand-in child node when building
41+
/// an expression's proto representation in tests.
42+
pub(crate) fn column_node(name: &str) -> PhysicalExprNode {
43+
PhysicalExprNode {
44+
expr_id: None,
45+
expr_type: Some(physical_expr_node::ExprType::Column(
46+
protobuf::PhysicalColumn {
47+
name: name.to_string(),
48+
index: 0,
49+
},
50+
)),
51+
}
52+
}
53+
54+
/// Decoder stub for driving `try_from_proto`: returns a fixed `Column` for each
55+
/// child node, optionally failing on the Nth `decode` call so the
56+
/// `ctx.decode(..)?` error arms can be exercised.
57+
pub(crate) struct StubDecoder {
58+
fail_on_call: Option<usize>,
59+
calls: Cell<usize>,
60+
}
61+
62+
impl StubDecoder {
63+
/// Always succeeds, returning a placeholder `Column` per child.
64+
pub(crate) fn ok() -> Self {
65+
Self {
66+
fail_on_call: None,
67+
calls: Cell::new(0),
68+
}
69+
}
70+
71+
/// Fails on the `call`-th invocation (1-based), succeeding otherwise.
72+
pub(crate) fn failing_on(call: usize) -> Self {
73+
Self {
74+
fail_on_call: Some(call),
75+
calls: Cell::new(0),
76+
}
77+
}
78+
}
79+
80+
impl PhysicalExprDecode for StubDecoder {
81+
fn decode(
82+
&self,
83+
_node: &PhysicalExprNode,
84+
_schema: &Schema,
85+
) -> Result<Arc<dyn PhysicalExpr>> {
86+
let call = self.calls.get() + 1;
87+
self.calls.set(call);
88+
if Some(call) == self.fail_on_call {
89+
return Err(DataFusionError::Internal(format!(
90+
"stub decode failure on call {call}"
91+
)));
92+
}
93+
Ok(Arc::new(Column::new("decoded", 0)))
94+
}
95+
}
96+
97+
/// Decoder that must never run: used to assert that the reject paths of a
98+
/// `try_from_proto` (wrong node, missing child) bail out before decoding.
99+
pub(crate) struct UnreachableDecoder;
100+
101+
impl PhysicalExprDecode for UnreachableDecoder {
102+
fn decode(
103+
&self,
104+
_node: &PhysicalExprNode,
105+
_schema: &Schema,
106+
) -> Result<Arc<dyn PhysicalExpr>> {
107+
unreachable!("decode must not be reached when the node is rejected")
108+
}
109+
}
110+
111+
/// Encoder stub for driving `try_to_proto`: emits a placeholder `Column` node
112+
/// for each child, optionally failing on the Nth `encode` call so the
113+
/// `ctx.encode_child(..)?` error arms can be exercised.
114+
pub(crate) struct StubEncoder {
115+
fail_on_call: Option<usize>,
116+
calls: Cell<usize>,
117+
}
118+
119+
impl StubEncoder {
120+
/// Always succeeds, emitting a placeholder `Column` node per child.
121+
pub(crate) fn ok() -> Self {
122+
Self {
123+
fail_on_call: None,
124+
calls: Cell::new(0),
125+
}
126+
}
127+
128+
/// Fails on the `call`-th invocation (1-based), succeeding otherwise.
129+
pub(crate) fn failing_on(call: usize) -> Self {
130+
Self {
131+
fail_on_call: Some(call),
132+
calls: Cell::new(0),
133+
}
134+
}
135+
}
136+
137+
impl PhysicalExprEncode for StubEncoder {
138+
fn encode(&self, _expr: &Arc<dyn PhysicalExpr>) -> Result<PhysicalExprNode> {
139+
let call = self.calls.get() + 1;
140+
self.calls.set(call);
141+
if Some(call) == self.fail_on_call {
142+
return Err(DataFusionError::Internal(format!(
143+
"stub encode failure on call {call}"
144+
)));
145+
}
146+
Ok(column_node("child"))
147+
}
148+
}

0 commit comments

Comments
 (0)