diff --git a/crates/polars-expr/src/dispatch/mod.rs b/crates/polars-expr/src/dispatch/mod.rs index 6c4e4e2ab4a8..b6bc2d578f7b 100644 --- a/crates/polars-expr/src/dispatch/mod.rs +++ b/crates/polars-expr/src/dispatch/mod.rs @@ -371,6 +371,7 @@ pub fn function_expr_to_udf(func: IRFunctionExpr) -> SpecialEq map_as_slice!(misc::fused, op), F::ConcatExpr { rechunk } => map_as_slice!(misc::concat_expr, rechunk), + F::ConcatList => wrap!(list::concat), #[cfg(feature = "cov")] F::Correlation { method } => map_as_slice!(misc::corr, method), #[cfg(feature = "peaks")] diff --git a/crates/polars-plan/dsl-schema-hashes.json b/crates/polars-plan/dsl-schema-hashes.json index 07b4902d25ba..1f5a19797b2d 100644 --- a/crates/polars-plan/dsl-schema-hashes.json +++ b/crates/polars-plan/dsl-schema-hashes.json @@ -66,7 +66,7 @@ "FileSinkOptions": "edebcf5e3965add5e4fd1be14ca6bdddc55fa22e6e829dca04beb321de0c992c", "FileWriteFormat": "1a685aba7dd5d6c0aefc99a9060d1b57f166ea44ef57ad0d0d0c565dbabda811", "FillNullStrategy": "459a9a9702415f9ca9e5218bb573609a60291e73162c38fbc046c97feb1b7500", - "FunctionExpr": "1b723aff4de79571f7263b470de2d4bfb9f1204ae91c7e5e09b37f66bc27875c", + "FunctionExpr": "04d59b9375612be6a620c3d3b8e9f7eeb4c4f8896e098e3abedb9336ff8694bf", "FunctionFlags": "54fd84a1b628c426b8d0f5e9bca174093e07da8992a9a9bb4c191d07133e0046", "FunctionOptions": "0784524479a30a7d91b890b03feac9eca6c46d04f0a7c3f4a9a2d827c3e34b5e", "GroupbyOptions": "0cda61fc19eb9866157ae4afeed3dc018294aaea5f02692b085885de771bfcdb", diff --git a/crates/polars-plan/src/dsl/function_expr/mod.rs b/crates/polars-plan/src/dsl/function_expr/mod.rs index 4b32ceb7bbcc..95aecb6bd789 100644 --- a/crates/polars-plan/src/dsl/function_expr/mod.rs +++ b/crates/polars-plan/src/dsl/function_expr/mod.rs @@ -265,6 +265,7 @@ pub enum FunctionExpr { UpperBound, LowerBound, ConcatExpr(bool), + ConcatList, #[cfg(feature = "cov")] Correlation { method: correlation::CorrelationMethod, @@ -596,6 +597,7 @@ impl Hash for FunctionExpr { UpperBound => {}, LowerBound => {}, ConcatExpr(rechunk) => rechunk.hash(state), + ConcatList => {}, #[cfg(feature = "peaks")] PeakMin => {}, #[cfg(feature = "peaks")] @@ -834,6 +836,7 @@ impl Display for FunctionExpr { UpperBound => "upper_bound", LowerBound => "lower_bound", ConcatExpr(..) => "concat_expr", + ConcatList => "concat_list", #[cfg(feature = "cov")] Correlation { method, .. } => return Display::fmt(method, f), #[cfg(feature = "peaks")] diff --git a/crates/polars-plan/src/dsl/functions/concat.rs b/crates/polars-plan/src/dsl/functions/concat.rs index 32618440a5d6..0c356d7c9079 100644 --- a/crates/polars-plan/src/dsl/functions/concat.rs +++ b/crates/polars-plan/src/dsl/functions/concat.rs @@ -112,7 +112,7 @@ pub fn concat_list, IE: Into + Clone>(s: E) -> PolarsResult Ok(Expr::Function { input: s, - function: FunctionExpr::ListExpr(ListFunction::Concat), + function: FunctionExpr::ConcatList, }) } diff --git a/crates/polars-plan/src/dsl/functions/mod.rs b/crates/polars-plan/src/dsl/functions/mod.rs index b86479635d89..f2aa4ca99bf6 100644 --- a/crates/polars-plan/src/dsl/functions/mod.rs +++ b/crates/polars-plan/src/dsl/functions/mod.rs @@ -52,7 +52,6 @@ pub use temporal::*; #[cfg(feature = "arg_where")] use crate::dsl::function_expr::FunctionExpr; -use crate::dsl::function_expr::ListFunction; #[cfg(all(feature = "concat_str", feature = "strings"))] use crate::dsl::function_expr::StringFunction; use crate::dsl::*; diff --git a/crates/polars-plan/src/dsl/list.rs b/crates/polars-plan/src/dsl/list.rs index 82c1e95b30cd..f3fc54093c69 100644 --- a/crates/polars-plan/src/dsl/list.rs +++ b/crates/polars-plan/src/dsl/list.rs @@ -296,6 +296,17 @@ impl ListNameSpace { } } + /// Concatenate the list with another list. + pub fn concat, IE: Into + Clone>(self, other: E) -> Expr { + let mut input: Vec<_> = other.as_ref().iter().map(|e| e.clone().into()).collect(); + input.insert(0, self.0); + + Expr::Function { + input, + function: FunctionExpr::ListExpr(ListFunction::Concat), + } + } + pub fn agg>(self, other: E) -> Expr { Expr::Eval { expr: Arc::new(self.0), diff --git a/crates/polars-plan/src/plans/aexpr/function_expr/list.rs b/crates/polars-plan/src/plans/aexpr/function_expr/list.rs index c44bf8ea90b9..287155425276 100644 --- a/crates/polars-plan/src/plans/aexpr/function_expr/list.rs +++ b/crates/polars-plan/src/plans/aexpr/function_expr/list.rs @@ -73,7 +73,7 @@ impl IRListFunction { pub(super) fn get_field(&self, mapper: FieldsMapper) -> PolarsResult { use IRListFunction::*; match self { - Concat => mapper.map_to_list_supertype(), + Concat => mapper.ensure_is_list()?.map_to_list_supertype(), #[cfg(feature = "is_in")] Contains { nulls_equal: _ } => mapper.ensure_is_list()?.with_dtype(DataType::Boolean), #[cfg(feature = "list_drop_nulls")] diff --git a/crates/polars-plan/src/plans/aexpr/function_expr/mod.rs b/crates/polars-plan/src/plans/aexpr/function_expr/mod.rs index 51e1cc7d2e49..4c6afa431966 100644 --- a/crates/polars-plan/src/plans/aexpr/function_expr/mod.rs +++ b/crates/polars-plan/src/plans/aexpr/function_expr/mod.rs @@ -277,6 +277,7 @@ pub enum IRFunctionExpr { ConcatExpr { rechunk: bool, }, + ConcatList, #[cfg(feature = "cov")] Correlation { method: correlation::IRCorrelationMethod, @@ -613,6 +614,7 @@ impl Hash for IRFunctionExpr { #[cfg(feature = "round_series")] Ceil => {}, ConcatExpr { rechunk } => rechunk.hash(state), + ConcatList => {}, #[cfg(feature = "peaks")] PeakMin => {}, #[cfg(feature = "peaks")] @@ -853,6 +855,7 @@ impl Display for IRFunctionExpr { #[cfg(feature = "fused")] Fused(fused) => return Display::fmt(fused, f), ConcatExpr { .. } => "concat_expr", + ConcatList => "concat_list", #[cfg(feature = "cov")] Correlation { method, .. } => return Display::fmt(method, f), #[cfg(feature = "peaks")] @@ -1172,6 +1175,8 @@ impl IRFunctionExpr { F::ConcatExpr { .. } => FunctionOptions::groupwise() .with_flags(|f| f | FunctionFlags::INPUT_WILDCARD_EXPANSION) .with_supertyping(Default::default()), + F::ConcatList => FunctionOptions::elementwise() + .with_flags(|f| f | FunctionFlags::INPUT_WILDCARD_EXPANSION), #[cfg(feature = "cov")] F::Correlation { .. } => { FunctionOptions::aggregation().with_supertyping(Default::default()) diff --git a/crates/polars-plan/src/plans/aexpr/function_expr/schema.rs b/crates/polars-plan/src/plans/aexpr/function_expr/schema.rs index 4b6e74cc5b3c..a10e17f77979 100644 --- a/crates/polars-plan/src/plans/aexpr/function_expr/schema.rs +++ b/crates/polars-plan/src/plans/aexpr/function_expr/schema.rs @@ -285,6 +285,7 @@ impl IRFunctionExpr { #[cfg(feature = "fused")] Fused(_) => mapper.map_to_supertype(), ConcatExpr { .. } => mapper.map_to_supertype(), + ConcatList => mapper.map_to_list_supertype(), #[cfg(feature = "cov")] Correlation { .. } => mapper.map_to_float_dtype(), #[cfg(feature = "peaks")] diff --git a/crates/polars-plan/src/plans/conversion/dsl_to_ir/expr_expansion.rs b/crates/polars-plan/src/plans/conversion/dsl_to_ir/expr_expansion.rs index e7e45ed56be5..a6ff48b84f86 100644 --- a/crates/polars-plan/src/plans/conversion/dsl_to_ir/expr_expansion.rs +++ b/crates/polars-plan/src/plans/conversion/dsl_to_ir/expr_expansion.rs @@ -82,6 +82,7 @@ fn function_input_wildcard_expansion(function: &FunctionExpr) -> FunctionExpansi | F::Coalesce | F::ListExpr(ListFunction::Concat) | F::ConcatExpr(..) + | F::ConcatList | F::MinHorizontal | F::MaxHorizontal | F::FoldHorizontal { .. } diff --git a/crates/polars-plan/src/plans/conversion/dsl_to_ir/functions.rs b/crates/polars-plan/src/plans/conversion/dsl_to_ir/functions.rs index 6e7795470e95..28b25158c766 100644 --- a/crates/polars-plan/src/plans/conversion/dsl_to_ir/functions.rs +++ b/crates/polars-plan/src/plans/conversion/dsl_to_ir/functions.rs @@ -914,6 +914,7 @@ pub(super) fn convert_functions( )); }, F::ConcatExpr(rechunk) => I::ConcatExpr { rechunk }, + F::ConcatList => I::ConcatList, #[cfg(feature = "cov")] F::Correlation { method } => { use CorrelationMethod as C; diff --git a/crates/polars-plan/src/plans/conversion/ir_to_dsl.rs b/crates/polars-plan/src/plans/conversion/ir_to_dsl.rs index da3038bb7ddc..8bcad4407d6f 100644 --- a/crates/polars-plan/src/plans/conversion/ir_to_dsl.rs +++ b/crates/polars-plan/src/plans/conversion/ir_to_dsl.rs @@ -1024,6 +1024,7 @@ pub fn ir_function_to_dsl(input: Vec, function: IRFunctionExpr) -> Expr { }; }, IF::ConcatExpr { rechunk } => F::ConcatExpr(rechunk), + IF::ConcatList => F::ConcatList, #[cfg(feature = "cov")] IF::Correlation { method } => { use CorrelationMethod as C; diff --git a/crates/polars-python/src/expr/list.rs b/crates/polars-python/src/expr/list.rs index b187d3d12fb2..099cf50d36a1 100644 --- a/crates/polars-python/src/expr/list.rs +++ b/crates/polars-python/src/expr/list.rs @@ -39,6 +39,11 @@ impl PyExpr { self.inner.clone().list().eval(expr.inner).into() } + fn list_concat(&self, other: Vec) -> Self { + let other: Vec<_> = other.into_iter().map(|e| e.inner).collect(); + self.inner.clone().list().concat(&other).into() + } + fn list_agg(&self, expr: PyExpr) -> Self { self.inner.clone().list().agg(expr.inner).into() } diff --git a/crates/polars-python/src/lazyframe/visitor/expr_nodes.rs b/crates/polars-python/src/lazyframe/visitor/expr_nodes.rs index 9e7e07ff5b3e..635a3f8c0652 100644 --- a/crates/polars-python/src/lazyframe/visitor/expr_nodes.rs +++ b/crates/polars-python/src/lazyframe/visitor/expr_nodes.rs @@ -1352,6 +1352,9 @@ pub(crate) fn into_py(py: Python<'_>, expr: &AExpr) -> PyResult> { IRFunctionExpr::ConcatExpr { .. } => { return Err(PyNotImplementedError::new_err("concat expr")); }, + IRFunctionExpr::ConcatList => { + return Err(PyNotImplementedError::new_err("concat list")); + }, IRFunctionExpr::Correlation { .. } => { return Err(PyNotImplementedError::new_err("corr")); }, diff --git a/py-polars/src/polars/_plr.pyi b/py-polars/src/polars/_plr.pyi index 237e64f04835..c112bd1e3049 100644 --- a/py-polars/src/polars/_plr.pyi +++ b/py-polars/src/polars/_plr.pyi @@ -1592,6 +1592,7 @@ class PyExpr: def list_count_matches(self, expr: PyExpr) -> PyExpr: ... def list_diff(self, n: int, null_behavior: NullBehavior) -> PyExpr: ... def list_eval(self, expr: PyExpr, _parallel: bool) -> PyExpr: ... + def list_concat(self, other: list[PyExpr]) -> PyExpr: ... def list_agg(self, expr: PyExpr) -> PyExpr: ... def list_filter(self, predicate: PyExpr) -> PyExpr: ... def list_get(self, index: PyExpr, null_on_oob: bool) -> PyExpr: ... diff --git a/py-polars/src/polars/expr/list.py b/py-polars/src/polars/expr/list.py index 57e3debcecb6..9ff96c4771cb 100644 --- a/py-polars/src/polars/expr/list.py +++ b/py-polars/src/polars/expr/list.py @@ -530,8 +530,8 @@ def concat(self, other: list[Expr | str] | Expr | str | Series | list[Any]) -> E other_list: list[Expr | str | Series] other_list = [other] if not isinstance(other, list) else copy.copy(other) # type: ignore[arg-type] - other_list.insert(0, wrap_expr(self._pyexpr)) - return F.concat_list(other_list) + other_exprs = [parse_into_expression(e) for e in other_list] + return wrap_expr(self._pyexpr.list_concat(other_exprs)) def get( self, diff --git a/py-polars/tests/unit/datatypes/test_temporal.py b/py-polars/tests/unit/datatypes/test_temporal.py index 33b70b9dfbcc..72fddc2776f1 100644 --- a/py-polars/tests/unit/datatypes/test_temporal.py +++ b/py-polars/tests/unit/datatypes/test_temporal.py @@ -1088,12 +1088,6 @@ def test_from_dict_tu_consistency() -> None: def test_date_arr_concat() -> None: expected = {"d": [[date(2000, 1, 1), date(2000, 1, 1)]]} - # type date - df = pl.DataFrame({"d": [date(2000, 1, 1)]}) - assert ( - df.select(pl.col("d").list.concat(pl.col("d"))).to_dict(as_series=False) - == expected - ) # type list[date] df = pl.DataFrame({"d": [[date(2000, 1, 1)]]}) assert ( diff --git a/py-polars/tests/unit/operations/namespaces/list/test_list.py b/py-polars/tests/unit/operations/namespaces/list/test_list.py index 5f7959efc825..a327ffb44b79 100644 --- a/py-polars/tests/unit/operations/namespaces/list/test_list.py +++ b/py-polars/tests/unit/operations/namespaces/list/test_list.py @@ -1362,3 +1362,14 @@ def test_list_sample_fraction_boundary_values_22024() -> None: s.list.sample(fraction=0.0) s.list.sample(fraction=1.0) s.list.sample(fraction=pl.Series([0.0, 1.0])) + + +def test_list_concat_on_non_list_raises_25649() -> None: + """Test that list.concat on a non-list column raises during schema collection.""" + lf = pl.LazyFrame({"a": ["a", "b", "c"], "b": [1, 2, 3]}) + + with pytest.raises( + InvalidOperationError, + match="expected List data type for list operation", + ): + lf.select(pl.col("a").list.concat(["a", "b"])).collect_schema()