From 4f3ed20cdd88cdf01826a0e1b3c900fc4d5278d4 Mon Sep 17 00:00:00 2001 From: David Hagen Date: Sun, 12 Apr 2026 15:32:47 -0400 Subject: [PATCH 1/4] Implement string concatenation --- src/typed_expression/validate.rs | 57 ++++++++++++++++++++++++++++---- tests/operators/test_add.py | 48 +++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/src/typed_expression/validate.rs b/src/typed_expression/validate.rs index 36ae73a..0a6537a 100644 --- a/src/typed_expression/validate.rs +++ b/src/typed_expression/validate.rs @@ -115,12 +115,10 @@ impl Expression { // Arithmetic operators Expression::Add { left, right } => { - validate_binary_arithmetic(left, right, df_type, "addition", |l, r, et| { - TypedExpression::Add { - left: Arc::new(l), - right: Arc::new(r), - expression_type: et, - } + validate_add(left, right, df_type, |l, r, et| TypedExpression::Add { + left: Arc::new(l), + right: Arc::new(r), + expression_type: et, }) } @@ -373,6 +371,53 @@ where Ok(constructor(typed_left, typed_right, result_type)) } +fn validate_add( + left: &Expression, + right: &Expression, + df_type: &DataFrameType, + constructor: F, +) -> Result +where + F: FnOnce(TypedExpression, TypedExpression, ExpressionType) -> TypedExpression, +{ + let typed_left = left.validate(df_type)?; + let typed_right = right.validate(df_type)?; + let left_type = typed_left.expression_type(); + let right_type = typed_right.expression_type(); + let left_dt = left_type.data_type(); + let right_dt = right_type.data_type(); + + // Addition supports numeric types and strings (not Boolean, etc.) + let left_addable = + left_dt == DataType::Nothing || left_dt.is_numeric() || left_dt == DataType::String; + let right_addable = + right_dt == DataType::Nothing || right_dt.is_numeric() || right_dt == DataType::String; + + if !left_addable { + return Err(ValidationError::NumericTypeNotSatisfied { + operation: "addition".to_string(), + actual: left_dt, + }); + } + if !right_addable { + return Err(ValidationError::NumericTypeNotSatisfied { + operation: "addition".to_string(), + actual: right_dt, + }); + } + + // harmonize_expression_types handles: Nothing propagation, numeric promotion, + // and same-type broadcasting (covers String + String) + let result_type = harmonize_expression_types(left_type, right_type, "addition")?; + let result_dt = result_type.data_type(); + + Ok(constructor( + typed_left.cast_if_needed(result_dt), + typed_right.cast_if_needed(result_dt), + result_type, + )) +} + fn validate_comparison( left: &Expression, right: &Expression, diff --git a/tests/operators/test_add.py b/tests/operators/test_add.py index 63958f6..18b43ba 100644 --- a/tests/operators/test_add.py +++ b/tests/operators/test_add.py @@ -123,3 +123,51 @@ def test_add_with_decimal_literal(original_dtype, expected_dtype, expression): actual = df.transmute(result=expression) expected = DataFrame(result=Array[expected_dtype](4.5, 6.5, None)) assert_data_frames_equal(actual, expected, absolute_tolerance=absolute_tolerance) + + +@pytest.mark.parametrize( + ("left", "right", "answer"), + [ + ("a", "b", "ab"), + ("", "x", "x"), + ("x", "", "x"), + ("x", None, None), + (None, "x", None), + (None, None, None), + ], +) +def test_concatenate_strings(left, right, answer): + df = DataFrame(a=Array[DataType.String](left), b=Array[DataType.String](right)) + actual = df.transmute(c="a + b") + expected = DataFrame(c=Array[DataType.String](answer)) + assert_data_frames_equal(actual, expected) + + +@pytest.mark.parametrize( + ("value", "answer"), + [ + ("hello", "hello world"), + ("", " world"), + (None, None), + ], +) +def test_concatenate_column_with_literal(value, answer): + df = DataFrame(a=Array[DataType.String](value)) + actual = df.transmute(c="a + ' world'") + expected = DataFrame(c=Array[DataType.String](answer)) + assert_data_frames_equal(actual, expected) + + +@pytest.mark.parametrize( + ("value", "answer"), + [ + ("world", "hello world"), + ("", "hello "), + (None, None), + ], +) +def test_concatenate_literal_with_column(value, answer): + df = DataFrame(a=Array[DataType.String](value)) + actual = df.transmute(c="'hello ' + a") + expected = DataFrame(c=Array[DataType.String](answer)) + assert_data_frames_equal(actual, expected) From 14e8485513227e708a06ae95d85d01121d78c55e Mon Sep 17 00:00:00 2001 From: David Hagen Date: Sun, 12 Apr 2026 20:10:28 -0400 Subject: [PATCH 2/4] Simplify implementation --- src/typed_expression/validate.rs | 96 ++++++++++++++---------------- tests/operators/test_add.py | 17 ++++++ tests/operators/test_validation.py | 2 +- 3 files changed, 63 insertions(+), 52 deletions(-) diff --git a/src/typed_expression/validate.rs b/src/typed_expression/validate.rs index 0a6537a..a65c1b8 100644 --- a/src/typed_expression/validate.rs +++ b/src/typed_expression/validate.rs @@ -115,10 +115,51 @@ impl Expression { // Arithmetic operators Expression::Add { left, right } => { - validate_add(left, right, df_type, |l, r, et| TypedExpression::Add { - left: Arc::new(l), - right: Arc::new(r), - expression_type: et, + let typed_left = left.validate(df_type)?; + let typed_right = right.validate(df_type)?; + let left_type = typed_left.expression_type(); + let right_type = typed_right.expression_type(); + let left_dt = left_type.data_type(); + let right_dt = right_type.data_type(); + + let result_type = match (left_dt, right_dt) { + // Numeric + Numeric -> Numeric + // Nothing + Numeric or Numeric + Nothing -> Nothing + // This also handles Nothing + Nothing -> Nothing + (l, r) if l.is_numeric() && r.is_numeric() => { + promote_expression_types(left_type, right_type, "addition")? + } + // String + String -> String + // Nothing + String or String + Nothing -> Nothing + (DataType::String, DataType::String) + | (DataType::Nothing, DataType::String) + | (DataType::String, DataType::Nothing) => { + let result_dt = if left_dt == right_dt { + DataType::String + } else { + DataType::Nothing + }; + match (left_type, right_type) { + (ExpressionType::Array(_), _) | (_, ExpressionType::Array(_)) => { + ExpressionType::Array(result_dt) + } + _ => ExpressionType::Scalar(result_dt), + } + } + (l, r) => { + return Err(ValidationError::IncompatibleTypes { + operation: "addition".to_string(), + left_type: l, + right_type: r, + }) + } + }; + + let result_dt = result_type.data_type(); + Ok(TypedExpression::Add { + left: Arc::new(typed_left.cast_if_needed(result_dt)), + right: Arc::new(typed_right.cast_if_needed(result_dt)), + expression_type: result_type, }) } @@ -371,53 +412,6 @@ where Ok(constructor(typed_left, typed_right, result_type)) } -fn validate_add( - left: &Expression, - right: &Expression, - df_type: &DataFrameType, - constructor: F, -) -> Result -where - F: FnOnce(TypedExpression, TypedExpression, ExpressionType) -> TypedExpression, -{ - let typed_left = left.validate(df_type)?; - let typed_right = right.validate(df_type)?; - let left_type = typed_left.expression_type(); - let right_type = typed_right.expression_type(); - let left_dt = left_type.data_type(); - let right_dt = right_type.data_type(); - - // Addition supports numeric types and strings (not Boolean, etc.) - let left_addable = - left_dt == DataType::Nothing || left_dt.is_numeric() || left_dt == DataType::String; - let right_addable = - right_dt == DataType::Nothing || right_dt.is_numeric() || right_dt == DataType::String; - - if !left_addable { - return Err(ValidationError::NumericTypeNotSatisfied { - operation: "addition".to_string(), - actual: left_dt, - }); - } - if !right_addable { - return Err(ValidationError::NumericTypeNotSatisfied { - operation: "addition".to_string(), - actual: right_dt, - }); - } - - // harmonize_expression_types handles: Nothing propagation, numeric promotion, - // and same-type broadcasting (covers String + String) - let result_type = harmonize_expression_types(left_type, right_type, "addition")?; - let result_dt = result_type.data_type(); - - Ok(constructor( - typed_left.cast_if_needed(result_dt), - typed_right.cast_if_needed(result_dt), - result_type, - )) -} - fn validate_comparison( left: &Expression, right: &Expression, diff --git a/tests/operators/test_add.py b/tests/operators/test_add.py index 18b43ba..5ff9919 100644 --- a/tests/operators/test_add.py +++ b/tests/operators/test_add.py @@ -3,6 +3,7 @@ import pytest from tabeline import Array, DataFrame, DataType +from tabeline.exceptions import IncompatibleTypesError from tabeline.testing import assert_data_frames_equal from .._types import ( @@ -171,3 +172,19 @@ def test_concatenate_literal_with_column(value, answer): actual = df.transmute(c="'hello ' + a") expected = DataFrame(c=Array[DataType.String](answer)) assert_data_frames_equal(actual, expected) + + +@pytest.mark.parametrize( + ("values", "right_type"), + [ + ([True, False, True], DataType.Boolean), + (["a", "b", "c"], DataType.String), + ], +) +def test_addition_rejects_incompatible_operand(values, right_type): + df = DataFrame(x=[1, 2, 3], y=values) + + with pytest.raises(IncompatibleTypesError) as exc_info: + df.mutate(z="x + y") + + assert exc_info.value == IncompatibleTypesError("addition", DataType.Integer64, right_type) diff --git a/tests/operators/test_validation.py b/tests/operators/test_validation.py index 09547ef..00ff4e7 100644 --- a/tests/operators/test_validation.py +++ b/tests/operators/test_validation.py @@ -7,7 +7,7 @@ @pytest.mark.parametrize( ("expression", "operation"), [ - ("x + y", "addition"), + # No `x + y` because `+` also does string concatenation ("x - y", "subtraction"), ("x * y", "multiplication"), ("x / y", "division"), From a3cb071f097948de86540fde40649e2ee2c8529d Mon Sep 17 00:00:00 2001 From: David Hagen Date: Tue, 14 Apr 2026 07:32:13 -0400 Subject: [PATCH 3/4] Expand testing to more types --- src/typed_expression/validate.rs | 7 +++-- tests/operators/test_add.py | 45 +++++++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/typed_expression/validate.rs b/src/typed_expression/validate.rs index a65c1b8..bc0bf04 100644 --- a/src/typed_expression/validate.rs +++ b/src/typed_expression/validate.rs @@ -134,10 +134,9 @@ impl Expression { (DataType::String, DataType::String) | (DataType::Nothing, DataType::String) | (DataType::String, DataType::Nothing) => { - let result_dt = if left_dt == right_dt { - DataType::String - } else { - DataType::Nothing + let result_dt = match (left_dt, right_dt) { + (DataType::String, DataType::String) => DataType::String, + _ => DataType::Nothing, }; match (left_type, right_type) { (ExpressionType::Array(_), _) | (_, ExpressionType::Array(_)) => { diff --git a/tests/operators/test_add.py b/tests/operators/test_add.py index 5ff9919..675bc02 100644 --- a/tests/operators/test_add.py +++ b/tests/operators/test_add.py @@ -175,16 +175,49 @@ def test_concatenate_literal_with_column(value, answer): @pytest.mark.parametrize( - ("values", "right_type"), + ("string_value", "answer"), [ - ([True, False, True], DataType.Boolean), - (["a", "b", "c"], DataType.String), + ("x", None), + (None, None), + ], +) +def test_concatenate_string_with_nothing(string_value, answer): + df = DataFrame(a=Array[DataType.String](string_value), b=Array[DataType.Nothing](None)) + actual = df.transmute(c="a + b") + expected = DataFrame(c=Array[DataType.Nothing](answer)) + assert_data_frames_equal(actual, expected) + + +@pytest.mark.parametrize( + ("string_value", "answer"), + [ + ("x", None), + (None, None), + ], +) +def test_concatenate_nothing_with_string(string_value, answer): + df = DataFrame(a=Array[DataType.Nothing](None), b=Array[DataType.String](string_value)) + actual = df.transmute(c="a + b") + expected = DataFrame(c=Array[DataType.Nothing](answer)) + assert_data_frames_equal(actual, expected) + + +@pytest.mark.parametrize( + ("left_values", "right_values", "left_type", "right_type"), + [ + ([1, 2, 3], [True, False, True], DataType.Integer64, DataType.Boolean), + ([1, 2, 3], ["a", "b", "c"], DataType.Integer64, DataType.String), + (["a", "b", "c"], [1, 2, 3], DataType.String, DataType.Integer64), + (["a", "b", "c"], [True, False, True], DataType.String, DataType.Boolean), + ([True, False, True], [True, False, True], DataType.Boolean, DataType.Boolean), + ([True, False, True], [1, 2, 3], DataType.Boolean, DataType.Integer64), + ([True, False, True], ["a", "b", "c"], DataType.Boolean, DataType.String), ], ) -def test_addition_rejects_incompatible_operand(values, right_type): - df = DataFrame(x=[1, 2, 3], y=values) +def test_addition_rejects_incompatible_operand(left_values, right_values, left_type, right_type): + df = DataFrame(x=left_values, y=right_values) with pytest.raises(IncompatibleTypesError) as exc_info: df.mutate(z="x + y") - assert exc_info.value == IncompatibleTypesError("addition", DataType.Integer64, right_type) + assert exc_info.value == IncompatibleTypesError("addition", left_type, right_type) From cacaf09097731c16efa78bf05ffa21323ae2e81d Mon Sep 17 00:00:00 2001 From: David Hagen Date: Tue, 14 Apr 2026 07:47:51 -0400 Subject: [PATCH 4/4] Simplify plus validation --- src/typed_expression/validate.rs | 48 +++++++++++--------------------- 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/src/typed_expression/validate.rs b/src/typed_expression/validate.rs index bc0bf04..4c88217 100644 --- a/src/typed_expression/validate.rs +++ b/src/typed_expression/validate.rs @@ -122,38 +122,24 @@ impl Expression { let left_dt = left_type.data_type(); let right_dt = right_type.data_type(); - let result_type = match (left_dt, right_dt) { - // Numeric + Numeric -> Numeric - // Nothing + Numeric or Numeric + Nothing -> Nothing - // This also handles Nothing + Nothing -> Nothing - (l, r) if l.is_numeric() && r.is_numeric() => { - promote_expression_types(left_type, right_type, "addition")? - } - // String + String -> String - // Nothing + String or String + Nothing -> Nothing - (DataType::String, DataType::String) - | (DataType::Nothing, DataType::String) - | (DataType::String, DataType::Nothing) => { - let result_dt = match (left_dt, right_dt) { - (DataType::String, DataType::String) => DataType::String, - _ => DataType::Nothing, - }; - match (left_type, right_type) { - (ExpressionType::Array(_), _) | (_, ExpressionType::Array(_)) => { - ExpressionType::Array(result_dt) - } - _ => ExpressionType::Scalar(result_dt), - } - } - (l, r) => { - return Err(ValidationError::IncompatibleTypes { - operation: "addition".to_string(), - left_type: l, - right_type: r, - }) - } - }; + let is_addition = left_dt.is_numeric() && right_dt.is_numeric(); + let is_concatenation = matches!( + (left_dt, right_dt), + ( + DataType::String | DataType::Nothing, + DataType::String | DataType::Nothing + ) + ); + + if !is_addition && !is_concatenation { + return Err(ValidationError::IncompatibleTypes { + operation: "addition".to_string(), + left_type: left_dt, + right_type: right_dt, + }); + } + let result_type = promote_expression_types(left_type, right_type, "addition")?; let result_dt = result_type.data_type(); Ok(TypedExpression::Add { left: Arc::new(typed_left.cast_if_needed(result_dt)),