From 57c5cbfcd07f4a88600c289283c18af184d0f0ff Mon Sep 17 00:00:00 2001
From: bubulalabu <bubulalabububu@gmail.com>
Date: Fri, 7 Nov 2025 18:24:19 +0100
Subject: [PATCH 01/18] support batched table functions

---
 Cargo.lock                                    |   4 +
 .../catalog/src/batched_function/exec.rs      | 685 ++++++++++++++++++
 .../catalog/src/batched_function/helpers.rs   | 245 +++++++
 .../catalog/src/batched_function/mod.rs       |  23 +
 .../catalog/src/default_table_source.rs       |  60 +-
 datafusion/catalog/src/lib.rs                 |   2 +
 datafusion/catalog/src/table.rs               | 288 +++++++-
 .../core/src/execution/session_state.rs       |  79 +-
 .../src/execution/session_state_defaults.rs   |   9 +
 datafusion/core/src/physical_planner.rs       |  93 ++-
 .../core/tests/batched_table_function.rs      | 517 +++++++++++++
 datafusion/core/tests/sql/lateral_simple.rs   | 167 +++++
 .../core/tests/sql/lateral_table_functions.rs | 219 ++++++
 datafusion/core/tests/sql/mod.rs              |   2 +
 datafusion/expr/src/lib.rs                    |   4 +-
 datafusion/expr/src/logical_plan/display.rs   |  30 +-
 datafusion/expr/src/logical_plan/mod.rs       |  12 +-
 datafusion/expr/src/logical_plan/plan.rs      | 271 ++++++-
 datafusion/expr/src/logical_plan/tree_node.rs |  82 ++-
 datafusion/expr/src/planner.rs                |  25 +-
 datafusion/expr/src/table_source.rs           |  35 +
 datafusion/functions-table/Cargo.toml         |   4 +
 .../src/generate_series_batched.rs            | 415 +++++++++++
 datafusion/functions-table/src/lib.rs         |  34 +-
 .../optimizer/src/common_subexpr_eliminate.rs |   4 +-
 .../optimizer/src/optimize_projections/mod.rs | 111 ++-
 datafusion/optimizer/src/push_down_filter.rs  | 155 ++++
 datafusion/optimizer/src/push_down_limit.rs   |  34 +
 .../physical-plan/src/placeholder_row.rs      |  12 +-
 datafusion/physical-plan/src/sorts/merge.rs   |   2 +-
 .../src/sorts/streaming_merge.rs              |   2 +-
 datafusion/proto/src/logical_plan/mod.rs      |   6 +
 datafusion/sql/src/relation/join.rs           | 145 +++-
 datafusion/sql/src/relation/mod.rs            | 253 +++++--
 datafusion/sql/src/unparser/plan.rs           |   6 +-
 datafusion/sqllogictest/src/test_context.rs   | 162 ++++-
 .../sqllogictest/test_files/lateral.slt       | 661 +++++++++++++++++
 .../src/logical_plan/producer/rel/mod.rs      |   6 +
 38 files changed, 4762 insertions(+), 102 deletions(-)
 create mode 100644 datafusion/catalog/src/batched_function/exec.rs
 create mode 100644 datafusion/catalog/src/batched_function/helpers.rs
 create mode 100644 datafusion/catalog/src/batched_function/mod.rs
 create mode 100644 datafusion/core/tests/batched_table_function.rs
 create mode 100644 datafusion/core/tests/sql/lateral_simple.rs
 create mode 100644 datafusion/core/tests/sql/lateral_table_functions.rs
 create mode 100644 datafusion/functions-table/src/generate_series_batched.rs
 create mode 100644 datafusion/sqllogictest/test_files/lateral.slt

diff --git a/Cargo.lock b/Cargo.lock
index 77df82639272c..e99fa1818818e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2413,10 +2413,14 @@ dependencies = [
  "async-trait",
  "datafusion-catalog",
  "datafusion-common",
+ "datafusion-execution",
  "datafusion-expr",
+ "datafusion-physical-expr",
  "datafusion-physical-plan",
+ "futures",
  "parking_lot",
  "paste",
+ "tokio",
 ]
 
 [[package]]
diff --git a/datafusion/catalog/src/batched_function/exec.rs b/datafusion/catalog/src/batched_function/exec.rs
new file mode 100644
index 0000000000000..18b78ad57f06c
--- /dev/null
+++ b/datafusion/catalog/src/batched_function/exec.rs
@@ -0,0 +1,685 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! ExecutionPlan for BatchedTableFunction
+//!
+//! This provides the generic wrapper that evaluates arguments and calls invoke_batch()
+
+use std::any::Any;
+use std::fmt;
+use std::future::Future;
+use std::pin::Pin;
+use std::sync::Arc;
+use std::task::{Context, Poll};
+
+use arrow::datatypes::SchemaRef;
+use arrow::record_batch::RecordBatch;
+use datafusion_common::{exec_err, Result};
+use datafusion_execution::SendableRecordBatchStream;
+use datafusion_expr::Expr;
+use datafusion_physical_expr::{EquivalenceProperties, PhysicalExpr};
+use datafusion_physical_plan::{
+    DisplayAs, DisplayFormatType, ExecutionPlan, ExecutionPlanProperties, PlanProperties,
+    RecordBatchStream,
+};
+use futures::stream::{Stream, StreamExt};
+
+use crate::batched_function::helpers::combine_lateral_result;
+use crate::{BatchResultChunk, BatchResultStream, BatchedTableFunctionImpl};
+
+/// Execution mode for batched table functions
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum BatchedTableFunctionMode {
+    /// Standalone mode: function executed once, output is only function columns
+    Standalone,
+    /// Lateral mode: function executed per input row, output is input + function columns
+    Lateral,
+}
+
+/// ExecutionPlan that wraps a BatchedTableFunctionImpl
+///
+/// This evaluates the argument expressions once per input batch,
+/// calls invoke_batch() to get a stream of output chunks, and produces output batches.
+///
+/// In Lateral mode, this combines each input row with the function's output rows
+/// using the input_row_indices mapping from BatchResultChunk.
+#[derive(Debug)]
+pub struct BatchedTableFunctionExec {
+    func: Arc<dyn BatchedTableFunctionImpl>,
+    args: Vec<Arc<dyn PhysicalExpr>>,
+    input: Arc<dyn ExecutionPlan>,
+    /// The final output schema of this exec
+    projected_schema: SchemaRef,
+    /// Table function output schema (function columns only, no input columns)
+    table_function_schema: SchemaRef,
+    mode: BatchedTableFunctionMode,
+    /// Optional column projection (indices into function output schema)
+    projection: Option<Vec<usize>>,
+    /// Logical filter expressions to pass to invoke_batch
+    filters: Vec<Expr>,
+    /// Optional limit pushed down from optimizer
+    limit: Option<usize>,
+    properties: PlanProperties,
+}
+
+impl BatchedTableFunctionExec {
+    pub fn new(
+        func: Arc<dyn BatchedTableFunctionImpl>,
+        args: Vec<Arc<dyn PhysicalExpr>>,
+        input: Arc<dyn ExecutionPlan>,
+        projected_schema: SchemaRef,
+        table_function_schema: SchemaRef,
+        mode: BatchedTableFunctionMode,
+        projection: Option<Vec<usize>>,
+        filters: Vec<Expr>,
+        limit: Option<usize>,
+    ) -> Result<Self> {
+        let eq_properties = EquivalenceProperties::new(Arc::clone(&projected_schema));
+
+        let properties = PlanProperties::new(
+            eq_properties,
+            input.output_partitioning().clone(),
+            input.pipeline_behavior(),
+            input.boundedness(),
+        );
+
+        Ok(Self {
+            func,
+            args,
+            input,
+            projected_schema,
+            table_function_schema,
+            mode,
+            projection,
+            filters,
+            limit,
+            properties,
+        })
+    }
+}
+
+impl DisplayAs for BatchedTableFunctionExec {
+    fn fmt_as(&self, t: DisplayFormatType, f: &mut fmt::Formatter) -> fmt::Result {
+        match t {
+            DisplayFormatType::Default => {
+                write!(f, "BatchedTableFunctionExec")?;
+                write!(f, ": function={}", self.func.name())?;
+                if !self.args.is_empty() {
+                    write!(f, ", args=[")?;
+                    for (i, arg) in self.args.iter().enumerate() {
+                        if i > 0 {
+                            write!(f, ", ")?;
+                        }
+                        write!(f, "{arg}")?;
+                    }
+                    write!(f, "]")?;
+                }
+                if let Some(proj) = &self.projection {
+                    write!(f, ", projection={proj:?}")?;
+                }
+                if !self.filters.is_empty() {
+                    write!(f, ", filters=[")?;
+                    for (i, filter) in self.filters.iter().enumerate() {
+                        if i > 0 {
+                            write!(f, ", ")?;
+                        }
+                        write!(f, "{filter}")?;
+                    }
+                    write!(f, "]")?;
+                }
+                Ok(())
+            }
+            DisplayFormatType::Verbose => {
+                write!(f, "BatchedTableFunctionExec: function={}", self.func.name())?;
+                if !self.args.is_empty() {
+                    write!(f, ", args=[")?;
+                    for (i, arg) in self.args.iter().enumerate() {
+                        if i > 0 {
+                            write!(f, ", ")?;
+                        }
+                        write!(f, "{arg}")?;
+                    }
+                    write!(f, "]")?;
+                }
+                if let Some(proj) = &self.projection {
+                    write!(f, ", projection={proj:?}")?;
+                }
+                if !self.filters.is_empty() {
+                    write!(f, ", filters=[")?;
+                    for (i, filter) in self.filters.iter().enumerate() {
+                        if i > 0 {
+                            write!(f, ", ")?;
+                        }
+                        write!(f, "{filter}")?;
+                    }
+                    write!(f, "]")?;
+                }
+                Ok(())
+            }
+            DisplayFormatType::TreeRender => {
+                // TreeRender is used for the box diagram display
+                // Only write details, not the node name (that's automatic)
+                write!(f, "function: {}", self.func.name())?;
+                if !self.args.is_empty() {
+                    writeln!(f)?;
+                    write!(f, "args: [")?;
+                    for (i, arg) in self.args.iter().enumerate() {
+                        if i > 0 {
+                            write!(f, ", ")?;
+                        }
+                        write!(f, "{arg}")?;
+                    }
+                    write!(f, "]")?;
+                }
+                if let Some(proj) = &self.projection {
+                    writeln!(f)?;
+                    write!(f, "projection: {proj:?}")?;
+                }
+                if !self.filters.is_empty() {
+                    writeln!(f)?;
+                    write!(f, "filters: [")?;
+                    for (i, filter) in self.filters.iter().enumerate() {
+                        if i > 0 {
+                            write!(f, ", ")?;
+                        }
+                        write!(f, "{filter}")?;
+                    }
+                    write!(f, "]")?;
+                }
+                Ok(())
+            }
+        }
+    }
+}
+
+impl ExecutionPlan for BatchedTableFunctionExec {
+    fn name(&self) -> &str {
+        "BatchedTableFunctionExec"
+    }
+
+    fn as_any(&self) -> &dyn Any {
+        self
+    }
+
+    fn properties(&self) -> &PlanProperties {
+        &self.properties
+    }
+
+    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
+        vec![&self.input]
+    }
+
+    fn with_new_children(
+        self: Arc<Self>,
+        children: Vec<Arc<dyn ExecutionPlan>>,
+    ) -> Result<Arc<dyn ExecutionPlan>> {
+        if children.len() != 1 {
+            return exec_err!("BatchedTableFunctionExec expects exactly 1 child");
+        }
+
+        Ok(Arc::new(Self::new(
+            Arc::clone(&self.func),
+            self.args.clone(),
+            Arc::clone(&children[0]),
+            Arc::clone(&self.projected_schema),
+            Arc::clone(&self.table_function_schema),
+            self.mode,
+            self.projection.clone(),
+            self.filters.clone(),
+            self.limit,
+        )?))
+    }
+
+    fn execute(
+        &self,
+        partition: usize,
+        context: Arc<datafusion_execution::TaskContext>,
+    ) -> Result<SendableRecordBatchStream> {
+        let input_stream = self.input.execute(partition, context)?;
+
+        Ok(Box::pin(BatchedTableFunctionStream {
+            func: Arc::clone(&self.func),
+            args: self.args.clone(),
+            input_stream,
+            schema: Arc::clone(&self.projected_schema),
+            mode: self.mode,
+            projection: self.projection.clone(),
+            filters: self.filters.clone(),
+            limit: self.limit,
+            state: StreamState::AwaitingInput,
+        }))
+    }
+}
+
+enum StreamState {
+    /// Waiting for the next input batch from input_stream
+    AwaitingInput,
+    /// Polling the invoke_batch future
+    Invoking {
+        future: Pin<Box<dyn Future<Output = Result<BatchResultStream>> + Send>>,
+        input_batch: RecordBatch,
+    },
+    /// Polling the result stream from invoke_batch
+    Streaming {
+        stream: BatchResultStream,
+        input_batch: RecordBatch,
+    },
+}
+
+struct BatchedTableFunctionStream {
+    func: Arc<dyn BatchedTableFunctionImpl>,
+    args: Vec<Arc<dyn PhysicalExpr>>,
+    input_stream: SendableRecordBatchStream,
+    schema: SchemaRef,
+    mode: BatchedTableFunctionMode,
+    projection: Option<Vec<usize>>,
+    filters: Vec<Expr>,
+    limit: Option<usize>,
+    state: StreamState,
+}
+
+impl BatchedTableFunctionStream {
+    /// Process a chunk from the inner stream
+    fn process_chunk(
+        &self,
+        chunk: BatchResultChunk,
+        input_batch: &RecordBatch,
+    ) -> Result<RecordBatch> {
+        match self.mode {
+            BatchedTableFunctionMode::Lateral => combine_lateral_result(
+                input_batch,
+                chunk.output,
+                &chunk.input_row_indices,
+            ),
+            BatchedTableFunctionMode::Standalone => Ok(chunk.output),
+        }
+    }
+}
+
+impl Stream for BatchedTableFunctionStream {
+    type Item = Result<RecordBatch>;
+
+    fn poll_next(
+        mut self: Pin<&mut Self>,
+        cx: &mut Context<'_>,
+    ) -> Poll<Option<Self::Item>> {
+        loop {
+            match std::mem::replace(&mut self.state, StreamState::AwaitingInput) {
+                StreamState::AwaitingInput => {
+                    // Get next input batch from input_stream
+                    match self.input_stream.poll_next_unpin(cx) {
+                        Poll::Ready(Some(Ok(batch))) => {
+                            // Evaluate arguments
+                            let arg_arrays: Vec<arrow::array::ArrayRef> = match self
+                                .args
+                                .iter()
+                                .map(|expr| {
+                                    expr.evaluate(&batch)?.into_array(batch.num_rows())
+                                })
+                                .collect::<Result<Vec<_>>>()
+                            {
+                                Ok(arrays) => arrays,
+                                Err(e) => return Poll::Ready(Some(Err(e))),
+                            };
+
+                            // Create future for invoke_batch
+                            let func = Arc::clone(&self.func);
+                            let projection = self.projection.clone();
+                            let filters = self.filters.clone();
+                            let limit = self.limit;
+
+                            let future = Box::pin(async move {
+                                func.invoke_batch(
+                                    &arg_arrays,
+                                    projection.as_deref(),
+                                    &filters,
+                                    limit,
+                                )
+                                .await
+                            });
+
+                            self.state = StreamState::Invoking {
+                                future,
+                                input_batch: batch,
+                            };
+                            continue;
+                        }
+                        Poll::Ready(Some(Err(e))) => return Poll::Ready(Some(Err(e))),
+                        Poll::Ready(None) => return Poll::Ready(None),
+                        Poll::Pending => {
+                            self.state = StreamState::AwaitingInput;
+                            return Poll::Pending;
+                        }
+                    }
+                }
+                StreamState::Invoking {
+                    mut future,
+                    input_batch,
+                } => {
+                    // Poll the invoke_batch future
+                    match future.as_mut().poll(cx) {
+                        Poll::Ready(Ok(stream)) => {
+                            self.state = StreamState::Streaming {
+                                stream,
+                                input_batch,
+                            };
+                            continue;
+                        }
+                        Poll::Ready(Err(e)) => return Poll::Ready(Some(Err(e))),
+                        Poll::Pending => {
+                            self.state = StreamState::Invoking {
+                                future,
+                                input_batch,
+                            };
+                            return Poll::Pending;
+                        }
+                    }
+                }
+                StreamState::Streaming {
+                    mut stream,
+                    input_batch,
+                } => {
+                    // Poll the result stream
+                    match stream.poll_next_unpin(cx) {
+                        Poll::Ready(Some(Ok(chunk))) => {
+                            match self.process_chunk(chunk, &input_batch) {
+                                Ok(batch) => {
+                                    self.state = StreamState::Streaming {
+                                        stream,
+                                        input_batch,
+                                    };
+                                    return Poll::Ready(Some(Ok(batch)));
+                                }
+                                Err(e) => return Poll::Ready(Some(Err(e))),
+                            }
+                        }
+                        Poll::Ready(Some(Err(e))) => return Poll::Ready(Some(Err(e))),
+                        Poll::Ready(None) => {
+                            // Stream finished, go back to awaiting next input
+                            self.state = StreamState::AwaitingInput;
+                            continue;
+                        }
+                        Poll::Pending => {
+                            self.state = StreamState::Streaming {
+                                stream,
+                                input_batch,
+                            };
+                            return Poll::Pending;
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+impl RecordBatchStream for BatchedTableFunctionStream {
+    fn schema(&self) -> SchemaRef {
+        Arc::clone(&self.schema)
+    }
+}
+
+impl fmt::Debug for BatchedTableFunctionStream {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("BatchedTableFunctionStream")
+            .field("func", &self.func.name())
+            .field("schema", &self.schema)
+            .finish()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::batched_function::helpers::materialized_batch_stream;
+    use crate::BatchResultStream;
+    use arrow::array::{Array, AsArray, Int64Array};
+    use arrow::datatypes::{DataType, Field, Schema};
+    use datafusion_common::Result;
+    use datafusion_expr::{Expr, Signature, Volatility};
+    use futures::StreamExt;
+
+    /// Simple test function: doubles the input value
+    #[derive(Debug)]
+    struct DoubleFunction {
+        signature: Signature,
+    }
+
+    impl DoubleFunction {
+        fn new() -> Self {
+            Self {
+                signature: Signature::exact(vec![DataType::Int64], Volatility::Immutable),
+            }
+        }
+    }
+
+    #[async_trait::async_trait]
+    impl BatchedTableFunctionImpl for DoubleFunction {
+        fn name(&self) -> &str {
+            "double"
+        }
+
+        fn signature(&self) -> &Signature {
+            &self.signature
+        }
+
+        fn return_type(&self, _arg_types: &[DataType]) -> Result<Schema> {
+            Ok(Schema::new(vec![Field::new(
+                "doubled",
+                DataType::Int64,
+                true, // nullable
+            )]))
+        }
+
+        async fn invoke_batch(
+            &self,
+            args: &[arrow::array::ArrayRef],
+            _projection: Option<&[usize]>,
+            _filters: &[Expr],
+            _limit: Option<usize>,
+        ) -> Result<BatchResultStream> {
+            if args.len() != 1 {
+                return exec_err!("double expects 1 argument");
+            }
+
+            let input_array = args[0].as_primitive::<arrow::datatypes::Int64Type>();
+
+            let output_array: Int64Array =
+                input_array.iter().map(|v| v.map(|val| val * 2)).collect();
+
+            let schema = self.return_type(&[DataType::Int64])?;
+            let output_batch =
+                RecordBatch::try_new(Arc::new(schema), vec![Arc::new(output_array)])?;
+
+            let input_row_indices: Vec<u32> = (0..input_array.len() as u32).collect();
+
+            Ok(materialized_batch_stream(output_batch, input_row_indices))
+        }
+    }
+
+    #[tokio::test]
+    async fn test_double_function() -> Result<()> {
+        let func = DoubleFunction::new();
+
+        let input_array = Int64Array::from(vec![5]);
+        let args: Vec<arrow::array::ArrayRef> = vec![Arc::new(input_array)];
+
+        let mut stream = func.invoke_batch(&args, None, &[], None).await?;
+        let chunk = stream.next().await.unwrap()?;
+
+        assert_eq!(chunk.output.num_rows(), 1);
+        assert_eq!(chunk.input_row_indices, vec![0]);
+
+        let output_array = chunk
+            .output
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(output_array.value(0), 10);
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_double_function_null() -> Result<()> {
+        let func = DoubleFunction::new();
+
+        let input_array = Int64Array::from(vec![None]);
+        let args: Vec<arrow::array::ArrayRef> = vec![Arc::new(input_array)];
+
+        let mut stream = func.invoke_batch(&args, None, &[], None).await?;
+        let chunk = stream.next().await.unwrap()?;
+
+        assert_eq!(chunk.output.num_rows(), 1);
+        let output_array = chunk
+            .output
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert!(output_array.is_null(0));
+
+        Ok(())
+    }
+
+    /// Function that generates N copies of the input
+    #[derive(Debug)]
+    struct ReplicateFunction {
+        signature: Signature,
+    }
+
+    impl ReplicateFunction {
+        fn new() -> Self {
+            Self {
+                signature: Signature::exact(
+                    vec![DataType::Int64, DataType::Int64],
+                    Volatility::Immutable,
+                ),
+            }
+        }
+    }
+
+    #[async_trait::async_trait]
+    impl BatchedTableFunctionImpl for ReplicateFunction {
+        fn name(&self) -> &str {
+            "replicate"
+        }
+
+        fn signature(&self) -> &Signature {
+            &self.signature
+        }
+
+        fn return_type(&self, _arg_types: &[DataType]) -> Result<Schema> {
+            Ok(Schema::new(vec![Field::new(
+                "value",
+                DataType::Int64,
+                false,
+            )]))
+        }
+
+        async fn invoke_batch(
+            &self,
+            args: &[arrow::array::ArrayRef],
+            _projection: Option<&[usize]>,
+            _filters: &[Expr],
+            _limit: Option<usize>,
+        ) -> Result<BatchResultStream> {
+            if args.len() != 2 {
+                return exec_err!("replicate expects 2 arguments");
+            }
+
+            let value_array = args[0].as_primitive::<arrow::datatypes::Int64Type>();
+            let count_array = args[1].as_primitive::<arrow::datatypes::Int64Type>();
+
+            if value_array.len() != count_array.len() {
+                return exec_err!("value and count arrays must have same length");
+            }
+
+            let mut output_values = Vec::new();
+            let mut input_row_indices = Vec::new();
+
+            for row_idx in 0..value_array.len() {
+                if value_array.is_null(row_idx) || count_array.is_null(row_idx) {
+                    continue;
+                }
+
+                let value = value_array.value(row_idx);
+                let count = count_array.value(row_idx) as usize;
+
+                for _ in 0..count {
+                    output_values.push(value);
+                    input_row_indices.push(row_idx as u32);
+                }
+            }
+
+            let schema = self.return_type(&[DataType::Int64, DataType::Int64])?;
+            let output_array = Int64Array::from(output_values);
+            let output_batch =
+                RecordBatch::try_new(Arc::new(schema), vec![Arc::new(output_array)])?;
+
+            Ok(materialized_batch_stream(output_batch, input_row_indices))
+        }
+    }
+
+    #[tokio::test]
+    async fn test_replicate_function() -> Result<()> {
+        let func = ReplicateFunction::new();
+
+        let value_array = Int64Array::from(vec![5]);
+        let count_array = Int64Array::from(vec![3]);
+        let args: Vec<arrow::array::ArrayRef> =
+            vec![Arc::new(value_array), Arc::new(count_array)];
+
+        let mut stream = func.invoke_batch(&args, None, &[], None).await?;
+        let chunk = stream.next().await.unwrap()?;
+
+        assert_eq!(chunk.output.num_rows(), 3);
+        assert_eq!(chunk.input_row_indices, vec![0, 0, 0]); // All from input row 0
+
+        let output_array = chunk
+            .output
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+
+        for i in 0..3 {
+            assert_eq!(output_array.value(i), 5);
+        }
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_replicate_function_zero_count() -> Result<()> {
+        let func = ReplicateFunction::new();
+
+        let value_array = Int64Array::from(vec![5]);
+        let count_array = Int64Array::from(vec![0]);
+        let args: Vec<arrow::array::ArrayRef> =
+            vec![Arc::new(value_array), Arc::new(count_array)];
+
+        let mut stream = func.invoke_batch(&args, None, &[], None).await?;
+        let chunk = stream.next().await.unwrap()?;
+
+        assert_eq!(chunk.output.num_rows(), 0);
+        assert_eq!(chunk.input_row_indices.len(), 0);
+
+        Ok(())
+    }
+}
diff --git a/datafusion/catalog/src/batched_function/helpers.rs b/datafusion/catalog/src/batched_function/helpers.rs
new file mode 100644
index 0000000000000..1d04229df12d3
--- /dev/null
+++ b/datafusion/catalog/src/batched_function/helpers.rs
@@ -0,0 +1,245 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Helper functions for table function implementations
+
+use arrow::array::RecordBatch;
+use datafusion_common::{exec_err, Result};
+use std::sync::Arc;
+
+/// Create a stream that yields a single batch result chunk
+///
+/// This is a convenience helper for simple table functions that materialize
+/// all results upfront and want to return them as a stream.
+///
+/// # Arguments
+/// * `output` - The generated rows as a RecordBatch
+/// * `input_row_indices` - Maps each output row to its input row
+///
+/// # Example
+/// ```
+/// use datafusion_catalog::batched_function::helpers::materialized_batch_stream;
+/// use datafusion_catalog::BatchResultChunk;
+/// use arrow::array::Int64Array;
+/// use arrow::array::RecordBatch;
+/// use arrow::datatypes::{Schema, Field, DataType};
+/// use std::sync::Arc;
+///
+/// # async fn example() {
+/// let output = RecordBatch::try_new(
+///     Arc::new(Schema::new(vec![Field::new("n", DataType::Int64, false)])),
+///     vec![Arc::new(Int64Array::from(vec![1, 2, 3]))],
+/// ).unwrap();
+/// let indices = vec![0, 0, 0]; // All from input row 0
+///
+/// let stream = materialized_batch_stream(output, indices);
+/// // Stream yields one chunk and then completes
+/// # }
+/// ```
+pub fn materialized_batch_stream(
+    output: RecordBatch,
+    input_row_indices: Vec<u32>,
+) -> crate::BatchResultStream {
+    use crate::BatchResultChunk;
+    use futures::stream;
+    Box::pin(stream::once(async move {
+        Ok(BatchResultChunk {
+            output,
+            input_row_indices,
+        })
+    }))
+}
+
+/// Combine input batch with output batch for LATERAL join
+///
+/// For each output row, duplicates the corresponding input row (based on
+/// input_row_indices) and concatenates it with the output row.
+///
+/// # Arguments
+/// * `input_batch` - The input rows (from the outer table)
+/// * `output_batch` - The generated rows (from the table function)
+/// * `input_row_indices` - Maps each output row to its source input row
+///
+/// # Returns
+/// A RecordBatch with schema [input_columns..., output_columns...]
+///
+/// # Example
+/// ```text
+/// Input (2 rows):
+///   id | value
+///   1  | 10
+///   2  | 20
+///
+/// Output (4 rows):
+///   n
+///   1
+///   2
+///   3
+///   4
+///
+/// input_row_indices = [0, 0, 1, 1]
+///
+/// Result (4 rows):
+///   id | value | n
+///   1  | 10    | 1
+///   1  | 10    | 2
+///   2  | 20    | 3
+///   2  | 20    | 4
+/// ```
+pub fn combine_lateral_result(
+    input_batch: &RecordBatch,
+    output_batch: RecordBatch,
+    input_row_indices: &[u32],
+) -> Result<RecordBatch> {
+    if input_row_indices.len() != output_batch.num_rows() {
+        return exec_err!(
+            "input_row_indices length ({}) != output batch rows ({})",
+            input_row_indices.len(),
+            output_batch.num_rows()
+        );
+    }
+
+    let take_indices = arrow::array::UInt32Array::from(input_row_indices.to_vec());
+
+    let mut combined_columns =
+        Vec::with_capacity(input_batch.num_columns() + output_batch.num_columns());
+
+    for col in input_batch.columns() {
+        let taken = arrow::compute::take(col.as_ref(), &take_indices, None)?;
+        combined_columns.push(taken);
+    }
+
+    for col in output_batch.columns() {
+        combined_columns.push(Arc::clone(col));
+    }
+
+    let mut combined_fields = input_batch.schema().fields().to_vec();
+    combined_fields.extend_from_slice(output_batch.schema().fields());
+    let combined_schema = Arc::new(arrow::datatypes::Schema::new(combined_fields));
+
+    Ok(RecordBatch::try_new(combined_schema, combined_columns)?)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use arrow::array::Int64Array;
+    use arrow::datatypes::{DataType, Field, Schema};
+
+    #[test]
+    fn test_combine_lateral_result() {
+        // Input batch: 2 rows with columns (id, value)
+        let input_schema = Arc::new(Schema::new(vec![
+            Field::new("id", DataType::Int64, false),
+            Field::new("value", DataType::Int64, false),
+        ]));
+        let input_batch = RecordBatch::try_new(
+            input_schema,
+            vec![
+                Arc::new(Int64Array::from(vec![1, 2])),
+                Arc::new(Int64Array::from(vec![10, 20])),
+            ],
+        )
+        .unwrap();
+
+        // Output batch: 4 rows with column (n)
+        let output_schema =
+            Arc::new(Schema::new(vec![Field::new("n", DataType::Int64, false)]));
+        let output_batch = RecordBatch::try_new(
+            output_schema,
+            vec![Arc::new(Int64Array::from(vec![100, 101, 200, 201]))],
+        )
+        .unwrap();
+
+        let input_row_indices = vec![0, 0, 1, 1];
+
+        let result =
+            combine_lateral_result(&input_batch, output_batch, &input_row_indices)
+                .unwrap();
+
+        assert_eq!(result.num_columns(), 3);
+        assert_eq!(result.num_rows(), 4);
+
+        assert_eq!(result.schema().field(0).name(), "id");
+        assert_eq!(result.schema().field(1).name(), "value");
+        assert_eq!(result.schema().field(2).name(), "n");
+
+        let id_col = result
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        let value_col = result
+            .column(1)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        let n_col = result
+            .column(2)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+
+        // Row 0: id=1, value=10, n=100
+        assert_eq!(id_col.value(0), 1);
+        assert_eq!(value_col.value(0), 10);
+        assert_eq!(n_col.value(0), 100);
+
+        // Row 1: id=1, value=10, n=101
+        assert_eq!(id_col.value(1), 1);
+        assert_eq!(value_col.value(1), 10);
+        assert_eq!(n_col.value(1), 101);
+
+        // Row 2: id=2, value=20, n=200
+        assert_eq!(id_col.value(2), 2);
+        assert_eq!(value_col.value(2), 20);
+        assert_eq!(n_col.value(2), 200);
+
+        // Row 3: id=2, value=20, n=201
+        assert_eq!(id_col.value(3), 2);
+        assert_eq!(value_col.value(3), 20);
+        assert_eq!(n_col.value(3), 201);
+    }
+
+    #[test]
+    fn test_combine_lateral_result_mismatched_lengths() {
+        let input_schema =
+            Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
+        let input_batch =
+            RecordBatch::try_new(input_schema, vec![Arc::new(Int64Array::from(vec![1]))])
+                .unwrap();
+
+        let output_schema =
+            Arc::new(Schema::new(vec![Field::new("n", DataType::Int64, false)]));
+        let output_batch = RecordBatch::try_new(
+            output_schema,
+            vec![Arc::new(Int64Array::from(vec![100, 101]))],
+        )
+        .unwrap();
+
+        // Wrong: indices has length 3 but output has 2 rows
+        let input_row_indices = vec![0, 0, 0];
+
+        let result =
+            combine_lateral_result(&input_batch, output_batch, &input_row_indices);
+        assert!(result.is_err());
+        assert!(result
+            .unwrap_err()
+            .to_string()
+            .contains("input_row_indices length"));
+    }
+}
diff --git a/datafusion/catalog/src/batched_function/mod.rs b/datafusion/catalog/src/batched_function/mod.rs
new file mode 100644
index 0000000000000..89690dd70def8
--- /dev/null
+++ b/datafusion/catalog/src/batched_function/mod.rs
@@ -0,0 +1,23 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Utilities and helpers for implementing batched table functions
+
+pub mod exec;
+pub mod helpers;
+
+pub use exec::{BatchedTableFunctionExec, BatchedTableFunctionMode};
diff --git a/datafusion/catalog/src/default_table_source.rs b/datafusion/catalog/src/default_table_source.rs
index 11963c06c88f5..e9177184ecd11 100644
--- a/datafusion/catalog/src/default_table_source.rs
+++ b/datafusion/catalog/src/default_table_source.rs
@@ -20,11 +20,13 @@
 use std::sync::Arc;
 use std::{any::Any, borrow::Cow};
 
-use crate::TableProvider;
+use crate::{BatchedTableFunctionImpl, TableProvider};
 
 use arrow::datatypes::SchemaRef;
 use datafusion_common::{internal_err, Constraints};
-use datafusion_expr::{Expr, TableProviderFilterPushDown, TableSource, TableType};
+use datafusion_expr::{
+    BatchedTableFunctionSource, Expr, TableProviderFilterPushDown, TableSource, TableType,
+};
 
 /// Implements [`TableSource`] for a [`TableProvider`]
 ///
@@ -144,3 +146,57 @@ fn preserves_table_type() {
     let table_source = DefaultTableSource::new(Arc::new(TestTempTable));
     assert_eq!(table_source.table_type(), TableType::Temporary);
 }
+
+/// Implements [`BatchedTableFunctionSource`] for a [`BatchedTableFunctionImpl`]
+///
+/// This structure adapts a [`BatchedTableFunctionImpl`] (a physical plan trait) to the
+/// [`BatchedTableFunctionSource`] (logical plan trait).
+///
+/// It is used so logical plans in the `datafusion_expr` crate do not have a
+/// direct dependency on physical plans, such as [`BatchedTableFunctionImpl`]s.
+///
+/// This source is created per call site, storing the specific argument types
+/// for that invocation. This allows the `schema()` method to correctly return
+/// the output schema based on the actual argument types.
+pub struct DefaultBatchedTableFunctionSource {
+    /// batched table function implementation
+    pub function_impl: Arc<dyn BatchedTableFunctionImpl>,
+    /// The argument types for this specific call site
+    pub arg_types: Vec<arrow::datatypes::DataType>,
+}
+
+impl DefaultBatchedTableFunctionSource {
+    pub fn new(
+        function_impl: Arc<dyn BatchedTableFunctionImpl>,
+        arg_types: Vec<arrow::datatypes::DataType>,
+    ) -> Self {
+        Self {
+            function_impl,
+            arg_types,
+        }
+    }
+}
+
+impl BatchedTableFunctionSource for DefaultBatchedTableFunctionSource {
+    fn as_any(&self) -> &dyn Any {
+        self
+    }
+
+    fn name(&self) -> &str {
+        self.function_impl.name()
+    }
+
+    fn schema(&self) -> SchemaRef {
+        match self.function_impl.return_type(&self.arg_types) {
+            Ok(schema) => Arc::new(schema),
+            Err(_) => Arc::new(arrow::datatypes::Schema::empty()),
+        }
+    }
+
+    fn supports_filters_pushdown(
+        &self,
+        filters: &[&Expr],
+    ) -> datafusion_common::Result<Vec<TableProviderFilterPushDown>> {
+        self.function_impl.supports_filters_pushdown(filters)
+    }
+}
diff --git a/datafusion/catalog/src/lib.rs b/datafusion/catalog/src/lib.rs
index 3e866f84a5ec7..88ce74fb5af51 100644
--- a/datafusion/catalog/src/lib.rs
+++ b/datafusion/catalog/src/lib.rs
@@ -33,6 +33,7 @@
 //! * Simple memory based catalog: [`MemoryCatalogProviderList`], [`MemoryCatalogProvider`], [`MemorySchemaProvider`]
 //! * Listing schema: [`listing_schema`]
 
+pub mod batched_function;
 pub mod cte_worktable;
 pub mod default_table_source;
 pub mod information_schema;
@@ -48,6 +49,7 @@ mod dynamic_file;
 mod schema;
 mod table;
 
+pub use batched_function::BatchedTableFunctionMode;
 pub use catalog::*;
 pub use datafusion_session::Session;
 pub use dynamic_file::catalog::*;
diff --git a/datafusion/catalog/src/table.rs b/datafusion/catalog/src/table.rs
index 11c9af01a7a54..2952830b5c1c9 100644
--- a/datafusion/catalog/src/table.rs
+++ b/datafusion/catalog/src/table.rs
@@ -18,20 +18,23 @@
 use std::any::Any;
 use std::borrow::Cow;
 use std::fmt::Debug;
+use std::pin::Pin;
 use std::sync::Arc;
 
+use crate::batched_function::BatchedTableFunctionExec;
 use crate::session::Session;
-use arrow::datatypes::SchemaRef;
+use arrow::array::{ArrayRef, RecordBatch};
+use arrow::datatypes::{DataType, Schema, SchemaRef};
 use async_trait::async_trait;
-use datafusion_common::Result;
-use datafusion_common::{not_impl_err, Constraints, Statistics};
+use datafusion_common::{not_impl_err, Constraints, Result, Statistics};
 use datafusion_expr::Expr;
+use futures::Stream;
 
 use datafusion_expr::dml::InsertOp;
 use datafusion_expr::{
-    CreateExternalTable, LogicalPlan, TableProviderFilterPushDown, TableType,
+    CreateExternalTable, LogicalPlan, Signature, TableProviderFilterPushDown, TableType,
 };
-use datafusion_physical_plan::ExecutionPlan;
+use datafusion_physical_plan::{ExecutionPlan, PhysicalExpr};
 
 /// A table which can be queried and modified.
 ///
@@ -488,3 +491,278 @@ impl TableFunction {
         self.fun.call(args)
     }
 }
+
+/// Implementation of a table function that can process batched inputs
+///
+/// This is the trait that table function implementors should implement.
+/// It receives already-evaluated arguments (similar to [`ScalarUDFImpl`]).
+///
+/// Unlike the legacy `TableFunctionImpl` trait which only accepts constant arguments,
+/// `BatchedTableFunctionImpl` can process batched inputs, enabling:
+/// - Constant evaluation: `SELECT * FROM generate_series(1, 10)`
+/// - LATERAL joins: `SELECT * FROM t, LATERAL generate_series(1, t.x)`
+/// - Correlated queries with deduplication and optimization
+///
+/// # Example
+///
+/// ```
+/// use datafusion_catalog::BatchedTableFunctionImpl;
+/// use datafusion_common::Result;
+/// use datafusion_expr::Signature;
+/// use arrow::datatypes::{DataType, Schema, Field};
+/// use std::sync::Arc;
+///
+/// #[derive(Debug)]
+/// struct MyFunction;
+///
+/// #[async_trait::async_trait]
+/// impl BatchedTableFunctionImpl for MyFunction {
+///     fn name(&self) -> &str {
+///         "my_func"
+///     }
+///
+///     fn signature(&self) -> &Signature {
+///         // Define function signature
+///         # unimplemented!()
+///     }
+///
+///     fn return_type(&self, arg_types: &[DataType]) -> Result<Schema> {
+///         Ok(Schema::new(vec![Field::new("value", DataType::Int64, false)]))
+///     }
+///
+///     async fn invoke_batch(
+///         &self,
+///         args: &[arrow::array::ArrayRef],
+///         projection: Option<&[usize]>,
+///         filters: &[datafusion_expr::Expr],
+///         limit: Option<usize>,
+///     ) -> Result<datafusion_catalog::BatchResultStream> {
+///         // Process argument arrays and return stream of result chunks
+///         # unimplemented!()
+///     }
+/// }
+/// ```
+///
+/// # Batch Result Types
+///
+/// Batched table functions return results as streams of [`BatchResultChunk`]s,
+/// allowing for:
+/// - Lazy generation (compute on-demand)
+/// - Memory efficiency (bounded by batch size)
+/// - Cancellation support (stop early if consumer drops stream)
+/// - Async operations (await external resources)
+///
+/// One chunk of results from a batched table function.
+///
+/// Each chunk contains output rows and a mapping from each output row
+/// to its source input row (for LATERAL joins).
+#[derive(Debug, Clone)]
+pub struct BatchResultChunk {
+    pub output: RecordBatch,
+
+    /// Maps each output row to its source input row
+    ///
+    /// For output row i, it was generated from input row input_row_indices[i].
+    /// Length must equal output.num_rows().
+    ///
+    /// For standalone invocations with a single input, this is typically
+    /// vec![0; output.num_rows()] (all rows from input row 0).
+    pub input_row_indices: Vec<u32>,
+}
+
+/// Stream of batch result chunks from a table function
+///
+/// The function yields chunks on-demand, allowing for:
+/// - Streaming large results without materializing everything
+/// - Early termination if consumer stops reading
+/// - Async operations within function execution
+pub type BatchResultStream = Pin<Box<dyn Stream<Item = Result<BatchResultChunk>> + Send>>;
+
+#[async_trait]
+pub trait BatchedTableFunctionImpl: Send + Sync + Debug {
+    fn name(&self) -> &str;
+
+    /// Function signature for type checking and coercion
+    ///
+    /// The signature specifies:
+    /// - Expected argument types
+    /// - Whether arguments can be coerced (e.g., INT32 → INT64)
+    /// - Volatility (Immutable/Stable/Volatile) for optimization
+    ///
+    /// The planner uses this to:
+    /// - Validate argument count and types
+    /// - Insert automatic type casts when needed
+    /// - Determine if function results can be cached/optimized
+    fn signature(&self) -> &Signature;
+
+    /// Infer output schema from input argument types
+    ///
+    /// Called during logical planning to determine the table's schema.
+    ///
+    /// # Arguments
+    /// * `arg_types` - Argument types after coercion by signature
+    ///
+    /// By the time this is called, the signature has already validated
+    /// and coerced types, so arg_types are guaranteed to match.
+    fn return_type(&self, arg_types: &[DataType]) -> Result<Schema>;
+
+    /// Specify if the function can apply filter expressions during execution.
+    ///
+    /// Similar to [`TableProvider::supports_filters_pushdown`], this allows
+    /// table functions to evaluate filters more efficiently than a separate
+    /// FilterExec operator.
+    ///
+    /// # Parameters and Return Value
+    ///
+    /// The return `Vec` must have one element for each element of the `filters`
+    /// argument, indicating if the function can apply that filter.
+    ///
+    /// Each element is one of:
+    /// * [`Exact`] or [`Inexact`]: Function can apply the filter
+    /// * [`Unsupported`]: Function cannot apply the filter
+    ///
+    /// Default implementation returns [`Unsupported`] for all filters.
+    ///
+    /// [`Unsupported`]: crate::TableProviderFilterPushDown::Unsupported
+    /// [`Exact`]: crate::TableProviderFilterPushDown::Exact
+    /// [`Inexact`]: crate::TableProviderFilterPushDown::Inexact
+    fn supports_filters_pushdown(
+        &self,
+        filters: &[&Expr],
+    ) -> Result<Vec<TableProviderFilterPushDown>> {
+        Ok(vec![
+            TableProviderFilterPushDown::Unsupported;
+            filters.len()
+        ])
+    }
+
+    /// Invoke the table function with batch of evaluated arguments
+    ///
+    /// This method processes a batch of argument values (as Arrow arrays) and
+    /// returns a stream of result chunks. Each chunk contains output rows and
+    /// maps each output row back to its input row (for LATERAL joins).
+    ///
+    /// # Arguments
+    /// * `args` - Argument values as Arrow arrays, all with the same length N
+    ///   representing N invocations
+    /// * `projection` - Optional column indices to return (None = all columns).
+    ///   Allows functions to skip computing unused columns.
+    /// * `filters` - Filter expressions to apply during execution.
+    ///   Only rows satisfying all filters should be returned.
+    ///   Functions should check [`supports_filters_pushdown`] to determine
+    ///   which filters they can handle.
+    /// * `limit` - Optional row limit. Functions may stop generating rows
+    ///   after producing at least this many rows (though they may produce more).
+    ///   This is an optimization hint; [`GlobalLimitExec`] still enforces correctness.
+    ///
+    /// # Returns
+    /// A stream of [`BatchResultChunk`]s, where each chunk contains:
+    /// - `output`: Generated rows in columnar format (RecordBatch)
+    /// - `input_row_indices`: Maps each output row to its source input row [0..N)
+    ///
+    /// # Examples
+    ///
+    /// ## Direct invocation with constants
+    /// ```text
+    /// // SELECT * FROM generate_series(1, 5)
+    /// args = [
+    ///     Int64Array([1]),  // start = 1
+    ///     Int64Array([5])   // end = 5
+    /// ]
+    /// projection = None (all columns)
+    /// filters = []
+    /// limit = None
+    ///
+    /// Stream yields:
+    /// BatchResultChunk {
+    ///     output: RecordBatch with 5 rows [1, 2, 3, 4, 5],
+    ///     input_row_indices: vec![0, 0, 0, 0, 0]  // all from input row 0
+    /// }
+    /// ```
+    ///
+    /// ## LATERAL with 3 input rows and limit
+    /// ```text
+    /// // SELECT * FROM t LATERAL generate_series(t.start, t.end) LIMIT 5
+    /// // where t has rows: (1,3), (5,7), (10,12)
+    /// args = [
+    ///     Int64Array([1, 5, 10]),  // start values
+    ///     Int64Array([3, 7, 12])   // end values
+    /// ]
+    /// projection = None
+    /// filters = []
+    /// limit = Some(5)
+    ///
+    /// Stream may yield (optimization):
+    /// BatchResultChunk {
+    ///     output: RecordBatch with 5 rows [1,2,3, 5,6],
+    ///     input_row_indices: vec![0,0,0, 1,1]
+    /// }
+    /// // Function stopped early instead of generating all 9 rows
+    /// ```
+    ///
+    /// [`supports_filters_pushdown`]: Self::supports_filters_pushdown
+    /// [`GlobalLimitExec`]: datafusion_physical_plan::limit::GlobalLimitExec
+    async fn invoke_batch(
+        &self,
+        args: &[ArrayRef],
+        projection: Option<&[usize]>,
+        filters: &[Expr],
+        limit: Option<usize>,
+    ) -> Result<BatchResultStream>;
+}
+
+/// A table function that can process batched inputs
+///
+/// This is a wrapper around [`BatchedTableFunctionImpl`] that provides
+/// a `create_plan` method for integration with DataFusion's planning.
+///
+/// Similar to how [`ScalarUDF`] wraps [`ScalarUDFImpl`].
+#[derive(Debug, Clone)]
+pub struct BatchedTableFunction {
+    inner: Arc<dyn BatchedTableFunctionImpl>,
+}
+
+impl BatchedTableFunction {
+    pub fn new(inner: Arc<dyn BatchedTableFunctionImpl>) -> Self {
+        Self { inner }
+    }
+
+    pub fn name(&self) -> &str {
+        self.inner.name()
+    }
+
+    pub fn signature(&self) -> &Signature {
+        self.inner.signature()
+    }
+
+    pub fn return_type(&self, arg_types: &[DataType]) -> Result<Schema> {
+        self.inner.return_type(arg_types)
+    }
+
+    pub fn inner(&self) -> &Arc<dyn BatchedTableFunctionImpl> {
+        &self.inner
+    }
+
+    /// Create execution plan for this table function
+    ///
+    /// Creates a wrapper ExecutionPlan that evaluates argument expressions
+    /// against input batches and calls the underlying `invoke_batch` method.
+    pub fn create_plan(
+        &self,
+        args: Vec<Arc<dyn PhysicalExpr>>,
+        input: Arc<dyn ExecutionPlan>,
+        projected_schema: SchemaRef,
+    ) -> Result<Arc<dyn ExecutionPlan>> {
+        Ok(Arc::new(BatchedTableFunctionExec::new(
+            Arc::clone(&self.inner),
+            args,
+            input,
+            Arc::clone(&projected_schema), // projected_schema
+            projected_schema, // table_function_schema (same in Standalone mode)
+            crate::BatchedTableFunctionMode::Standalone,
+            None,   // no projection
+            vec![], // no filters
+            None,   // no limit
+        )?))
+    }
+}
diff --git a/datafusion/core/src/execution/session_state.rs b/datafusion/core/src/execution/session_state.rs
index 2466f9544bd17..12c77a58e915c 100644
--- a/datafusion/core/src/execution/session_state.rs
+++ b/datafusion/core/src/execution/session_state.rs
@@ -35,7 +35,9 @@ use datafusion_catalog::information_schema::{
     InformationSchemaProvider, INFORMATION_SCHEMA,
 };
 use datafusion_catalog::MemoryCatalogProviderList;
-use datafusion_catalog::{TableFunction, TableFunctionImpl};
+use datafusion_catalog::{
+    BatchedTableFunction, BatchedTableFunctionImpl, TableFunction, TableFunctionImpl,
+};
 use datafusion_common::alias::AliasGenerator;
 #[cfg(feature = "sql")]
 use datafusion_common::config::Dialect;
@@ -154,6 +156,8 @@ pub struct SessionState {
     catalog_list: Arc<dyn CatalogProviderList>,
     /// Table Functions
     table_functions: HashMap<String, Arc<TableFunction>>,
+    /// Batched Table Functions
+    batched_table_functions: HashMap<String, Arc<BatchedTableFunction>>,
     /// Scalar functions that are registered with the context
     scalar_functions: HashMap<String, Arc<ScalarUDF>>,
     /// Aggregate functions registered in the context
@@ -223,6 +227,7 @@ impl Debug for SessionState {
             .field("optimizer", &self.optimizer)
             .field("physical_optimizers", &self.physical_optimizers)
             .field("table_functions", &self.table_functions)
+            .field("batched_table_functions", &self.batched_table_functions)
             .field("scalar_functions", &self.scalar_functions)
             .field("aggregate_functions", &self.aggregate_functions)
             .field("window_functions", &self.window_functions)
@@ -922,6 +927,38 @@ impl SessionState {
         Ok(udtf.map(|x| Arc::clone(x.function())))
     }
 
+    /// Register a batched table function
+    pub fn register_batched_table_function(
+        &mut self,
+        name: &str,
+        func: Arc<dyn BatchedTableFunctionImpl>,
+    ) {
+        self.batched_table_functions
+            .insert(name.to_owned(), Arc::new(BatchedTableFunction::new(func)));
+    }
+
+    /// Deregister a batched table function
+    pub fn deregister_batched_table_function(
+        &mut self,
+        name: &str,
+    ) -> datafusion_common::Result<Option<Arc<dyn BatchedTableFunctionImpl>>> {
+        let func = self.batched_table_functions.remove(name);
+        Ok(func.map(|f| Arc::clone(f.inner())))
+    }
+
+    /// Get a batched table function by name
+    pub fn batched_table_function(
+        &self,
+        name: &str,
+    ) -> Option<Arc<BatchedTableFunction>> {
+        self.batched_table_functions.get(name).cloned()
+    }
+
+    /// Get all batched table functions
+    pub fn batched_table_functions(&self) -> &HashMap<String, Arc<BatchedTableFunction>> {
+        &self.batched_table_functions
+    }
+
     /// Store the logical plan and the parameter types of a prepared statement.
     pub(crate) fn store_prepared(
         &mut self,
@@ -974,6 +1011,7 @@ pub struct SessionStateBuilder {
     query_planner: Option<Arc<dyn QueryPlanner + Send + Sync>>,
     catalog_list: Option<Arc<dyn CatalogProviderList>>,
     table_functions: Option<HashMap<String, Arc<TableFunction>>>,
+    batched_table_functions: Option<HashMap<String, Arc<BatchedTableFunction>>>,
     scalar_functions: Option<Vec<Arc<ScalarUDF>>>,
     aggregate_functions: Option<Vec<Arc<AggregateUDF>>>,
     window_functions: Option<Vec<Arc<WindowUDF>>>,
@@ -1014,6 +1052,7 @@ impl SessionStateBuilder {
             query_planner: None,
             catalog_list: None,
             table_functions: None,
+            batched_table_functions: None,
             scalar_functions: None,
             aggregate_functions: None,
             window_functions: None,
@@ -1067,6 +1106,7 @@ impl SessionStateBuilder {
             query_planner: Some(existing.query_planner),
             catalog_list: Some(existing.catalog_list),
             table_functions: Some(existing.table_functions),
+            batched_table_functions: Some(existing.batched_table_functions),
             scalar_functions: Some(existing.scalar_functions.into_values().collect_vec()),
             aggregate_functions: Some(
                 existing.aggregate_functions.into_values().collect_vec(),
@@ -1125,6 +1165,16 @@ impl SessionStateBuilder {
                     .map(|f| (f.name().to_string(), f)),
             );
 
+        self.batched_table_functions
+            .get_or_insert_with(HashMap::new)
+            .extend(
+                SessionStateDefaults::default_batched_table_functions()
+                    .into_iter()
+                    .map(|(name, func)| {
+                        (name.to_string(), Arc::new(BatchedTableFunction::new(func)))
+                    }),
+            );
+
         self
     }
 
@@ -1442,6 +1492,7 @@ impl SessionStateBuilder {
             query_planner,
             catalog_list,
             table_functions,
+            batched_table_functions,
             scalar_functions,
             aggregate_functions,
             window_functions,
@@ -1478,6 +1529,7 @@ impl SessionStateBuilder {
                 Arc::new(MemoryCatalogProviderList::new()) as Arc<dyn CatalogProviderList>
             }),
             table_functions: table_functions.unwrap_or_default(),
+            batched_table_functions: batched_table_functions.unwrap_or_default(),
             scalar_functions: HashMap::new(),
             aggregate_functions: HashMap::new(),
             window_functions: HashMap::new(),
@@ -1757,6 +1809,7 @@ impl Debug for SessionStateBuilder {
             .field("physical_optimizer_rules", &self.physical_optimizer_rules)
             .field("physical_optimizers", &self.physical_optimizers)
             .field("table_functions", &self.table_functions)
+            .field("batched_table_functions", &self.batched_table_functions)
             .field("scalar_functions", &self.scalar_functions)
             .field("aggregate_functions", &self.aggregate_functions)
             .field("window_functions", &self.window_functions)
@@ -1838,6 +1891,30 @@ impl ContextProvider for SessionContextProvider<'_> {
         Ok(provider_as_source(provider))
     }
 
+    fn is_batched_table_function(&self, name: &str) -> bool {
+        self.state.batched_table_function(name).is_some()
+    }
+
+    fn get_batched_table_function_source(
+        &self,
+        name: &str,
+        arg_types: &[DataType],
+    ) -> datafusion_common::Result<
+        Option<Arc<dyn datafusion_expr::BatchedTableFunctionSource>>,
+    > {
+        match self.state.batched_table_function(name) {
+            Some(tf) => {
+                use datafusion_catalog::default_table_source::DefaultBatchedTableFunctionSource;
+                let source = Arc::new(DefaultBatchedTableFunctionSource::new(
+                    Arc::clone(tf.inner()),
+                    arg_types.to_vec(),
+                ));
+                Ok(Some(source))
+            }
+            None => Ok(None),
+        }
+    }
+
     /// Create a new CTE work table for a recursive CTE logical plan
     /// This table will be used in conjunction with a Worktable physical plan
     /// to read and write each iteration of a recursive CTE
diff --git a/datafusion/core/src/execution/session_state_defaults.rs b/datafusion/core/src/execution/session_state_defaults.rs
index 62a575541a5d8..4ee717f5c8f15 100644
--- a/datafusion/core/src/execution/session_state_defaults.rs
+++ b/datafusion/core/src/execution/session_state_defaults.rs
@@ -128,6 +128,15 @@ impl SessionStateDefaults {
     }
 
     /// returns the list of default [`FileFormatFactory`]s
+    /// returns the list of default batched table functions
+    pub fn default_batched_table_functions() -> Vec<(
+        &'static str,
+        Arc<dyn datafusion_catalog::BatchedTableFunctionImpl>,
+    )> {
+        functions_table::all_default_batched_table_functions()
+    }
+
+    /// returns the list of default [`FileFormatFactory']'s
     pub fn default_file_formats() -> Vec<Arc<dyn FileFormatFactory>> {
         let file_formats: Vec<Arc<dyn FileFormatFactory>> = vec![
             #[cfg(feature = "parquet")]
diff --git a/datafusion/core/src/physical_planner.rs b/datafusion/core/src/physical_planner.rs
index 5bce862dc958c..a881b5300cf4e 100644
--- a/datafusion/core/src/physical_planner.rs
+++ b/datafusion/core/src/physical_planner.rs
@@ -29,7 +29,8 @@ use crate::error::{DataFusionError, Result};
 use crate::execution::context::{ExecutionProps, SessionState};
 use crate::logical_expr::utils::generate_sort_key;
 use crate::logical_expr::{
-    Aggregate, EmptyRelation, Join, Projection, Sort, TableScan, Unnest, Values, Window,
+    Aggregate, EmptyRelation, Join, LateralBatchedTableFunction, Projection, Sort,
+    StandaloneBatchedTableFunction, TableScan, Unnest, Values, Window,
 };
 use crate::logical_expr::{
     Expr, LogicalPlan, Partitioning as LogicalPartitioning, PlanType, Repartition,
@@ -60,6 +61,7 @@ use crate::schema_equivalence::schema_satisfied_by;
 use arrow::array::{builder::StringBuilder, RecordBatch};
 use arrow::compute::SortOptions;
 use arrow::datatypes::Schema;
+use datafusion_catalog::batched_function::BatchedTableFunctionExec;
 use arrow_schema::Field;
 use datafusion_catalog::ScanArgs;
 use datafusion_common::display::ToStringifiedPlan;
@@ -501,6 +503,51 @@ impl DefaultPhysicalPlanner {
                 let output_schema = Arc::clone(output_schema.inner());
                 self.plan_describe(&Arc::clone(schema), output_schema)?
             }
+            LogicalPlan::StandaloneBatchedTableFunction(
+                StandaloneBatchedTableFunction {
+                    function_name,
+                    source: _source,
+                    args,
+                    schema,
+                    projection,
+                    filters,
+                    fetch,
+                },
+            ) => {
+                let batched_tf = session_state
+                    .batched_table_function(function_name.as_str())
+                    .ok_or_else(|| {
+                        DataFusionError::Plan(format!(
+                            "Batched table function '{function_name}' not found"
+                        ))
+                    })?;
+
+                // Standalone mode: args are constants evaluated with empty schema
+                let empty_schema = Arc::new(DFSchema::empty());
+                let physical_args = args
+                    .iter()
+                    .map(|e| self.create_physical_expr(e, &empty_schema, session_state))
+                    .collect::<Result<Vec<_>>>()?;
+
+                let function_schema = Arc::clone(schema.inner());
+
+                // All filters have been pre-filtered by the logical optimizer
+                // based on supports_filters_pushdown(). Just pass them all through.
+                // PlaceholderRowExec produces one empty row to trigger the function once
+                let placeholder_exec =
+                    Arc::new(PlaceholderRowExec::new(Arc::new(Schema::empty())));
+                Arc::new(BatchedTableFunctionExec::new(
+                    Arc::clone(batched_tf.inner()),
+                    physical_args,
+                    placeholder_exec,
+                    Arc::clone(&function_schema), // projected_schema
+                    function_schema,              // table_function_schema
+                    datafusion_catalog::BatchedTableFunctionMode::Standalone,
+                    projection.clone(),
+                    filters.clone(),
+                    *fetch,
+                )?)
+            }
 
             // 1 Child
             LogicalPlan::Copy(CopyTo {
@@ -1506,6 +1553,50 @@ impl DefaultPhysicalPlanner {
                     plan
                 }
             }
+            LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction {
+                input,
+                function_name,
+                source: _source,
+                args,
+                schema,
+                table_function_schema,
+                projection,
+                filters,
+                fetch,
+            }) => {
+                let input_exec = children.one()?;
+
+                let batched_tf = session_state
+                    .batched_table_function(function_name.as_str())
+                    .ok_or_else(|| {
+                        DataFusionError::Plan(format!(
+                            "Batched table function '{function_name}' not found"
+                        ))
+                    })?;
+
+                // Args evaluated against input schema, not combined schema
+                let physical_args = args
+                    .iter()
+                    .map(|e| self.create_physical_expr(e, input.schema(), session_state))
+                    .collect::<Result<Vec<_>>>()?;
+
+                let combined_schema = Arc::clone(schema.inner());
+                let function_schema = Arc::clone(table_function_schema.inner());
+
+                // All filters have been pre-filtered by the logical optimizer
+                // based on supports_filters_pushdown(). Just pass them all through.
+                Arc::new(BatchedTableFunctionExec::new(
+                    Arc::clone(batched_tf.inner()),
+                    physical_args,
+                    input_exec,
+                    combined_schema,
+                    function_schema,
+                    datafusion_catalog::BatchedTableFunctionMode::Lateral,
+                    projection.clone(),
+                    filters.clone(),
+                    *fetch,
+                )?)
+            }
 
             // Other
             LogicalPlan::Statement(statement) => {
diff --git a/datafusion/core/tests/batched_table_function.rs b/datafusion/core/tests/batched_table_function.rs
new file mode 100644
index 0000000000000..b28b0c3a4f28c
--- /dev/null
+++ b/datafusion/core/tests/batched_table_function.rs
@@ -0,0 +1,517 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Integration tests for BatchedTableFunction
+
+use std::any::Any;
+use std::fmt;
+use std::pin::Pin;
+use std::sync::Arc;
+use std::task::{Context, Poll};
+
+use arrow::array::{Array, ArrayRef, AsArray, Int64Array, RecordBatch};
+use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
+use datafusion::prelude::*;
+use datafusion_catalog::batched_function::helpers::materialized_batch_stream;
+use datafusion_catalog::{BatchedTableFunction, BatchedTableFunctionImpl};
+use datafusion_common::{exec_err, Result};
+use datafusion_execution::{SendableRecordBatchStream, TaskContext};
+use datafusion_expr::{Expr, Signature, Volatility};
+use datafusion_physical_expr::expressions::Column;
+use datafusion_physical_expr::{EquivalenceProperties, PhysicalExpr};
+use datafusion_physical_plan::execution_plan::{Boundedness, EmissionType};
+use datafusion_physical_plan::{
+    DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties, RecordBatchStream,
+};
+use futures::stream::{Stream, StreamExt};
+
+/// Simple test execution plan that produces a single batch of data
+#[derive(Debug, Clone)]
+struct TestExec {
+    schema: SchemaRef,
+    data: Vec<RecordBatch>,
+    cache: PlanProperties,
+}
+
+impl TestExec {
+    fn new(data: Vec<RecordBatch>) -> Self {
+        let schema = data[0].schema();
+        let cache = PlanProperties::new(
+            EquivalenceProperties::new(Arc::clone(&schema)),
+            datafusion_physical_plan::Partitioning::UnknownPartitioning(1),
+            EmissionType::Incremental,
+            Boundedness::Bounded,
+        );
+        Self {
+            schema,
+            data,
+            cache,
+        }
+    }
+}
+
+impl DisplayAs for TestExec {
+    fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter) -> fmt::Result {
+        write!(f, "TestExec")
+    }
+}
+
+impl ExecutionPlan for TestExec {
+    fn name(&self) -> &str {
+        "TestExec"
+    }
+
+    fn as_any(&self) -> &dyn Any {
+        self
+    }
+
+    fn properties(&self) -> &PlanProperties {
+        &self.cache
+    }
+
+    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
+        vec![]
+    }
+
+    fn with_new_children(
+        self: Arc<Self>,
+        _children: Vec<Arc<dyn ExecutionPlan>>,
+    ) -> Result<Arc<dyn ExecutionPlan>> {
+        Ok(self)
+    }
+
+    fn execute(
+        &self,
+        _partition: usize,
+        _context: Arc<TaskContext>,
+    ) -> Result<SendableRecordBatchStream> {
+        Ok(Box::pin(TestStream {
+            schema: Arc::clone(&self.schema),
+            data: self.data.clone(),
+            index: 0,
+        }))
+    }
+}
+
+struct TestStream {
+    schema: SchemaRef,
+    data: Vec<RecordBatch>,
+    index: usize,
+}
+
+impl Stream for TestStream {
+    type Item = Result<RecordBatch>;
+
+    fn poll_next(
+        mut self: Pin<&mut Self>,
+        _cx: &mut Context<'_>,
+    ) -> Poll<Option<Self::Item>> {
+        if self.index < self.data.len() {
+            let batch = self.data[self.index].clone();
+            self.index += 1;
+            Poll::Ready(Some(Ok(batch)))
+        } else {
+            Poll::Ready(None)
+        }
+    }
+}
+
+impl RecordBatchStream for TestStream {
+    fn schema(&self) -> SchemaRef {
+        Arc::clone(&self.schema)
+    }
+}
+
+/// Simple batched table function that generates a range of numbers
+#[derive(Debug)]
+struct GenerateRangeFunction {
+    signature: Signature,
+}
+
+impl GenerateRangeFunction {
+    fn new() -> Self {
+        Self {
+            signature: Signature::exact(
+                vec![DataType::Int64, DataType::Int64],
+                Volatility::Immutable,
+            ),
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl BatchedTableFunctionImpl for GenerateRangeFunction {
+    fn name(&self) -> &str {
+        "generate_range"
+    }
+
+    fn signature(&self) -> &Signature {
+        &self.signature
+    }
+
+    fn return_type(&self, _arg_types: &[DataType]) -> Result<Schema> {
+        Ok(Schema::new(vec![Field::new("n", DataType::Int64, false)]))
+    }
+
+    async fn invoke_batch(
+        &self,
+        args: &[ArrayRef],
+        _projection: Option<&[usize]>,
+        _filters: &[Expr],
+        _limit: Option<usize>,
+    ) -> Result<datafusion_catalog::BatchResultStream> {
+        if args.len() != 2 {
+            return exec_err!("generate_range expects 2 arguments");
+        }
+
+        let start_array = args[0].as_primitive::<arrow::datatypes::Int64Type>();
+        let end_array = args[1].as_primitive::<arrow::datatypes::Int64Type>();
+
+        if start_array.len() != end_array.len() {
+            return exec_err!("start and end arrays must have same length");
+        }
+
+        let mut output_values = Vec::new();
+        let mut input_row_indices = Vec::new();
+
+        for row_idx in 0..start_array.len() {
+            if start_array.is_null(row_idx) || end_array.is_null(row_idx) {
+                continue;
+            }
+
+            let start = start_array.value(row_idx);
+            let end = end_array.value(row_idx);
+
+            // [start, end)
+            for value in start..end {
+                output_values.push(value);
+                input_row_indices.push(row_idx as u32);
+            }
+        }
+
+        let schema = self.return_type(&[DataType::Int64, DataType::Int64])?;
+        let output_array = Int64Array::from(output_values);
+        let output_batch =
+            RecordBatch::try_new(Arc::new(schema), vec![Arc::new(output_array)])?;
+
+        Ok(materialized_batch_stream(output_batch, input_row_indices))
+    }
+}
+
+#[tokio::test]
+async fn test_batched_table_function_basic() -> Result<()> {
+    let func = Arc::new(GenerateRangeFunction::new());
+    let batched_tf = BatchedTableFunction::new(func);
+
+    // Single row: generate_range(10, 13) -> [10, 11, 12]
+    let input_schema = Arc::new(Schema::new(vec![
+        Field::new("start", DataType::Int64, false),
+        Field::new("end", DataType::Int64, false),
+    ]));
+
+    let input_batch = RecordBatch::try_new(
+        Arc::clone(&input_schema),
+        vec![
+            Arc::new(Int64Array::from(vec![10])),
+            Arc::new(Int64Array::from(vec![13])),
+        ],
+    )?;
+
+    let input_exec = Arc::new(TestExec::new(vec![input_batch]));
+
+    let args = vec![
+        Arc::new(Column::new("start", 0)) as Arc<dyn PhysicalExpr>,
+        Arc::new(Column::new("end", 1)) as Arc<dyn PhysicalExpr>,
+    ];
+
+    let output_schema =
+        Arc::new(batched_tf.return_type(&[DataType::Int64, DataType::Int64])?);
+    let exec_plan =
+        batched_tf.create_plan(args, input_exec, Arc::clone(&output_schema))?;
+
+    let task_ctx = Arc::new(TaskContext::default());
+    let mut stream = exec_plan.execute(0, task_ctx)?;
+
+    let mut results = Vec::new();
+    while let Some(batch) = stream.next().await {
+        results.push(batch?);
+    }
+
+    assert!(!results.is_empty(), "Should have at least one batch");
+
+    let total_rows: usize = results.iter().map(|b| b.num_rows()).sum();
+    assert_eq!(total_rows, 3, "Should generate 3 values: 10, 11, 12");
+
+    let first_batch = &results[0];
+    assert_eq!(first_batch.num_columns(), 1);
+
+    let array = first_batch
+        .column(0)
+        .as_any()
+        .downcast_ref::<Int64Array>()
+        .unwrap();
+
+    assert_eq!(array.value(0), 10);
+    assert_eq!(array.value(1), 11);
+    assert_eq!(array.value(2), 12);
+
+    Ok(())
+}
+
+#[tokio::test]
+async fn test_batched_table_function_multiple_input_rows() -> Result<()> {
+    let func = Arc::new(GenerateRangeFunction::new());
+    let batched_tf = BatchedTableFunction::new(func);
+
+    // Two rows:
+    // Row 0: generate_range(1, 3) -> [1, 2]
+    // Row 1: generate_range(5, 7) -> [5, 6]
+    let input_schema = Arc::new(Schema::new(vec![
+        Field::new("start", DataType::Int64, false),
+        Field::new("end", DataType::Int64, false),
+    ]));
+
+    let input_batch = RecordBatch::try_new(
+        Arc::clone(&input_schema),
+        vec![
+            Arc::new(Int64Array::from(vec![1, 5])),
+            Arc::new(Int64Array::from(vec![3, 7])),
+        ],
+    )?;
+
+    let input_exec = Arc::new(TestExec::new(vec![input_batch]));
+
+    let args = vec![
+        Arc::new(Column::new("start", 0)) as Arc<dyn PhysicalExpr>,
+        Arc::new(Column::new("end", 1)) as Arc<dyn PhysicalExpr>,
+    ];
+
+    let output_schema =
+        Arc::new(batched_tf.return_type(&[DataType::Int64, DataType::Int64])?);
+    let exec_plan =
+        batched_tf.create_plan(args, input_exec, Arc::clone(&output_schema))?;
+
+    let task_ctx = Arc::new(TaskContext::default());
+    let mut stream = exec_plan.execute(0, task_ctx)?;
+
+    let mut results = Vec::new();
+    while let Some(batch) = stream.next().await {
+        results.push(batch?);
+    }
+
+    // [1, 2] from row 0, [5, 6] from row 1
+    let total_rows: usize = results.iter().map(|b| b.num_rows()).sum();
+    assert_eq!(total_rows, 4);
+
+    let first_batch = &results[0];
+    let array = first_batch
+        .column(0)
+        .as_any()
+        .downcast_ref::<Int64Array>()
+        .unwrap();
+
+    // Values in order: 1, 2, 5, 6
+    assert_eq!(array.value(0), 1);
+    assert_eq!(array.value(1), 2);
+    assert_eq!(array.value(2), 5);
+    assert_eq!(array.value(3), 6);
+
+    Ok(())
+}
+
+#[tokio::test]
+async fn test_batched_table_function_registration() -> Result<()> {
+    let ctx = SessionContext::new();
+
+    {
+        let state_ref = ctx.state_ref();
+        let mut state = state_ref.write();
+        state.register_batched_table_function(
+            "my_range",
+            Arc::new(GenerateRangeFunction::new()),
+        );
+    }
+
+    {
+        let state_ref = ctx.state_ref();
+        let state = state_ref.read();
+        let registered_func = state.batched_table_function("my_range");
+        assert!(registered_func.is_some(), "Function should be registered");
+
+        let func = registered_func.unwrap();
+        assert_eq!(func.name(), "generate_range");
+    }
+
+    {
+        let state_ref = ctx.state_ref();
+        let mut state = state_ref.write();
+        state.deregister_batched_table_function("my_range")?;
+    }
+
+    {
+        let state_ref = ctx.state_ref();
+        let state = state_ref.read();
+        let after_dereg = state.batched_table_function("my_range");
+        assert!(after_dereg.is_none(), "Function should be deregistered");
+    }
+
+    Ok(())
+}
+
+/// Simple test function for LATERAL testing
+#[derive(Debug)]
+struct DoubleFn {
+    signature: Signature,
+}
+
+impl DoubleFn {
+    fn new() -> Self {
+        Self {
+            signature: Signature::exact(vec![DataType::Int64], Volatility::Immutable),
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl BatchedTableFunctionImpl for DoubleFn {
+    fn name(&self) -> &str {
+        "double"
+    }
+
+    fn signature(&self) -> &Signature {
+        &self.signature
+    }
+
+    fn return_type(&self, _arg_types: &[DataType]) -> Result<Schema> {
+        Ok(Schema::new(vec![Field::new(
+            "doubled",
+            DataType::Int64,
+            true,
+        )]))
+    }
+
+    async fn invoke_batch(
+        &self,
+        args: &[ArrayRef],
+        _projection: Option<&[usize]>,
+        _filters: &[Expr],
+        _limit: Option<usize>,
+    ) -> Result<datafusion_catalog::BatchResultStream> {
+        if args.is_empty() {
+            return exec_err!("Expected at least one argument");
+        }
+
+        let input_array = args[0].as_primitive::<arrow::datatypes::Int64Type>();
+
+        let output_array: Int64Array =
+            input_array.iter().map(|v| v.map(|val| val * 2)).collect();
+
+        let schema = self.return_type(&[DataType::Int64])?;
+        let output_batch =
+            RecordBatch::try_new(Arc::new(schema), vec![Arc::new(output_array)])?;
+
+        // 1:1 mapping
+        let input_row_indices: Vec<u32> = (0..input_array.len() as u32).collect();
+
+        Ok(materialized_batch_stream(output_batch, input_row_indices))
+    }
+}
+
+#[tokio::test]
+async fn test_lateral_wildcard() -> Result<()> {
+    let ctx = SessionContext::new();
+
+    {
+        let state_ref = ctx.state_ref();
+        let mut state = state_ref.write();
+        state.register_batched_table_function("double", Arc::new(DoubleFn::new()));
+    }
+
+    let schema = Arc::new(Schema::new(vec![Field::new(
+        "value",
+        DataType::Int64,
+        false,
+    )]));
+    let batch = RecordBatch::try_new(
+        schema.clone(),
+        vec![Arc::new(Int64Array::from(vec![1, 2, 3]))],
+    )?;
+
+    ctx.register_batch("numbers", batch)?;
+
+    let df = ctx
+        .sql("SELECT * FROM numbers CROSS JOIN LATERAL double(numbers.value)")
+        .await?;
+
+    let results = df.collect().await?;
+
+    assert!(!results.is_empty());
+    assert_eq!(results[0].num_columns(), 2);
+
+    Ok(())
+}
+
+#[tokio::test]
+async fn test_lateral_explicit_columns() -> Result<()> {
+    let ctx = SessionContext::new();
+
+    {
+        let state_ref = ctx.state_ref();
+        let mut state = state_ref.write();
+        state.register_batched_table_function("double", Arc::new(DoubleFn::new()));
+    }
+
+    let schema = Arc::new(Schema::new(vec![Field::new(
+        "value",
+        DataType::Int64,
+        false,
+    )]));
+    let batch = RecordBatch::try_new(
+        schema.clone(),
+        vec![Arc::new(Int64Array::from(vec![1, 2, 3]))],
+    )?;
+
+    ctx.register_batch("numbers", batch)?;
+
+    let df = ctx
+        .sql(
+            "SELECT value, doubled FROM numbers CROSS JOIN LATERAL double(numbers.value)",
+        )
+        .await?;
+
+    let results = df.collect().await?;
+
+    assert!(!results.is_empty());
+    assert_eq!(results[0].num_columns(), 2);
+
+    let value_array = results[0]
+        .column(0)
+        .as_any()
+        .downcast_ref::<Int64Array>()
+        .unwrap();
+    let doubled_array = results[0]
+        .column(1)
+        .as_any()
+        .downcast_ref::<Int64Array>()
+        .unwrap();
+
+    assert_eq!(value_array.len(), 3);
+    assert_eq!(value_array.value(0), 1);
+    assert_eq!(doubled_array.value(0), 2);
+
+    Ok(())
+}
diff --git a/datafusion/core/tests/sql/lateral_simple.rs b/datafusion/core/tests/sql/lateral_simple.rs
new file mode 100644
index 0000000000000..8ed72d149353b
--- /dev/null
+++ b/datafusion/core/tests/sql/lateral_simple.rs
@@ -0,0 +1,167 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Simplified test to isolate LATERAL planning issue
+
+use std::sync::Arc;
+
+use arrow::array::*;
+use arrow::datatypes::*;
+use arrow::record_batch::RecordBatch;
+use datafusion::catalog::BatchedTableFunctionImpl;
+use datafusion::error::Result;
+use datafusion::prelude::*;
+use datafusion_catalog::batched_function::helpers::materialized_batch_stream;
+use datafusion_common::DataFusionError;
+use datafusion_expr::{Expr, Signature, Volatility};
+
+#[derive(Debug)]
+struct DoubleFn {
+    signature: Signature,
+}
+
+impl DoubleFn {
+    fn new() -> Self {
+        Self {
+            signature: Signature::exact(vec![DataType::Int64], Volatility::Immutable),
+        }
+    }
+}
+
+impl BatchedTableFunctionImpl for DoubleFn {
+    fn name(&self) -> &str {
+        "double"
+    }
+
+    fn signature(&self) -> &Signature {
+        &self.signature
+    }
+
+    fn return_type(&self, _arg_types: &[DataType]) -> Result<Schema> {
+        Ok(Schema::new(vec![Field::new(
+            "doubled",
+            DataType::Int64,
+            true,
+        )]))
+    }
+
+    fn invoke_batch(
+        &self,
+        args: &[ArrayRef],
+        _projection: Option<&[usize]>,
+        _filters: &[Expr],
+        _limit: Option<usize>,
+    ) -> Result<datafusion_catalog::BatchResultStream> {
+        if args.is_empty() {
+            return Err(DataFusionError::Internal(
+                "Expected at least one argument".to_string(),
+            ));
+        }
+
+        let input_array = args[0].as_primitive::<Int64Type>();
+
+        let output_array: Int64Array =
+            input_array.iter().map(|v| v.map(|val| val * 2)).collect();
+
+        let schema = self.return_type(&[DataType::Int64])?;
+        let output_batch =
+            RecordBatch::try_new(Arc::new(schema), vec![Arc::new(output_array)])?;
+
+        // 1:1 mapping
+        let input_row_indices: Vec<u32> = (0..input_array.len() as u32).collect();
+
+        Ok(materialized_batch_stream(output_batch, input_row_indices))
+    }
+}
+
+#[tokio::test]
+async fn test_lateral_no_select() -> Result<()> {
+    let ctx = SessionContext::new();
+
+    {
+        let state_ref = ctx.state_ref();
+        let mut state = state_ref.write();
+        state.register_batched_table_function("double", Arc::new(DoubleFn::new()));
+    }
+
+    let schema = Arc::new(Schema::new(vec![Field::new(
+        "value",
+        DataType::Int64,
+        false,
+    )]));
+    let batch = RecordBatch::try_new(
+        schema.clone(),
+        vec![Arc::new(Int64Array::from(vec![1, 2, 3]))],
+    )?;
+
+    ctx.register_batch("numbers", batch)?;
+
+    // Verify LATERAL join creates the correct plan
+    let result = ctx
+        .sql(
+            "SELECT value, doubled FROM numbers CROSS JOIN LATERAL double(numbers.value)",
+        )
+        .await;
+
+    match result {
+        Ok(_df) => {
+            println!("Plan created successfully!");
+            Ok(())
+        }
+        Err(e) => {
+            println!("Error: {}", e);
+            Err(e)
+        }
+    }
+}
+
+#[tokio::test]
+async fn test_lateral_simple_explain() -> Result<()> {
+    let ctx = SessionContext::new();
+
+    {
+        let state_ref = ctx.state_ref();
+        let mut state = state_ref.write();
+        state.register_batched_table_function("double", Arc::new(DoubleFn::new()));
+    }
+
+    let schema = Arc::new(Schema::new(vec![Field::new(
+        "value",
+        DataType::Int64,
+        false,
+    )]));
+    let batch = RecordBatch::try_new(
+        schema.clone(),
+        vec![Arc::new(Int64Array::from(vec![1, 2, 3]))],
+    )?;
+
+    ctx.register_batch("numbers", batch)?;
+
+    // Verify logical planning works
+    let df = ctx
+        .sql("EXPLAIN SELECT * FROM numbers CROSS JOIN LATERAL double(numbers.value)")
+        .await?;
+
+    let results = df.collect().await?;
+
+    println!("EXPLAIN results:");
+    for batch in &results {
+        println!("{:?}", batch);
+    }
+
+    Ok(())
+}
diff --git a/datafusion/core/tests/sql/lateral_table_functions.rs b/datafusion/core/tests/sql/lateral_table_functions.rs
new file mode 100644
index 0000000000000..b0a5ecd4f4c03
--- /dev/null
+++ b/datafusion/core/tests/sql/lateral_table_functions.rs
@@ -0,0 +1,219 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Integration tests for LATERAL table functions
+
+use std::sync::Arc;
+
+use arrow::array::*;
+use arrow::datatypes::*;
+use arrow::record_batch::RecordBatch;
+use datafusion::assert_batches_eq;
+use datafusion::catalog::BatchedTableFunctionImpl;
+use datafusion::error::Result;
+use datafusion::prelude::*;
+use datafusion_catalog::batched_function::helpers::materialized_batch_stream;
+use datafusion_common::DataFusionError;
+use datafusion_expr::{Expr, Signature, Volatility};
+
+/// A simple batched table function that doubles each input value
+#[derive(Debug)]
+struct DoubleFn {
+    signature: Signature,
+}
+
+impl DoubleFn {
+    fn new() -> Self {
+        Self {
+            signature: Signature::exact(vec![DataType::Int64], Volatility::Immutable),
+        }
+    }
+}
+
+impl BatchedTableFunctionImpl for DoubleFn {
+    fn name(&self) -> &str {
+        "double"
+    }
+
+    fn signature(&self) -> &Signature {
+        &self.signature
+    }
+
+    fn return_type(&self, _arg_types: &[DataType]) -> Result<Schema> {
+        Ok(Schema::new(vec![Field::new(
+            "doubled",
+            DataType::Int64,
+            true,
+        )]))
+    }
+
+    fn invoke_batch(
+        &self,
+        args: &[ArrayRef],
+        _projection: Option<&[usize]>,
+        _filters: &[Expr],
+        _limit: Option<usize>,
+    ) -> Result<datafusion_catalog::BatchResultStream> {
+        if args.is_empty() {
+            return Err(DataFusionError::Internal(
+                "Expected at least one argument".to_string(),
+            ));
+        }
+
+        let input_array = args[0].as_primitive::<Int64Type>();
+
+        let output_array: Int64Array =
+            input_array.iter().map(|v| v.map(|val| val * 2)).collect();
+
+        let schema = self.return_type(&[DataType::Int64])?;
+        let output_batch =
+            RecordBatch::try_new(Arc::new(schema), vec![Arc::new(output_array)])?;
+
+        // 1:1 mapping
+        let input_row_indices: Vec<u32> = (0..input_array.len() as u32).collect();
+
+        Ok(materialized_batch_stream(output_batch, input_row_indices))
+    }
+}
+
+#[tokio::test]
+async fn test_lateral_double_function() -> Result<()> {
+    let ctx = SessionContext::new();
+
+    {
+        let state_ref = ctx.state_ref();
+        let mut state = state_ref.write();
+        state.register_batched_table_function("double", Arc::new(DoubleFn::new()));
+    }
+
+    let schema = Arc::new(Schema::new(vec![Field::new(
+        "value",
+        DataType::Int64,
+        false,
+    )]));
+    let batch = RecordBatch::try_new(
+        schema.clone(),
+        vec![Arc::new(Int64Array::from(vec![1, 2, 3, 4, 5]))],
+    )?;
+
+    ctx.register_batch("numbers", batch)?;
+
+    let df = ctx
+        .sql("SELECT numbers.value, doubled FROM numbers CROSS JOIN LATERAL double(numbers.value)")
+        .await?;
+
+    let results = df.collect().await?;
+
+    let expected = [
+        "+-------+---------+",
+        "| value | doubled |",
+        "+-------+---------+",
+        "| 1     | 2       |",
+        "| 2     | 4       |",
+        "| 3     | 6       |",
+        "| 4     | 8       |",
+        "| 5     | 10      |",
+        "+-------+---------+",
+    ];
+    assert_batches_eq!(expected, &results);
+
+    Ok(())
+}
+
+#[tokio::test]
+async fn test_lateral_with_filter() -> Result<()> {
+    let ctx = SessionContext::new();
+
+    {
+        let state_ref = ctx.state_ref();
+        let mut state = state_ref.write();
+        state.register_batched_table_function("double", Arc::new(DoubleFn::new()));
+    }
+
+    let schema = Arc::new(Schema::new(vec![Field::new(
+        "value",
+        DataType::Int64,
+        false,
+    )]));
+    let batch = RecordBatch::try_new(
+        schema.clone(),
+        vec![Arc::new(Int64Array::from(vec![1, 2, 3, 4, 5]))],
+    )?;
+
+    ctx.register_batch("numbers", batch)?;
+
+    let df = ctx
+        .sql("SELECT numbers.value, doubled FROM numbers CROSS JOIN LATERAL double(numbers.value) WHERE value > 2")
+        .await?;
+
+    let results = df.collect().await?;
+
+    let expected = [
+        "+-------+---------+",
+        "| value | doubled |",
+        "+-------+---------+",
+        "| 3     | 6       |",
+        "| 4     | 8       |",
+        "| 5     | 10      |",
+        "+-------+---------+",
+    ];
+    assert_batches_eq!(expected, &results);
+
+    Ok(())
+}
+
+#[tokio::test]
+async fn test_lateral_with_projection() -> Result<()> {
+    let ctx = SessionContext::new();
+
+    {
+        let state_ref = ctx.state_ref();
+        let mut state = state_ref.write();
+        state.register_batched_table_function("double", Arc::new(DoubleFn::new()));
+    }
+
+    let schema = Arc::new(Schema::new(vec![Field::new(
+        "value",
+        DataType::Int64,
+        false,
+    )]));
+    let batch = RecordBatch::try_new(
+        schema.clone(),
+        vec![Arc::new(Int64Array::from(vec![1, 2, 3]))],
+    )?;
+
+    ctx.register_batch("numbers", batch)?;
+
+    let df = ctx
+        .sql("SELECT doubled FROM numbers CROSS JOIN LATERAL double(numbers.value)")
+        .await?;
+
+    let results = df.collect().await?;
+
+    let expected = [
+        "+---------+",
+        "| doubled |",
+        "+---------+",
+        "| 2       |",
+        "| 4       |",
+        "| 6       |",
+        "+---------+",
+    ];
+    assert_batches_eq!(expected, &results);
+
+    Ok(())
+}
diff --git a/datafusion/core/tests/sql/mod.rs b/datafusion/core/tests/sql/mod.rs
index 426ec213b3246..302d03b88c8a9 100644
--- a/datafusion/core/tests/sql/mod.rs
+++ b/datafusion/core/tests/sql/mod.rs
@@ -66,6 +66,8 @@ pub mod aggregates;
 pub mod create_drop;
 pub mod explain_analyze;
 pub mod joins;
+pub mod lateral_simple;
+pub mod lateral_table_functions;
 mod path_partition;
 mod runtime_config;
 pub mod select;
diff --git a/datafusion/expr/src/lib.rs b/datafusion/expr/src/lib.rs
index b02254bb7d486..f95ed8819a1b4 100644
--- a/datafusion/expr/src/lib.rs
+++ b/datafusion/expr/src/lib.rs
@@ -113,7 +113,9 @@ pub use logical_plan::*;
 pub use partition_evaluator::PartitionEvaluator;
 #[cfg(feature = "sql")]
 pub use sqlparser;
-pub use table_source::{TableProviderFilterPushDown, TableSource, TableType};
+pub use table_source::{
+    BatchedTableFunctionSource, TableProviderFilterPushDown, TableSource, TableType,
+};
 pub use udaf::{
     AggregateUDF, AggregateUDFImpl, ReversedUDAF, SetMonotonicity, StatisticsArgs,
     udaf_default_display_name, udaf_default_human_display, udaf_default_return_field,
diff --git a/datafusion/expr/src/logical_plan/display.rs b/datafusion/expr/src/logical_plan/display.rs
index 480974b055d11..f0f2f62770df7 100644
--- a/datafusion/expr/src/logical_plan/display.rs
+++ b/datafusion/expr/src/logical_plan/display.rs
@@ -21,10 +21,10 @@ use std::collections::HashMap;
 use std::fmt;
 
 use crate::{
-    Aggregate, DescribeTable, Distinct, DistinctOn, DmlStatement, Expr, Filter, Join,
-    Limit, LogicalPlan, Partitioning, Projection, RecursiveQuery, Repartition, Sort,
-    Subquery, SubqueryAlias, TableProviderFilterPushDown, TableScan, Unnest, Values,
-    Window, expr_vec_fmt,
+    expr_vec_fmt, Aggregate, DescribeTable, Distinct, DistinctOn, DmlStatement, Expr,
+    Filter, Join, LateralBatchedTableFunction, Limit, LogicalPlan, Partitioning, Projection,
+    RecursiveQuery, Repartition, Sort, StandaloneBatchedTableFunction, Subquery,
+    SubqueryAlias, TableProviderFilterPushDown, TableScan, Unnest, Values, Window,
 };
 
 use crate::dml::CopyTo;
@@ -640,6 +640,28 @@ impl<'a, 'b> PgJsonVisitor<'a, 'b> {
                     "StructColumn": expr_vec_fmt!(struct_type_columns),
                 })
             }
+            LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction {
+                function_name,
+                args,
+                ..
+            }) => {
+                json!({
+                    "Node Type": "LateralBatchedTableFunction",
+                    "Function": function_name,
+                    "Arguments": expr_vec_fmt!(args),
+                })
+            }
+            LogicalPlan::StandaloneBatchedTableFunction(StandaloneBatchedTableFunction {
+                function_name,
+                args,
+                ..
+            }) => {
+                json!({
+                    "Node Type": "StandaloneBatchedTableFunction",
+                    "Function": function_name,
+                    "Arguments": expr_vec_fmt!(args),
+                })
+            }
         }
     }
 }
diff --git a/datafusion/expr/src/logical_plan/mod.rs b/datafusion/expr/src/logical_plan/mod.rs
index c2b01868c97f3..49b87d8924961 100644
--- a/datafusion/expr/src/logical_plan/mod.rs
+++ b/datafusion/expr/src/logical_plan/mod.rs
@@ -38,12 +38,12 @@ pub use ddl::{
 };
 pub use dml::{DmlStatement, WriteOp};
 pub use plan::{
-    Aggregate, Analyze, ColumnUnnestList, DescribeTable, Distinct, DistinctOn,
-    EmptyRelation, Explain, ExplainOption, Extension, FetchType, Filter, Join,
-    JoinConstraint, JoinType, Limit, LogicalPlan, Partitioning, PlanType, Projection,
-    RecursiveQuery, Repartition, SkipType, Sort, StringifiedPlan, Subquery,
-    SubqueryAlias, TableScan, ToStringifiedPlan, Union, Unnest, Values, Window,
-    projection_schema,
+    projection_schema, Aggregate, Analyze, ColumnUnnestList, DescribeTable, Distinct,
+    DistinctOn, EmptyRelation, Explain, ExplainOption, Extension, FetchType, Filter,
+    Join, JoinConstraint, JoinType, LateralBatchedTableFunction, Limit, LogicalPlan,
+    Partitioning, PlanType, Projection, RecursiveQuery, Repartition, SkipType, Sort,
+    StandaloneBatchedTableFunction, StringifiedPlan, Subquery, SubqueryAlias, TableScan,
+    ToStringifiedPlan, Union, Unnest, Values, Window,
 };
 pub use statement::{
     Deallocate, Execute, Prepare, ResetVariable, SetVariable, Statement,
diff --git a/datafusion/expr/src/logical_plan/plan.rs b/datafusion/expr/src/logical_plan/plan.rs
index 4219c24bfc9c9..67392a3b6c578 100644
--- a/datafusion/expr/src/logical_plan/plan.rs
+++ b/datafusion/expr/src/logical_plan/plan.rs
@@ -46,7 +46,8 @@ use crate::utils::{
 };
 use crate::{
     BinaryExpr, CreateMemoryTable, CreateView, Execute, Expr, ExprSchemable,
-    LogicalPlanBuilder, Operator, Prepare, TableProviderFilterPushDown, TableSource,
+    LogicalPlanBuilder, Operator, Prepare, BatchedTableFunctionSource, TableProviderFilterPushDown,
+    TableSource,
     WindowFunctionDefinition, build_join_schema, expr_vec_fmt, requalify_sides_if_needed,
 };
 
@@ -288,6 +289,12 @@ pub enum LogicalPlan {
     /// Unnest a column that contains a nested list type such as an
     /// ARRAY. This is used to implement SQL `UNNEST`
     Unnest(Unnest),
+    /// LATERAL table function call that produces rows for each input row.
+    /// This is used to implement SQL LATERAL joins with table functions.
+    LateralBatchedTableFunction(LateralBatchedTableFunction),
+    /// Standalone batched table function call with constant arguments.
+    /// This is used for direct table function calls like `SELECT * FROM func(1, 2)`.
+    StandaloneBatchedTableFunction(StandaloneBatchedTableFunction),
     /// A variadic query (e.g. "Recursive CTEs")
     RecursiveQuery(RecursiveQuery),
 }
@@ -350,6 +357,13 @@ impl LogicalPlan {
             LogicalPlan::Copy(CopyTo { output_schema, .. }) => output_schema,
             LogicalPlan::Ddl(ddl) => ddl.schema(),
             LogicalPlan::Unnest(Unnest { schema, .. }) => schema,
+            LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction {
+                schema, ..
+            }) => schema,
+            LogicalPlan::StandaloneBatchedTableFunction(StandaloneBatchedTableFunction {
+                schema,
+                ..
+            }) => schema,
             LogicalPlan::RecursiveQuery(RecursiveQuery { static_term, .. }) => {
                 // we take the schema of the static term as the schema of the entire recursive query
                 static_term.schema()
@@ -365,7 +379,8 @@ impl LogicalPlan {
             | LogicalPlan::Projection(_)
             | LogicalPlan::Aggregate(_)
             | LogicalPlan::Unnest(_)
-            | LogicalPlan::Join(_) => self
+            | LogicalPlan::Join(_)
+            | LogicalPlan::LateralBatchedTableFunction(_) => self
                 .inputs()
                 .iter()
                 .map(|input| input.schema().as_ref())
@@ -471,6 +486,9 @@ impl LogicalPlan {
             LogicalPlan::Copy(copy) => vec![&copy.input],
             LogicalPlan::Ddl(ddl) => ddl.inputs(),
             LogicalPlan::Unnest(Unnest { input, .. }) => vec![input],
+            LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction { input, .. }) => {
+                vec![input]
+            }
             LogicalPlan::RecursiveQuery(RecursiveQuery {
                 static_term,
                 recursive_term,
@@ -481,7 +499,8 @@ impl LogicalPlan {
             LogicalPlan::TableScan { .. }
             | LogicalPlan::EmptyRelation { .. }
             | LogicalPlan::Values { .. }
-            | LogicalPlan::DescribeTable(_) => vec![],
+            | LogicalPlan::DescribeTable(_)
+            | LogicalPlan::StandaloneBatchedTableFunction { .. } => vec![],
         }
     }
 
@@ -594,7 +613,9 @@ impl LogicalPlan {
             | LogicalPlan::Copy(_)
             | LogicalPlan::Ddl(_)
             | LogicalPlan::DescribeTable(_)
-            | LogicalPlan::Unnest(_) => Ok(None),
+            | LogicalPlan::Unnest(_)
+            | LogicalPlan::LateralBatchedTableFunction(_)
+            | LogicalPlan::StandaloneBatchedTableFunction(_) => Ok(None),
         }
     }
 
@@ -742,6 +763,8 @@ impl LogicalPlan {
             LogicalPlan::EmptyRelation(_) => Ok(self),
             LogicalPlan::Statement(_) => Ok(self),
             LogicalPlan::DescribeTable(_) => Ok(self),
+            LogicalPlan::LateralBatchedTableFunction(_) => Ok(self),
+            LogicalPlan::StandaloneBatchedTableFunction(_) => Ok(self),
             LogicalPlan::Unnest(Unnest {
                 input,
                 exec_columns,
@@ -1135,6 +1158,51 @@ impl LogicalPlan {
                 self.assert_no_inputs(inputs)?;
                 Ok(self.clone())
             }
+            LogicalPlan::StandaloneBatchedTableFunction(StandaloneBatchedTableFunction {
+                function_name,
+                source,
+                schema,
+                projection,
+                filters,
+                fetch,
+                ..
+            }) => {
+                self.assert_no_inputs(inputs)?;
+                Ok(LogicalPlan::StandaloneBatchedTableFunction(
+                    StandaloneBatchedTableFunction {
+                        function_name: function_name.clone(),
+                        source: Arc::clone(source),
+                        args: expr,
+                        schema: Arc::clone(schema),
+                        projection: projection.clone(),
+                        filters: filters.clone(),
+                        fetch: *fetch,
+                    },
+                ))
+            }
+            LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction {
+                function_name,
+                source,
+                schema,
+                table_function_schema,
+                projection,
+                filters,
+                fetch,
+                ..
+            }) => {
+                let input = self.only_input(inputs)?;
+                Ok(LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction {
+                    input: Arc::new(input),
+                    function_name: function_name.clone(),
+                    source: Arc::clone(source),
+                    args: expr.to_vec(),
+                    schema: Arc::clone(schema),
+                    table_function_schema: Arc::clone(table_function_schema),
+                    projection: projection.clone(),
+                    filters: filters.clone(),
+                    fetch: *fetch,
+                }))
+            }
             LogicalPlan::Unnest(Unnest {
                 exec_columns: columns,
                 options,
@@ -1377,6 +1445,8 @@ impl LogicalPlan {
             ) => input.max_rows(),
             LogicalPlan::Values(v) => Some(v.values.len()),
             LogicalPlan::Unnest(_) => None,
+            LogicalPlan::LateralBatchedTableFunction(_) => None,
+            LogicalPlan::StandaloneBatchedTableFunction(_) => None,
             LogicalPlan::Ddl(_)
             | LogicalPlan::Explain(_)
             | LogicalPlan::Analyze(_)
@@ -2098,6 +2168,38 @@ impl LogicalPlan {
                             expr_vec_fmt!(struct_type_columns)
                         )
                     }
+                    LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction {
+                        function_name,
+                        args,
+                        projection,
+                        filters,
+                        ..
+                    }) => {
+                        write!(f, "LateralBatchedTableFunction: {}({})", function_name, expr_vec_fmt!(args))?;
+                        if let Some(proj) = projection {
+                            write!(f, ", projection={:?}", proj)?;
+                        }
+                        if !filters.is_empty() {
+                            write!(f, ", filters=[{}]", expr_vec_fmt!(filters))?;
+                        }
+                        Ok(())
+                    }
+                    LogicalPlan::StandaloneBatchedTableFunction(StandaloneBatchedTableFunction {
+                        function_name,
+                        args,
+                        projection,
+                        filters,
+                        ..
+                    }) => {
+                        write!(f, "StandaloneBatchedTableFunction: {}({})", function_name, expr_vec_fmt!(args))?;
+                        if let Some(proj) = projection {
+                            write!(f, ", projection={:?}", proj)?;
+                        }
+                        if !filters.is_empty() {
+                            write!(f, ", filters=[{}]", expr_vec_fmt!(filters))?;
+                        }
+                        Ok(())
+                    }
                 }
             }
         }
@@ -4298,6 +4400,167 @@ fn get_unnested_list_datatype_recursive(
     internal_err!("trying to unnest on invalid data type {data_type}")
 }
 
+/// Standalone batched table function call with constant arguments.
+///
+/// This represents a direct call to a batched table function where all arguments
+/// are constants (not correlated with any outer table). The function is invoked once
+/// and returns a set of rows.
+///
+/// # Example SQL
+/// ```sql
+/// SELECT * FROM generate_series(1, 10)
+/// ```
+#[derive(Clone)]
+pub struct StandaloneBatchedTableFunction {
+    /// Name of the table function to call
+    pub function_name: String,
+    /// The source of the batched table function
+    pub source: Arc<dyn BatchedTableFunctionSource>,
+    /// Expressions for table function arguments (should be constants for standalone mode)
+    pub args: Vec<Expr>,
+    /// The output schema (only table function output columns)
+    pub schema: DFSchemaRef,
+    /// Optional column indices to use as a projection
+    pub projection: Option<Vec<usize>>,
+    /// Optional expressions to be used as filters by the table function
+    pub filters: Vec<Expr>,
+    /// Optional limit to push down to the function (number of rows to fetch)
+    pub fetch: Option<usize>,
+}
+
+impl Debug for StandaloneBatchedTableFunction {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        f.debug_struct("StandaloneBatchedTableFunction")
+            .field("function_name", &self.function_name)
+            .field("source", &"...")
+            .field("args", &self.args)
+            .field("schema", &self.schema)
+            .field("projection", &self.projection)
+            .field("filters", &self.filters)
+            .field("fetch", &self.fetch)
+            .finish()
+    }
+}
+
+impl PartialEq for StandaloneBatchedTableFunction {
+    fn eq(&self, other: &Self) -> bool {
+        self.function_name == other.function_name
+            && self.args == other.args
+            && self.projection == other.projection
+            && self.filters == other.filters
+            && self.fetch == other.fetch
+    }
+}
+
+impl Eq for StandaloneBatchedTableFunction {}
+
+// Manual implementation needed because of `source` and `schema` fields. Comparison excludes these fields.
+impl PartialOrd for StandaloneBatchedTableFunction {
+    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+        match self.function_name.partial_cmp(&other.function_name) {
+            Some(Ordering::Equal) => self.args.partial_cmp(&other.args),
+            cmp => cmp,
+        }
+        // TODO (https://github.com/apache/datafusion/issues/17477) avoid recomparing all fields
+        .filter(|cmp| *cmp != Ordering::Equal || self == other)
+    }
+}
+
+impl Hash for StandaloneBatchedTableFunction {
+    fn hash<H: Hasher>(&self, state: &mut H) {
+        self.function_name.hash(state);
+        self.args.hash(state);
+        self.projection.hash(state);
+        self.filters.hash(state);
+        self.fetch.hash(state);
+    }
+}
+
+/// Lateral table function call
+///
+/// Evaluates a table function for each row from the input plan.
+/// This is used to implement SQL LATERAL joins with table functions.
+///
+/// # Example SQL
+/// ```sql
+/// SELECT * FROM t, LATERAL generate_series(1, t.x)
+/// ```
+#[derive(Clone)]
+pub struct LateralBatchedTableFunction {
+    /// The incoming logical plan (the "outer" table)
+    pub input: Arc<LogicalPlan>,
+    /// Name of the table function to call
+    pub function_name: String,
+    /// The source of the batched table function
+    pub source: Arc<dyn BatchedTableFunctionSource>,
+    /// Expressions for table function arguments (can reference input columns)
+    pub args: Vec<Expr>,
+    /// The output schema (input columns + table function output columns)
+    pub schema: DFSchemaRef,
+    /// Table function output schema only (excludes input columns).
+    /// Used by the physical executor to properly combine batches.
+    pub table_function_schema: DFSchemaRef,
+    /// Optional column indices to use as a projection
+    pub projection: Option<Vec<usize>>,
+    /// Optional expressions to be used as filters by the table function
+    pub filters: Vec<Expr>,
+    /// Optional limit to push down to the function (number of rows to fetch)
+    pub fetch: Option<usize>,
+}
+
+impl Debug for LateralBatchedTableFunction {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        f.debug_struct("LateralBatchedTableFunction")
+            .field("function_name", &self.function_name)
+            .field("source", &"...")
+            .field("args", &self.args)
+            .field("schema", &self.schema)
+            .field("projection", &self.projection)
+            .field("filters", &self.filters)
+            .field("fetch", &self.fetch)
+            .finish()
+    }
+}
+
+impl PartialEq for LateralBatchedTableFunction {
+    fn eq(&self, other: &Self) -> bool {
+        self.input == other.input
+            && self.function_name == other.function_name
+            && self.args == other.args
+            && self.projection == other.projection
+            && self.filters == other.filters
+            && self.fetch == other.fetch
+    }
+}
+
+impl Eq for LateralBatchedTableFunction {}
+
+// Manual implementation needed because of `source` and `schema` fields. Comparison excludes these fields.
+impl PartialOrd for LateralBatchedTableFunction {
+    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+        match self.function_name.partial_cmp(&other.function_name) {
+            Some(Ordering::Equal) => match self.input.partial_cmp(&other.input) {
+                Some(Ordering::Equal) => self.args.partial_cmp(&other.args),
+                cmp => cmp,
+            },
+            cmp => cmp,
+        }
+        // TODO (https://github.com/apache/datafusion/issues/17477) avoid recomparing all fields
+        .filter(|cmp| *cmp != Ordering::Equal || self == other)
+    }
+}
+
+impl Hash for LateralBatchedTableFunction {
+    fn hash<H: Hasher>(&self, state: &mut H) {
+        self.input.hash(state);
+        self.function_name.hash(state);
+        self.args.hash(state);
+        self.projection.hash(state);
+        self.filters.hash(state);
+        self.fetch.hash(state);
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/datafusion/expr/src/logical_plan/tree_node.rs b/datafusion/expr/src/logical_plan/tree_node.rs
index 62a27b0a025ad..c0cc806f3e4bd 100644
--- a/datafusion/expr/src/logical_plan/tree_node.rs
+++ b/datafusion/expr/src/logical_plan/tree_node.rs
@@ -38,11 +38,11 @@
 //! * [`LogicalPlan::expressions`]: Return a copy of the plan's expressions
 
 use crate::{
-    Aggregate, Analyze, CreateMemoryTable, CreateView, DdlStatement, Distinct,
-    DistinctOn, DmlStatement, Execute, Explain, Expr, Extension, Filter, Join, Limit,
-    LogicalPlan, Partitioning, Prepare, Projection, RecursiveQuery, Repartition, Sort,
-    Statement, Subquery, SubqueryAlias, TableScan, Union, Unnest, UserDefinedLogicalNode,
-    Values, Window, dml::CopyTo,
+    dml::CopyTo, Aggregate, Analyze, CreateMemoryTable, CreateView, DdlStatement,
+    Distinct, DistinctOn, DmlStatement, Execute, Explain, Expr, Extension, Filter, Join,
+    LateralBatchedTableFunction, Limit, LogicalPlan, Partitioning, Prepare, Projection,
+    RecursiveQuery, Repartition, Sort, Statement, StandaloneBatchedTableFunction, Subquery,
+    SubqueryAlias, TableScan, Union, Unnest, UserDefinedLogicalNode, Values, Window,
 };
 use datafusion_common::tree_node::TreeNodeRefContainer;
 
@@ -322,6 +322,29 @@ impl TreeNode for LogicalPlan {
                     options,
                 })
             }),
+            LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction {
+                input,
+                function_name,
+                source,
+                args,
+                schema,
+                table_function_schema,
+                projection,
+                filters,
+                fetch,
+            }) => input.map_elements(f)?.update_data(|input| {
+                LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction {
+                    input,
+                    function_name,
+                    source,
+                    args,
+                    schema,
+                    table_function_schema,
+                    projection,
+                    filters,
+                    fetch,
+                })
+            }),
             LogicalPlan::RecursiveQuery(RecursiveQuery {
                 name,
                 static_term,
@@ -349,7 +372,8 @@ impl TreeNode for LogicalPlan {
             LogicalPlan::TableScan { .. }
             | LogicalPlan::EmptyRelation { .. }
             | LogicalPlan::Values { .. }
-            | LogicalPlan::DescribeTable(_) => Transformed::no(self),
+            | LogicalPlan::DescribeTable(_)
+            | LogicalPlan::StandaloneBatchedTableFunction(_) => Transformed::no(self),
         })
     }
 }
@@ -446,6 +470,10 @@ impl LogicalPlan {
                     .collect::<Vec<_>>();
                 exprs.apply_elements(f)
             }
+            LogicalPlan::StandaloneBatchedTableFunction(batched_scan) => {
+                batched_scan.args.apply_elements(f)
+            }
+            LogicalPlan::LateralBatchedTableFunction(lateral) => lateral.args.apply_elements(f),
             LogicalPlan::Distinct(Distinct::On(DistinctOn {
                 on_expr,
                 select_expr,
@@ -640,6 +668,48 @@ impl LogicalPlan {
                 _ => Transformed::no(stmt),
             }
             .update_data(LogicalPlan::Statement),
+            LogicalPlan::StandaloneBatchedTableFunction(StandaloneBatchedTableFunction {
+                function_name,
+                source,
+                args,
+                schema,
+                projection,
+                filters,
+                fetch,
+            }) => args.map_elements(f)?.update_data(|args| {
+                LogicalPlan::StandaloneBatchedTableFunction(StandaloneBatchedTableFunction {
+                    function_name,
+                    source,
+                    args,
+                    schema,
+                    projection,
+                    filters,
+                    fetch,
+                })
+            }),
+            LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction {
+                input,
+                function_name,
+                source,
+                args,
+                schema,
+                table_function_schema,
+                projection,
+                filters,
+                fetch,
+            }) => args.map_elements(f)?.update_data(|args| {
+                LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction {
+                    input,
+                    function_name,
+                    source,
+                    args,
+                    schema,
+                    table_function_schema,
+                    projection,
+                    filters,
+                    fetch,
+                })
+            }),
             // plans without expressions
             LogicalPlan::EmptyRelation(_)
             | LogicalPlan::Unnest(_)
diff --git a/datafusion/expr/src/planner.rs b/datafusion/expr/src/planner.rs
index 954f511651ced..7d00a3932767c 100644
--- a/datafusion/expr/src/planner.rs
+++ b/datafusion/expr/src/planner.rs
@@ -24,8 +24,8 @@ use crate::expr::NullTreatment;
 #[cfg(feature = "sql")]
 use crate::logical_plan::LogicalPlan;
 use crate::{
-    AggregateUDF, Expr, GetFieldAccess, ScalarUDF, SortExpr, TableSource, WindowFrame,
-    WindowFunctionDefinition, WindowUDF,
+    AggregateUDF, Expr, GetFieldAccess, ScalarUDF, SortExpr, BatchedTableFunctionSource,
+    TableSource, WindowFrame, WindowFunctionDefinition, WindowUDF,
 };
 use arrow::datatypes::{DataType, Field, FieldRef, SchemaRef};
 use datafusion_common::datatype::DataTypeExt;
@@ -61,6 +61,27 @@ pub trait ContextProvider {
         not_impl_err!("Table Functions are not supported")
     }
 
+    /// Check if a function is a batched table function
+    ///
+    /// Batched table functions can be used with LATERAL joins
+    /// to produce rows for each input row.
+    fn is_batched_table_function(&self, _name: &str) -> bool {
+        false
+    }
+
+    /// Get a batched table function source by name with specific argument types
+    ///
+    /// Returns the source for a batched table function that can be used in logical planning.
+    /// The source is created for a specific call site with the given argument types,
+    /// allowing the source to return the correct output schema.
+    fn get_batched_table_function_source(
+        &self,
+        _name: &str,
+        _arg_types: &[DataType],
+    ) -> Result<Option<Arc<dyn BatchedTableFunctionSource>>> {
+        not_impl_err!("Batched table functions are not supported")
+    }
+
     /// Provides an intermediate table that is used to store the results of a CTE during execution
     ///
     /// CTE stands for "Common Table Expression"
diff --git a/datafusion/expr/src/table_source.rs b/datafusion/expr/src/table_source.rs
index d3b253c0e102c..2f4ad8264f738 100644
--- a/datafusion/expr/src/table_source.rs
+++ b/datafusion/expr/src/table_source.rs
@@ -130,3 +130,38 @@ pub trait TableSource: Sync + Send {
         None
     }
 }
+
+/// Planning time information about a batched table function.
+///
+/// This trait is used during logical query planning and optimizations for
+/// batched table functions (UDTFs), providing schema information and filter
+/// push-down capabilities.
+///
+/// Similar to [`TableSource`], this trait provides a subset of functionality
+/// needed during logical planning, while the full [`BatchedTableFunctionImpl`]
+/// trait provides the execution capabilities.
+///
+/// [`BatchedTableFunctionImpl`]: https://docs.rs/datafusion/latest/datafusion/catalog/trait.BatchedTableFunctionImpl.html
+pub trait BatchedTableFunctionSource: Sync + Send {
+    fn as_any(&self) -> &dyn Any;
+
+    /// Get the name of this batched table function
+    fn name(&self) -> &str;
+
+    /// Get a reference to the schema for this table function's output
+    fn schema(&self) -> SchemaRef;
+
+    /// Tests whether the batched table function can make use of any or all filter
+    /// expressions to optimize data retrieval. Only non-volatile expressions are passed
+    /// to this function.
+    ///
+    /// This is similar to [`TableSource::supports_filters_pushdown`].
+    fn supports_filters_pushdown(
+        &self,
+        filters: &[&Expr],
+    ) -> Result<Vec<TableProviderFilterPushDown>> {
+        Ok((0..filters.len())
+            .map(|_| TableProviderFilterPushDown::Unsupported)
+            .collect())
+    }
+}
diff --git a/datafusion/functions-table/Cargo.toml b/datafusion/functions-table/Cargo.toml
index aa401fbd7d4ed..1173e56781062 100644
--- a/datafusion/functions-table/Cargo.toml
+++ b/datafusion/functions-table/Cargo.toml
@@ -47,8 +47,12 @@ datafusion-catalog = { workspace = true }
 datafusion-common = { workspace = true }
 datafusion-expr = { workspace = true }
 datafusion-physical-plan = { workspace = true }
+datafusion-physical-expr = { workspace = true }
+futures = { workspace = true }
 parking_lot = { workspace = true }
 paste = { workspace = true }
 
 [dev-dependencies]
 arrow = { workspace = true, features = ["test_utils"] }
+datafusion-execution = { workspace = true }
+tokio = { workspace = true }
diff --git a/datafusion/functions-table/src/generate_series_batched.rs b/datafusion/functions-table/src/generate_series_batched.rs
new file mode 100644
index 0000000000000..ebe8933ca2a98
--- /dev/null
+++ b/datafusion/functions-table/src/generate_series_batched.rs
@@ -0,0 +1,415 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Batched implementation of generate_series table function
+//!
+//! This validates the BatchedTableFunctionImpl trait design.
+//! The function receives already-evaluated arguments and just needs to
+//! generate output rows.
+
+use std::sync::Arc;
+
+use arrow::array::{Array, ArrayRef, AsArray, Int64Array, RecordBatch};
+use arrow::datatypes::{DataType, Field, Schema};
+use datafusion_catalog::{BatchResultChunk, BatchedTableFunctionImpl};
+use datafusion_common::{exec_err, Result};
+use datafusion_expr::Signature;
+
+/// Batched generate_series table function
+///
+/// Generates a series of Int64 values from start to end (inclusive).
+///
+/// # Example
+/// ```text
+/// invoke([ScalarValue::Int64(1), ScalarValue::Int64(5)])
+/// Returns: [[1], [2], [3], [4], [5]]
+/// ```
+#[derive(Debug)]
+pub struct GenerateSeriesFunction {
+    signature: Signature,
+}
+
+impl GenerateSeriesFunction {
+    pub fn new() -> Self {
+        Self {
+            signature: Signature::exact(
+                vec![DataType::Int64, DataType::Int64],
+                datafusion_expr::Volatility::Immutable,
+            ),
+        }
+    }
+}
+
+impl Default for GenerateSeriesFunction {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+#[async_trait::async_trait]
+impl BatchedTableFunctionImpl for GenerateSeriesFunction {
+    fn name(&self) -> &str {
+        "generate_series"
+    }
+
+    fn signature(&self) -> &Signature {
+        &self.signature
+    }
+
+    fn return_type(&self, _arg_types: &[DataType]) -> Result<Schema> {
+        Ok(Schema::new(vec![Field::new(
+            "value",
+            DataType::Int64,
+            false,
+        )]))
+    }
+
+    async fn invoke_batch(
+        &self,
+        args: &[ArrayRef],
+        _projection: Option<&[usize]>,
+        _filters: &[datafusion_expr::Expr],
+        limit: Option<usize>,
+    ) -> Result<datafusion_catalog::BatchResultStream> {
+        if args.len() != 2 {
+            return exec_err!("generate_series expects 2 arguments, got {}", args.len());
+        }
+
+        let start_array = args[0].as_primitive::<arrow::datatypes::Int64Type>();
+        let end_array = args[1].as_primitive::<arrow::datatypes::Int64Type>();
+
+        if start_array.len() != end_array.len() {
+            return exec_err!("start and end arrays must have same length");
+        }
+
+        let mut ranges = Vec::new();
+        for row_idx in 0..start_array.len() {
+            if start_array.is_null(row_idx) {
+                return exec_err!("generate_series start argument cannot be NULL");
+            }
+            if end_array.is_null(row_idx) {
+                return exec_err!("generate_series end argument cannot be NULL");
+            }
+
+            let start = start_array.value(row_idx);
+            let end = end_array.value(row_idx);
+
+            ranges.push((start, end, row_idx as u32));
+        }
+
+        let schema = Arc::new(self.return_type(&[DataType::Int64, DataType::Int64])?);
+        let stream = GenerateSeriesStream {
+            schema,
+            ranges,
+            current_range_idx: 0,
+            current_value: None,
+            batch_size: 8192,
+            limit,
+            total_emitted: 0,
+        };
+
+        Ok(Box::pin(stream))
+    }
+}
+
+/// Batched generator that yields batches of generated series values
+struct GenerateSeriesStream {
+    schema: Arc<Schema>,
+    ranges: Vec<(i64, i64, u32)>, // (start, end, input_row_idx)
+    current_range_idx: usize,
+    current_value: Option<i64>,
+    batch_size: usize,
+    limit: Option<usize>,
+    total_emitted: usize,
+}
+
+impl futures::stream::Stream for GenerateSeriesStream {
+    type Item = Result<BatchResultChunk>;
+
+    fn poll_next(
+        mut self: std::pin::Pin<&mut Self>,
+        _cx: &mut std::task::Context<'_>,
+    ) -> std::task::Poll<Option<Self::Item>> {
+        // Check if we've reached the limit
+        if let Some(limit) = self.limit {
+            if self.total_emitted >= limit {
+                return std::task::Poll::Ready(None);
+            }
+        }
+
+        if self.current_range_idx >= self.ranges.len() {
+            return std::task::Poll::Ready(None);
+        }
+
+        let mut output_values = Vec::with_capacity(self.batch_size);
+        let mut input_row_indices = Vec::with_capacity(self.batch_size);
+
+        // Calculate effective batch size considering limit
+        let batch_size = match self.limit {
+            Some(limit) => self.batch_size.min(limit - self.total_emitted),
+            None => self.batch_size,
+        };
+
+        while output_values.len() < batch_size
+            && self.current_range_idx < self.ranges.len()
+        {
+            let (start, end, input_row_idx) = self.ranges[self.current_range_idx];
+
+            if self.current_value.is_none() {
+                self.current_value = Some(start);
+            }
+            let current = self.current_value.as_mut().unwrap();
+
+            while output_values.len() < batch_size && *current <= end {
+                output_values.push(*current);
+                input_row_indices.push(input_row_idx);
+                *current += 1;
+            }
+
+            if *current > end {
+                self.current_range_idx += 1;
+                self.current_value = None;
+            }
+        }
+
+        if output_values.is_empty() {
+            return std::task::Poll::Ready(None);
+        }
+
+        // Update total emitted count
+        self.total_emitted += output_values.len();
+
+        let output_array = Int64Array::from(output_values);
+        let output_batch = match RecordBatch::try_new(
+            Arc::clone(&self.schema),
+            vec![Arc::new(output_array)],
+        ) {
+            Ok(batch) => batch,
+            Err(e) => {
+                return std::task::Poll::Ready(Some(Err(
+                    datafusion_common::DataFusionError::ArrowError(Box::new(e), None),
+                )))
+            }
+        };
+
+        std::task::Poll::Ready(Some(Ok(BatchResultChunk {
+            output: output_batch,
+            input_row_indices,
+        })))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use futures::StreamExt;
+
+    #[test]
+    fn test_function_metadata() {
+        let function = GenerateSeriesFunction::new();
+
+        assert_eq!(function.name(), "generate_series");
+
+        let sig = function.signature();
+        assert_eq!(sig.volatility, datafusion_expr::Volatility::Immutable);
+
+        let schema = function
+            .return_type(&[DataType::Int64, DataType::Int64])
+            .unwrap();
+        assert_eq!(schema.fields().len(), 1);
+        assert_eq!(schema.field(0).name(), "value");
+        assert_eq!(schema.field(0).data_type(), &DataType::Int64);
+    }
+
+    #[tokio::test]
+    async fn test_invoke_basic() {
+        let function = GenerateSeriesFunction::new();
+
+        // generate_series(1, 5)
+        let start_array = Int64Array::from(vec![1]);
+        let end_array = Int64Array::from(vec![5]);
+        let args: Vec<ArrayRef> = vec![Arc::new(start_array), Arc::new(end_array)];
+
+        let mut stream = function.invoke_batch(&args, None, &[], None).await.unwrap();
+        let chunk = stream.next().await.unwrap().unwrap();
+
+        assert_eq!(chunk.output.num_rows(), 5);
+        assert_eq!(chunk.input_row_indices, vec![0, 0, 0, 0, 0]);
+
+        let output_array = chunk
+            .output
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+
+        for (i, expected) in (1..=5).enumerate() {
+            assert_eq!(output_array.value(i), expected);
+        }
+    }
+
+    #[tokio::test]
+    async fn test_invoke_empty_range() {
+        let function = GenerateSeriesFunction::new();
+
+        // start > end
+        let start_array = Int64Array::from(vec![5]);
+        let end_array = Int64Array::from(vec![1]);
+        let args: Vec<ArrayRef> = vec![Arc::new(start_array), Arc::new(end_array)];
+
+        let mut stream = function.invoke_batch(&args, None, &[], None).await.unwrap();
+        let chunk = stream.next().await;
+
+        assert!(chunk.is_none());
+    }
+
+    #[tokio::test]
+    async fn test_invoke_single_value() {
+        let function = GenerateSeriesFunction::new();
+
+        let start_array = Int64Array::from(vec![3]);
+        let end_array = Int64Array::from(vec![3]);
+        let args: Vec<ArrayRef> = vec![Arc::new(start_array), Arc::new(end_array)];
+
+        let mut stream = function.invoke_batch(&args, None, &[], None).await.unwrap();
+        let chunk = stream.next().await.unwrap().unwrap();
+
+        assert_eq!(chunk.output.num_rows(), 1);
+        assert_eq!(chunk.input_row_indices, vec![0]);
+
+        let output_array = chunk
+            .output
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(output_array.value(0), 3);
+    }
+
+    #[tokio::test]
+    async fn test_invoke_null_argument() {
+        let function = GenerateSeriesFunction::new();
+
+        let start_array = Int64Array::from(vec![None]);
+        let end_array = Int64Array::from(vec![Some(5)]);
+        let args: Vec<ArrayRef> = vec![Arc::new(start_array), Arc::new(end_array)];
+
+        let result = function.invoke_batch(&args, None, &[], None).await;
+        assert!(result.is_err());
+        let err = result.err().unwrap();
+        assert!(err.to_string().contains("cannot be NULL"));
+    }
+
+    #[tokio::test]
+    async fn test_invoke_wrong_arg_count() {
+        let function = GenerateSeriesFunction::new();
+
+        let start_array = Int64Array::from(vec![1]);
+        let args: Vec<ArrayRef> = vec![Arc::new(start_array)];
+
+        let result = function.invoke_batch(&args, None, &[], None).await;
+        assert!(result.is_err());
+        let err = result.err().unwrap();
+        assert!(err.to_string().contains("expects 2 arguments"));
+    }
+
+    #[tokio::test]
+    async fn test_limit_pushdown() {
+        use futures::StreamExt;
+
+        let function = GenerateSeriesFunction::new();
+
+        // Generate series 1 to 10000, but with limit 10
+        let start_array = Int64Array::from(vec![1]);
+        let end_array = Int64Array::from(vec![10000]);
+        let args: Vec<ArrayRef> = vec![Arc::new(start_array), Arc::new(end_array)];
+
+        let mut stream = function
+            .invoke_batch(&args, None, &[], Some(10))
+            .await
+            .unwrap();
+
+        let mut total_rows = 0;
+        while let Some(chunk) = stream.next().await {
+            let chunk = chunk.unwrap();
+            total_rows += chunk.output.num_rows();
+        }
+
+        // With limit pushdown, should only generate 10 rows instead of 10000
+        assert_eq!(
+            total_rows, 10,
+            "Limit pushdown should generate exactly 10 rows"
+        );
+    }
+
+    #[tokio::test]
+    async fn test_projection_parameter() {
+        use futures::StreamExt;
+
+        let function = GenerateSeriesFunction::new();
+
+        // Generate series with projection (column 0)
+        let start_array = Int64Array::from(vec![1]);
+        let end_array = Int64Array::from(vec![5]);
+        let args: Vec<ArrayRef> = vec![Arc::new(start_array), Arc::new(end_array)];
+
+        // Pass projection - even though we only have 1 column, verify it's accepted
+        let projection = Some(&[0usize][..]);
+        let mut stream = function
+            .invoke_batch(&args, projection, &[], None)
+            .await
+            .unwrap();
+
+        let mut total_rows = 0;
+        while let Some(chunk) = stream.next().await {
+            let chunk = chunk.unwrap();
+            total_rows += chunk.output.num_rows();
+
+            // Verify we still get the expected output
+            let output_array = chunk
+                .output
+                .column(0)
+                .as_any()
+                .downcast_ref::<Int64Array>()
+                .unwrap();
+
+            for i in 0..chunk.output.num_rows() {
+                assert!(output_array.value(i) >= 1 && output_array.value(i) <= 5);
+            }
+        }
+
+        assert_eq!(total_rows, 5, "Should generate 5 rows with projection");
+    }
+
+    #[tokio::test]
+    async fn test_empty_projection() {
+        use futures::StreamExt;
+
+        let function = GenerateSeriesFunction::new();
+
+        let start_array = Int64Array::from(vec![1]);
+        let end_array = Int64Array::from(vec![3]);
+        let args: Vec<ArrayRef> = vec![Arc::new(start_array), Arc::new(end_array)];
+
+        // Pass None for projection (all columns)
+        let mut stream = function.invoke_batch(&args, None, &[], None).await.unwrap();
+
+        let chunk = stream.next().await.unwrap().unwrap();
+        assert_eq!(chunk.output.num_rows(), 3);
+        assert_eq!(chunk.output.num_columns(), 1);
+    }
+}
diff --git a/datafusion/functions-table/src/lib.rs b/datafusion/functions-table/src/lib.rs
index 1783c15b14b58..7efcfc6e558da 100644
--- a/datafusion/functions-table/src/lib.rs
+++ b/datafusion/functions-table/src/lib.rs
@@ -28,8 +28,9 @@
 #![deny(clippy::allow_attributes)]
 
 pub mod generate_series;
+pub mod generate_series_batched;
 
-use datafusion_catalog::TableFunction;
+use datafusion_catalog::{BatchedTableFunctionImpl, TableFunction};
 use std::sync::Arc;
 
 /// Returns all default table functions
@@ -37,6 +38,12 @@ pub fn all_default_table_functions() -> Vec<Arc<TableFunction>> {
     vec![generate_series(), range()]
 }
 
+/// Returns all default batched table functions
+pub fn all_default_batched_table_functions(
+) -> Vec<(&'static str, Arc<dyn BatchedTableFunctionImpl>)> {
+    vec![("batched_generate_series", generate_series_batched())]
+}
+
 /// Creates a singleton instance of a table function
 /// - `$module`: A struct implementing `TableFunctionImpl` to create the function from
 /// - `$name`: The name to give to the created function
@@ -62,3 +69,28 @@ macro_rules! create_udtf_function {
 
 create_udtf_function!(generate_series::GenerateSeriesFunc, "generate_series");
 create_udtf_function!(generate_series::RangeFunc, "range");
+
+/// Creates a singleton instance of a batched table function implementation
+/// - `$module`: A struct implementing `BatchedTableFunctionImpl` to create the function from
+/// - `$name`: The name to give to the created function
+///
+/// This is used to ensure creating the list of `BatchedTableFunctionImpl` only happens once.
+#[macro_export]
+macro_rules! create_batched_udtf_function {
+    ($module:path, $name:expr) => {
+        paste::paste! {
+            pub fn [<$name:lower>]() -> Arc<dyn datafusion_catalog::BatchedTableFunctionImpl> {
+                static INSTANCE: std::sync::LazyLock<Arc<dyn datafusion_catalog::BatchedTableFunctionImpl>> =
+                    std::sync::LazyLock::new(|| {
+                        std::sync::Arc::new($module::new())
+                    });
+                std::sync::Arc::clone(&INSTANCE)
+            }
+        }
+    };
+}
+
+create_batched_udtf_function!(
+    generate_series_batched::GenerateSeriesFunction,
+    "generate_series_batched"
+);
diff --git a/datafusion/optimizer/src/common_subexpr_eliminate.rs b/datafusion/optimizer/src/common_subexpr_eliminate.rs
index d9273a8f60fb2..10845f939122f 100644
--- a/datafusion/optimizer/src/common_subexpr_eliminate.rs
+++ b/datafusion/optimizer/src/common_subexpr_eliminate.rs
@@ -586,7 +586,9 @@ impl OptimizerRule for CommonSubexprEliminate {
             | LogicalPlan::Dml(_)
             | LogicalPlan::Copy(_)
             | LogicalPlan::Unnest(_)
-            | LogicalPlan::RecursiveQuery(_) => {
+            | LogicalPlan::RecursiveQuery(_)
+            | LogicalPlan::LateralBatchedTableFunction(_)
+            | LogicalPlan::StandaloneBatchedTableFunction(_) => {
                 // This rule handles recursion itself in a `ApplyOrder::TopDown` like
                 // manner.
                 plan.map_children(|c| self.rewrite(c, config))?
diff --git a/datafusion/optimizer/src/optimize_projections/mod.rs b/datafusion/optimizer/src/optimize_projections/mod.rs
index 81b119088c520..027f92959ecb8 100644
--- a/datafusion/optimizer/src/optimize_projections/mod.rs
+++ b/datafusion/optimizer/src/optimize_projections/mod.rs
@@ -30,8 +30,8 @@ use datafusion_common::{
 };
 use datafusion_expr::expr::Alias;
 use datafusion_expr::{
-    Aggregate, Distinct, EmptyRelation, Expr, Projection, TableScan, Unnest, Window,
-    logical_plan::LogicalPlan,
+     Aggregate, Distinct, EmptyRelation, Expr,
+    LateralBatchedTableFunction, Projection, TableScan, Unnest, Window,logical_plan::LogicalPlan,
 };
 
 use crate::optimize_projections::required_indices::RequiredIndices;
@@ -252,6 +252,96 @@ fn optimize_projections(
                 }
             });
         }
+        LogicalPlan::LateralBatchedTableFunction(ref lateral) => {
+            // Schema: [input_fields..., function_output_fields...]
+            let input_len = lateral.input.schema().fields().len();
+            let tf_len = lateral.table_function_schema.fields().len();
+
+            // Determine which input columns and function output columns are needed
+            let mut input_indices = vec![];
+            let mut tf_indices = vec![];
+
+            for &idx in indices.indices() {
+                if idx < input_len {
+                    // Input column requested
+                    input_indices.push(idx);
+                } else {
+                    // Function output column requested (idx >= input_len)
+                    tf_indices.push(idx - input_len);
+                }
+            }
+
+            let mut required_input_indices =
+                RequiredIndices::new_from_indices(input_indices);
+            required_input_indices =
+                required_input_indices.with_plan_exprs(&plan, lateral.input.schema())?;
+
+            let new_input = optimize_projections(
+                Arc::unwrap_or_clone(Arc::clone(&lateral.input)),
+                config,
+                required_input_indices.with_projection_beneficial(),
+            )?;
+
+            return new_input.transform_data(|optimized_input| {
+                // Determine table function projection
+                let tf_projection = if tf_indices.is_empty() {
+                    // No function output columns needed
+                    None
+                } else if tf_indices.len() == tf_len {
+                    // All function output columns needed
+                    lateral.projection.clone()
+                } else {
+                    // Subset of function output columns needed
+                    Some(match &lateral.projection {
+                        Some(existing_proj) => {
+                            // Chain projections: map through existing projection
+                            tf_indices.iter().map(|&idx| existing_proj[idx]).collect()
+                        }
+                        None => tf_indices,
+                    })
+                };
+
+                // Update table function schema if projection changed
+                let new_tf_schema = if let Some(proj) = &tf_projection {
+                    let qualified_fields: Vec<_> = proj
+                        .iter()
+                        .map(|&i| {
+                            let (qualifier, field) = lateral.table_function_schema.qualified_field(i);
+                            (qualifier.cloned(), Arc::new(field.clone()))
+                        })
+                        .collect();
+                    Arc::new(DFSchema::new_with_metadata(
+                        qualified_fields,
+                        lateral.table_function_schema.metadata().clone(),
+                    )?)
+                } else {
+                    Arc::clone(&lateral.table_function_schema)
+                };
+
+                // Rebuild combined schema
+                let new_schema = Arc::new(
+                    optimized_input
+                        .schema()
+                        .as_ref()
+                        .join(new_tf_schema.as_ref())?,
+                );
+
+                let new_lateral = LateralBatchedTableFunction {
+                    input: Arc::new(optimized_input),
+                    function_name: lateral.function_name.clone(),
+                    source: Arc::clone(&lateral.source),
+                    args: lateral.args.clone(),
+                    schema: new_schema,
+                    table_function_schema: new_tf_schema,
+                    projection: tf_projection,
+                    filters: lateral.filters.clone(),
+                    fetch: lateral.fetch,
+                };
+                Ok(Transformed::yes(LogicalPlan::LateralBatchedTableFunction(
+                    new_lateral,
+                )))
+            });
+        }
         LogicalPlan::TableScan(table_scan) => {
             let TableScan {
                 table_name,
@@ -358,6 +448,20 @@ fn optimize_projections(
                 })
                 .collect::<Result<Vec<_>>>()?
         }
+        LogicalPlan::StandaloneBatchedTableFunction(scan) => {
+            // Get indices referred to in the original (schema with all fields)
+            // given projected indices.
+            let new_projection = match &scan.projection {
+                Some(projection) => indices.into_mapped_indices(|idx| projection[idx]),
+                None => indices.into_inner(),
+            };
+
+            let mut new_scan = scan.clone();
+            new_scan.projection = Some(new_projection);
+            return Ok(Transformed::yes(LogicalPlan::StandaloneBatchedTableFunction(
+                new_scan,
+            )));
+        }
         LogicalPlan::EmptyRelation(_)
         | LogicalPlan::Values(_)
         | LogicalPlan::DescribeTable(_) => {
@@ -407,7 +511,8 @@ fn optimize_projections(
         LogicalPlan::Projection(_)
         | LogicalPlan::Aggregate(_)
         | LogicalPlan::Window(_)
-        | LogicalPlan::TableScan(_) => {
+        | LogicalPlan::TableScan(_)
+        | LogicalPlan::LateralBatchedTableFunction(_) => {
             return internal_err!(
                 "OptimizeProjection: should have handled in the match statement above"
             );
diff --git a/datafusion/optimizer/src/push_down_filter.rs b/datafusion/optimizer/src/push_down_filter.rs
index 552d6ae2da6c2..7f498c9e79798 100644
--- a/datafusion/optimizer/src/push_down_filter.rs
+++ b/datafusion/optimizer/src/push_down_filter.rs
@@ -1182,6 +1182,161 @@ impl OptimizerRule for PushDownFilter {
                     }
                 })
             }
+            LogicalPlan::StandaloneBatchedTableFunction(mut scan) => {
+                let filter_predicates = split_conjunction(&filter.predicate);
+
+                let (volatile_filters, non_volatile_filters): (Vec<&Expr>, Vec<&Expr>) =
+                    filter_predicates
+                        .into_iter()
+                        .partition(|pred| pred.is_volatile());
+
+                // Check which non-volatile filters are supported by source
+                let supported_filters = scan
+                    .source
+                    .supports_filters_pushdown(non_volatile_filters.as_slice())?;
+                if non_volatile_filters.len() != supported_filters.len() {
+                    return internal_err!(
+                        "Vec returned length: {} from supports_filters_pushdown is not the same size as the filters passed, which length is: {}",
+                        supported_filters.len(),
+                        non_volatile_filters.len());
+                }
+
+                // Compose scan filters from non-volatile filters of `Exact` or `Inexact` pushdown type
+                let zip = non_volatile_filters.into_iter().zip(supported_filters);
+
+                let new_scan_filters = zip
+                    .clone()
+                    .filter(|(_, res)| res != &TableProviderFilterPushDown::Unsupported)
+                    .map(|(pred, _)| pred);
+
+                // Add new scan filters
+                let new_scan_filters: Vec<Expr> = scan
+                    .filters
+                    .iter()
+                    .chain(new_scan_filters)
+                    .unique()
+                    .cloned()
+                    .collect();
+
+                scan.filters = new_scan_filters;
+
+                // Compose predicates to be of `Unsupported` or `Inexact` pushdown type, and also include volatile filters
+                let new_predicate: Vec<Expr> = zip
+                    .filter(|(_, res)| res != &TableProviderFilterPushDown::Exact)
+                    .map(|(pred, _)| pred)
+                    .chain(volatile_filters)
+                    .cloned()
+                    .collect();
+
+                let new_scan = LogicalPlan::StandaloneBatchedTableFunction(scan);
+
+                Transformed::yes(new_scan).transform_data(|new_scan| {
+                    if let Some(predicate) = conjunction(new_predicate) {
+                        make_filter(predicate, Arc::new(new_scan)).map(Transformed::yes)
+                    } else {
+                        Ok(Transformed::no(new_scan))
+                    }
+                })
+            }
+            LogicalPlan::LateralBatchedTableFunction(mut lateral) => {
+                // Schema: [input_fields..., function_output_fields...]
+                // Split filters into:
+                // 1. Filters that only reference table function output -> can push down
+                // 2. Filters that reference input columns -> must stay above
+                let input_len = lateral.input.schema().fields().len();
+                let filter_predicates = split_conjunction(&filter.predicate);
+
+                let (volatile_filters, non_volatile_filters): (Vec<&Expr>, Vec<&Expr>) =
+                    filter_predicates
+                        .into_iter()
+                        .partition(|pred| pred.is_volatile());
+
+                // Further split non-volatile filters by column references
+                let mut pushdown_candidates = vec![];
+                let mut above_filters = vec![];
+
+                for pred in non_volatile_filters {
+                    let mut references_input = false;
+                    let mut references_tf_output = false;
+
+                    // Check which columns this predicate references
+                    pred.apply(|expr| {
+                        if let Expr::Column(col) = expr {
+                            // Find the index of this column in the combined schema
+                            if let Ok(idx) = lateral.schema.index_of_column(col) {
+                                if idx < input_len {
+                                    references_input = true;
+                                } else {
+                                    references_tf_output = true;
+                                }
+                            }
+                        }
+                        Ok(TreeNodeRecursion::Continue)
+                    })?;
+
+                    if references_tf_output && !references_input {
+                        // Only references table function output - candidate for pushdown
+                        pushdown_candidates.push(pred);
+                    } else {
+                        // References input columns or both - keep above
+                        above_filters.push(pred.clone());
+                    }
+                }
+
+                // Check which pushdown candidates are supported by the source
+                let supported_filters = lateral
+                    .source
+                    .supports_filters_pushdown(pushdown_candidates.as_slice())?;
+                if pushdown_candidates.len() != supported_filters.len() {
+                    return internal_err!(
+                        "Vec returned length: {} from supports_filters_pushdown is not the same size as the filters passed, which length is: {}",
+                        supported_filters.len(),
+                        pushdown_candidates.len());
+                }
+
+                // Compose lateral filters from candidates of `Exact` or `Inexact` pushdown type
+                let zip = pushdown_candidates.into_iter().zip(supported_filters);
+
+                let new_lateral_filters = zip
+                    .clone()
+                    .filter(|(_, res)| res != &TableProviderFilterPushDown::Unsupported)
+                    .map(|(pred, _)| pred);
+
+                // Add new lateral filters
+                let new_lateral_filters: Vec<Expr> = lateral
+                    .filters
+                    .iter()
+                    .chain(new_lateral_filters)
+                    .unique()
+                    .cloned()
+                    .collect();
+
+                lateral.filters = new_lateral_filters;
+
+                // Combine: volatile filters + above filters + unsupported/inexact pushdown filters
+                let pushdown_above: Vec<Expr> = zip
+                    .filter(|(_, res)| res != &TableProviderFilterPushDown::Exact)
+                    .map(|(pred, _)| pred)
+                    .cloned()
+                    .collect();
+
+                let remaining_predicates: Vec<Expr> = volatile_filters
+                    .into_iter()
+                    .chain(above_filters.iter())
+                    .chain(pushdown_above.iter())
+                    .cloned()
+                    .collect();
+
+                let new_lateral = LogicalPlan::LateralBatchedTableFunction(lateral);
+
+                Transformed::yes(new_lateral).transform_data(|new_lateral| {
+                    if let Some(predicate) = conjunction(remaining_predicates) {
+                        make_filter(predicate, Arc::new(new_lateral)).map(Transformed::yes)
+                    } else {
+                        Ok(Transformed::no(new_lateral))
+                    }
+                })
+            }
             LogicalPlan::Extension(extension_plan) => {
                 // This check prevents the Filter from being removed when the extension node has no children,
                 // so we return the original Filter unchanged.
diff --git a/datafusion/optimizer/src/push_down_limit.rs b/datafusion/optimizer/src/push_down_limit.rs
index dbd8e18929495..0d5a91852f2c5 100644
--- a/datafusion/optimizer/src/push_down_limit.rs
+++ b/datafusion/optimizer/src/push_down_limit.rs
@@ -154,6 +154,40 @@ impl OptimizerRule for PushDownLimit {
                 subquery_alias.input = Arc::new(new_limit);
                 Ok(Transformed::yes(LogicalPlan::SubqueryAlias(subquery_alias)))
             }
+            LogicalPlan::StandaloneBatchedTableFunction(mut scan) => {
+                let rows_needed = if fetch != 0 { fetch + skip } else { 0 };
+                let new_fetch = scan
+                    .fetch
+                    .map(|x| min(x, rows_needed))
+                    .or(Some(rows_needed));
+                if new_fetch == scan.fetch {
+                    original_limit(skip, fetch, LogicalPlan::StandaloneBatchedTableFunction(scan))
+                } else {
+                    // push limit into the table function scan itself
+                    scan.fetch = scan
+                        .fetch
+                        .map(|x| min(x, rows_needed))
+                        .or(Some(rows_needed));
+                    transformed_limit(skip, fetch, LogicalPlan::StandaloneBatchedTableFunction(scan))
+                }
+            }
+            LogicalPlan::LateralBatchedTableFunction(mut lateral) => {
+                let rows_needed = if fetch != 0 { fetch + skip } else { 0 };
+                let new_fetch = lateral
+                    .fetch
+                    .map(|x| min(x, rows_needed))
+                    .or(Some(rows_needed));
+                if new_fetch == lateral.fetch {
+                    original_limit(skip, fetch, LogicalPlan::LateralBatchedTableFunction(lateral))
+                } else {
+                    // push limit into the lateral table function itself
+                    lateral.fetch = lateral
+                        .fetch
+                        .map(|x| min(x, rows_needed))
+                        .or(Some(rows_needed));
+                    transformed_limit(skip, fetch, LogicalPlan::LateralBatchedTableFunction(lateral))
+                }
+            }
             LogicalPlan::Extension(extension_plan)
                 if extension_plan.node.supports_limit_pushdown() =>
             {
diff --git a/datafusion/physical-plan/src/placeholder_row.rs b/datafusion/physical-plan/src/placeholder_row.rs
index 4d00b73cff39c..c1bee0327b2fc 100644
--- a/datafusion/physical-plan/src/placeholder_row.rs
+++ b/datafusion/physical-plan/src/placeholder_row.rs
@@ -113,11 +113,15 @@ impl DisplayAs for PlaceholderRowExec {
         f: &mut std::fmt::Formatter,
     ) -> std::fmt::Result {
         match t {
-            DisplayFormatType::Default | DisplayFormatType::Verbose => {
-                write!(f, "PlaceholderRowExec")
+            DisplayFormatType::Default => {
+                write!(f, "PlaceholderRowExec: produces single row")
+            }
+            DisplayFormatType::Verbose => {
+                write!(f, "PlaceholderRowExec: produces single row")
+            }
+            DisplayFormatType::TreeRender => {
+                write!(f, "produces single row")
             }
-
-            DisplayFormatType::TreeRender => Ok(()),
         }
     }
 }
diff --git a/datafusion/physical-plan/src/sorts/merge.rs b/datafusion/physical-plan/src/sorts/merge.rs
index 272816251daf9..8bce574dcd5b5 100644
--- a/datafusion/physical-plan/src/sorts/merge.rs
+++ b/datafusion/physical-plan/src/sorts/merge.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-//! Merge that deals with an arbitrary size of streaming inputs.
+//! Merge that deals with an arbitrary size of batched inputs.
 //! This is an order-preserving merge.
 
 use std::pin::Pin;
diff --git a/datafusion/physical-plan/src/sorts/streaming_merge.rs b/datafusion/physical-plan/src/sorts/streaming_merge.rs
index 8129c3d8f695d..356bbd98a6669 100644
--- a/datafusion/physical-plan/src/sorts/streaming_merge.rs
+++ b/datafusion/physical-plan/src/sorts/streaming_merge.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-//! Merge that deals with an arbitrary size of streaming inputs.
+//! Merge that deals with an arbitrary size of batched inputs.
 //! This is an order-preserving merge.
 
 use crate::metrics::BaselineMetrics;
diff --git a/datafusion/proto/src/logical_plan/mod.rs b/datafusion/proto/src/logical_plan/mod.rs
index c42f4d15f29b0..02af664ed431b 100644
--- a/datafusion/proto/src/logical_plan/mod.rs
+++ b/datafusion/proto/src/logical_plan/mod.rs
@@ -1784,6 +1784,12 @@ impl AsLogicalPlan for LogicalPlanNode {
             LogicalPlan::DescribeTable(_) => Err(proto_error(
                 "LogicalPlan serde is not yet implemented for DescribeTable",
             )),
+            LogicalPlan::LateralBatchedTableFunction(_) => Err(proto_error(
+                "LogicalPlan serde is not yet implemented for LateralBatchedTableFunction",
+            )),
+            LogicalPlan::StandaloneBatchedTableFunction(_) => Err(proto_error(
+                "LogicalPlan serde is not yet implemented for StandaloneBatchedTableFunction",
+            )),
             LogicalPlan::RecursiveQuery(recursive) => {
                 let static_term = LogicalPlanNode::try_from_logical_plan(
                     recursive.static_term.as_ref(),
diff --git a/datafusion/sql/src/relation/join.rs b/datafusion/sql/src/relation/join.rs
index 8e1a8817309f0..971e099737327 100644
--- a/datafusion/sql/src/relation/join.rs
+++ b/datafusion/sql/src/relation/join.rs
@@ -17,11 +17,14 @@
 
 use crate::planner::{ContextProvider, PlannerContext, SqlToRel};
 use datafusion_common::{Column, Result, not_impl_err, plan_datafusion_err};
-use datafusion_expr::{JoinType, LogicalPlan, LogicalPlanBuilder};
+use datafusion_expr::{
+    ExprSchemable, JoinType, LateralBatchedTableFunction, LogicalPlan, LogicalPlanBuilder,
+};
 use sqlparser::ast::{
     Join, JoinConstraint, JoinOperator, ObjectName, TableFactor, TableWithJoins,
 };
 use std::collections::HashSet;
+use std::sync::Arc;
 
 impl<S: ContextProvider> SqlToRel<'_, S> {
     pub(crate) fn plan_table_with_joins(
@@ -49,12 +52,56 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
         join: Join,
         planner_context: &mut PlannerContext,
     ) -> Result<LogicalPlan> {
-        let right = if is_lateral_join(&join)? {
-            self.create_relation_subquery(join.relation, planner_context)?
+        let is_lateral = is_lateral_join(&join)?;
+
+        if is_lateral {
+            if let Some(lateral_tf_plan) = self.try_create_lateral_table_function(
+                &left,
+                &join.relation,
+                planner_context,
+            )? {
+                // LateralBatchedTableFunction already combines input + function output
+                match &join.join_operator {
+                    JoinOperator::CrossJoin(_) | JoinOperator::CrossApply => {
+                        return Ok(lateral_tf_plan);
+                    }
+                    _ => {
+                        // For other join types, use lateral function as right side
+                        let right = lateral_tf_plan;
+                        return self.finish_join(
+                            left,
+                            right,
+                            join.join_operator,
+                            planner_context,
+                        );
+                    }
+                }
+            }
+        }
+
+        let Join {
+            relation,
+            join_operator,
+            ..
+        } = join;
+
+        let right = if is_lateral {
+            self.create_relation_subquery(relation, planner_context)?
         } else {
-            self.create_relation(join.relation, planner_context)?
+            self.create_relation(relation, planner_context)?
         };
-        match join.join_operator {
+
+        self.finish_join(left, right, join_operator, planner_context)
+    }
+
+    fn finish_join(
+        &self,
+        left: LogicalPlan,
+        right: LogicalPlan,
+        join_operator: JoinOperator,
+        planner_context: &mut PlannerContext,
+    ) -> Result<LogicalPlan> {
+        match join_operator {
             JoinOperator::LeftOuter(constraint) | JoinOperator::Left(constraint) => {
                 self.parse_join(left, right, constraint, JoinType::Left, planner_context)
             }
@@ -177,6 +224,94 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
                 .build(),
         }
     }
+
+    /// Try to create a LateralBatchedTableFunction node if this is a lateral batched table function
+    fn try_create_lateral_table_function(
+        &self,
+        input_plan: &LogicalPlan,
+        relation: &TableFactor,
+        planner_context: &mut PlannerContext,
+    ) -> Result<Option<LogicalPlan>> {
+        let TableFactor::Function {
+            name, args, alias, ..
+        } = relation
+        else {
+            return Ok(None);
+        };
+
+        let tbl_func_ref = self.object_name_to_table_reference(name.clone())?;
+        let func_name = tbl_func_ref.table();
+
+        if !self.context_provider.is_batched_table_function(func_name) {
+            return Ok(None);
+        }
+
+        let input_schema = input_plan.schema();
+
+        let func_args: Vec<datafusion_expr::Expr> = args
+            .iter()
+            .map(|arg| match arg {
+                sqlparser::ast::FunctionArg::Unnamed(
+                    sqlparser::ast::FunctionArgExpr::Expr(expr),
+                )
+                | sqlparser::ast::FunctionArg::Named {
+                    arg: sqlparser::ast::FunctionArgExpr::Expr(expr),
+                    ..
+                } => self.sql_expr_to_logical_expr(
+                    expr.clone(),
+                    input_schema.as_ref(),
+                    planner_context,
+                ),
+                _ => plan_err!("Unsupported function argument: {arg:?}"),
+            })
+            .collect::<Result<Vec<_>>>()?;
+
+        let arg_types: Vec<arrow::datatypes::DataType> = func_args
+            .iter()
+            .map(|e| e.get_type(input_schema.as_ref()))
+            .collect::<Result<Vec<_>>>()?;
+
+        let source = self
+            .context_provider
+            .get_batched_table_function_source(func_name, &arg_types)?
+            .ok_or_else(|| {
+                datafusion_common::plan_datafusion_err!(
+                    "Failed to get source for batched table function '{}'",
+                    func_name
+                )
+            })?;
+
+        // Get schema from source
+        let func_schema = source.schema();
+
+        // Use alias if provided, otherwise "function_name()"
+        let qualifier = alias
+            .as_ref()
+            .map(|a| self.ident_normalizer.normalize(a.name.clone()))
+            .unwrap_or_else(|| format!("{func_name}()"));
+
+        let qualified_func_schema =
+            datafusion_common::DFSchema::try_from_qualified_schema(
+                &qualifier,
+                &func_schema,
+            )?;
+
+        let combined_schema = input_schema.as_ref().join(&qualified_func_schema)?;
+
+        let lateral_plan = LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction {
+            input: Arc::new(input_plan.clone()),
+            function_name: func_name.to_string(),
+            source,
+            args: func_args,
+            schema: Arc::new(combined_schema),
+            table_function_schema: Arc::new(qualified_func_schema),
+            projection: None,
+            filters: vec![],
+            fetch: None,
+        });
+
+        Ok(Some(lateral_plan))
+    }
 }
 
 /// Return `true` iff the given [`TableFactor`] is lateral.
diff --git a/datafusion/sql/src/relation/mod.rs b/datafusion/sql/src/relation/mod.rs
index 3115d8dfffbd2..4222b108abd35 100644
--- a/datafusion/sql/src/relation/mod.rs
+++ b/datafusion/sql/src/relation/mod.rs
@@ -21,13 +21,17 @@ use crate::planner::{ContextProvider, PlannerContext, SqlToRel};
 
 use datafusion_common::tree_node::{Transformed, TreeNode};
 use datafusion_common::{
-    DFSchema, Diagnostic, Result, Span, Spans, TableReference, not_impl_err, plan_err,
+    not_impl_err, plan_datafusion_err, plan_err, DFSchema, Diagnostic, Result, Span,
+    Spans, TableReference,
 };
 use datafusion_expr::builder::subquery_alias;
 use datafusion_expr::planner::{
     PlannedRelation, RelationPlannerContext, RelationPlanning,
 };
-use datafusion_expr::{Expr, LogicalPlan, LogicalPlanBuilder, expr::Unnest};
+use datafusion_expr::{
+    expr::Unnest, Expr, ExprSchemable, LogicalPlan, LogicalPlanBuilder,
+    StandaloneBatchedTableFunction,
+};
 use datafusion_expr::{Subquery, SubqueryAlias};
 use sqlparser::ast::{FunctionArg, FunctionArgExpr, Spanned, TableFactor};
 
@@ -148,34 +152,111 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
                 if let Some(func_args) = args {
                     let tbl_func_name =
                         name.0.first().unwrap().as_ident().unwrap().to_string();
-                    let args = func_args
-                        .args
-                        .into_iter()
-                        .flat_map(|arg| {
-                            if let FunctionArg::Unnamed(FunctionArgExpr::Expr(expr)) = arg
-                            {
-                                self.sql_expr_to_logical_expr(
+
+                    if self
+                        .context_provider
+                        .is_batched_table_function(&tbl_func_name)
+                    {
+                        // Standalone batched table function
+                        let schema = planner_context
+                            .outer_query_schema()
+                            .cloned()
+                            .unwrap_or_else(DFSchema::empty);
+
+                        let args = func_args
+                            .args
+                            .into_iter()
+                            .map(|arg| match arg {
+                                FunctionArg::Unnamed(FunctionArgExpr::Expr(expr))
+                                | FunctionArg::Named {
+                                    arg: FunctionArgExpr::Expr(expr),
+                                    ..
+                                } => self.sql_expr_to_logical_expr(
                                     expr,
-                                    &DFSchema::empty(),
+                                    &schema,
                                     planner_context,
+                                ),
+                                _ => plan_err!("Unsupported function argument: {arg:?}"),
+                            })
+                            .collect::<Result<Vec<Expr>>>()?;
+
+                        let arg_types: Vec<arrow::datatypes::DataType> = args
+                            .iter()
+                            .map(|e| e.get_type(&schema))
+                            .collect::<Result<Vec<_>>>()?;
+
+                        let source = self
+                            .context_provider
+                            .get_batched_table_function_source(&tbl_func_name, &arg_types)?
+                            .ok_or_else(|| {
+                                plan_datafusion_err!(
+                                    "Failed to get source for batched table function '{}'",
+                                    tbl_func_name
                                 )
-                            } else {
-                                plan_err!("Unsupported function argument type: {}", arg)
-                            }
-                        })
-                        .collect::<Vec<_>>();
-                    let provider = self
-                        .context_provider
-                        .get_table_function_source(&tbl_func_name, args)?;
-                    let plan = LogicalPlanBuilder::scan(
-                        TableReference::Bare {
-                            table: format!("{tbl_func_name}()").into(),
-                        },
-                        provider,
-                        None,
-                    )?
-                    .build()?;
-                    (plan, alias)
+                            })?;
+
+                        // Get schema from source
+                        let func_schema = source.schema();
+
+                        // Use alias if provided, otherwise "function_name()"
+                        let qualifier = alias
+                            .as_ref()
+                            .map(|a| self.ident_normalizer.normalize(a.name.clone()))
+                            .unwrap_or_else(|| format!("{tbl_func_name}()"));
+
+                        let qualified_func_schema = DFSchema::try_from_qualified_schema(
+                            &qualifier,
+                            &func_schema,
+                        )?;
+
+                        let plan = LogicalPlan::StandaloneBatchedTableFunction(
+                            StandaloneBatchedTableFunction {
+                                function_name: tbl_func_name.clone(),
+                                source,
+                                args,
+                                schema: Arc::new(qualified_func_schema),
+                                projection: None,
+                                filters: vec![],
+                                fetch: None,
+                            },
+                        );
+
+                        // Alias already in schema
+                        (plan, None)
+                    } else {
+                        let args = func_args
+                            .args
+                            .into_iter()
+                            .flat_map(|arg| {
+                                if let FunctionArg::Unnamed(FunctionArgExpr::Expr(expr)) =
+                                    arg
+                                {
+                                    self.sql_expr_to_logical_expr(
+                                        expr,
+                                        &DFSchema::empty(),
+                                        planner_context,
+                                    )
+                                } else {
+                                    plan_err!(
+                                        "Unsupported function argument type: {}",
+                                        arg
+                                    )
+                                }
+                            })
+                            .collect::<Vec<_>>();
+                        let provider = self
+                            .context_provider
+                            .get_table_function_source(&tbl_func_name, args)?;
+                        let plan = LogicalPlanBuilder::scan(
+                            TableReference::Bare {
+                                table: format!("{tbl_func_name}()").into(),
+                            },
+                            provider,
+                            None,
+                        )?
+                        .build()?;
+                        (plan, alias)
+                    }
                 } else {
                     // Normalize name and alias
                     let table_ref = self.object_name_to_table_reference(name)?;
@@ -261,30 +342,100 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
                 name, args, alias, ..
             } => {
                 let tbl_func_ref = self.object_name_to_table_reference(name)?;
-                let schema = planner_context
-                    .outer_query_schema()
-                    .cloned()
-                    .unwrap_or_else(DFSchema::empty);
-                let func_args = args
-                    .into_iter()
-                    .map(|arg| match arg {
-                        FunctionArg::Unnamed(FunctionArgExpr::Expr(expr))
-                        | FunctionArg::Named {
-                            arg: FunctionArgExpr::Expr(expr),
-                            ..
-                        } => {
-                            self.sql_expr_to_logical_expr(expr, &schema, planner_context)
-                        }
-                        _ => plan_err!("Unsupported function argument: {arg:?}"),
-                    })
-                    .collect::<Result<Vec<Expr>>>()?;
-                let provider = self
-                    .context_provider
-                    .get_table_function_source(tbl_func_ref.table(), func_args)?;
-                let plan =
-                    LogicalPlanBuilder::scan(tbl_func_ref.table(), provider, None)?
-                        .build()?;
-                (plan, alias)
+                let func_name = tbl_func_ref.table();
+
+                if self.context_provider.is_batched_table_function(func_name) {
+                    // Standalone batched table function
+                    let schema = planner_context
+                        .outer_query_schema()
+                        .cloned()
+                        .unwrap_or_else(DFSchema::empty);
+
+                    let func_args = args
+                        .into_iter()
+                        .map(|arg| match arg {
+                            FunctionArg::Unnamed(FunctionArgExpr::Expr(expr))
+                            | FunctionArg::Named {
+                                arg: FunctionArgExpr::Expr(expr),
+                                ..
+                            } => self.sql_expr_to_logical_expr(
+                                expr,
+                                &schema,
+                                planner_context,
+                            ),
+                            _ => plan_err!("Unsupported function argument: {arg:?}"),
+                        })
+                        .collect::<Result<Vec<Expr>>>()?;
+
+                    let arg_types: Vec<arrow::datatypes::DataType> = func_args
+                        .iter()
+                        .map(|e| e.get_type(&schema))
+                        .collect::<Result<Vec<_>>>()?;
+
+                    let source = self
+                        .context_provider
+                        .get_batched_table_function_source(func_name, &arg_types)?
+                        .ok_or_else(|| {
+                            plan_datafusion_err!(
+                                "Failed to get source for batched table function '{}'",
+                                func_name
+                            )
+                        })?;
+
+                    // Get schema from source
+                    let func_schema = source.schema();
+
+                    // Use alias if provided, otherwise "function_name()"
+                    let qualifier = alias
+                        .as_ref()
+                        .map(|a| self.ident_normalizer.normalize(a.name.clone()))
+                        .unwrap_or_else(|| format!("{func_name}()"));
+
+                    let qualified_func_schema =
+                        DFSchema::try_from_qualified_schema(&qualifier, &func_schema)?;
+
+                    let plan = LogicalPlan::StandaloneBatchedTableFunction(
+                        StandaloneBatchedTableFunction {
+                            function_name: func_name.to_string(),
+                            source,
+                            args: func_args,
+                            schema: Arc::new(qualified_func_schema),
+                            projection: None,
+                            filters: vec![],
+                            fetch: None,
+                        },
+                    );
+
+                    // Alias already in schema
+                    (plan, None)
+                } else {
+                    let schema = planner_context
+                        .outer_query_schema()
+                        .cloned()
+                        .unwrap_or_else(DFSchema::empty);
+                    let func_args = args
+                        .into_iter()
+                        .map(|arg| match arg {
+                            FunctionArg::Unnamed(FunctionArgExpr::Expr(expr))
+                            | FunctionArg::Named {
+                                arg: FunctionArgExpr::Expr(expr),
+                                ..
+                            } => self.sql_expr_to_logical_expr(
+                                expr,
+                                &schema,
+                                planner_context,
+                            ),
+                            _ => plan_err!("Unsupported function argument: {arg:?}"),
+                        })
+                        .collect::<Result<Vec<Expr>>>()?;
+                    let provider = self
+                        .context_provider
+                        .get_table_function_source(tbl_func_ref.table(), func_args)?;
+                    let plan =
+                        LogicalPlanBuilder::scan(tbl_func_ref.table(), provider, None)?
+                            .build()?;
+                    (plan, alias)
+                }
             }
             // @todo Support TableFactory::TableFunction?
             _ => {
diff --git a/datafusion/sql/src/unparser/plan.rs b/datafusion/sql/src/unparser/plan.rs
index 61a06d9c38379..a86b1b13bc759 100644
--- a/datafusion/sql/src/unparser/plan.rs
+++ b/datafusion/sql/src/unparser/plan.rs
@@ -131,7 +131,11 @@ impl Unparser<'_> {
             | LogicalPlan::Copy(_)
             | LogicalPlan::DescribeTable(_)
             | LogicalPlan::RecursiveQuery(_)
-            | LogicalPlan::Unnest(_) => not_impl_err!("Unsupported plan: {plan:?}"),
+            | LogicalPlan::Unnest(_)
+            | LogicalPlan::LateralBatchedTableFunction(_)
+            | LogicalPlan::StandaloneBatchedTableFunction(_) => {
+                not_impl_err!("Unsupported plan: {plan:?}")
+            }
         }
     }
 
diff --git a/datafusion/sqllogictest/src/test_context.rs b/datafusion/sqllogictest/src/test_context.rs
index b499401e5589c..b06dd6c92589d 100644
--- a/datafusion/sqllogictest/src/test_context.rs
+++ b/datafusion/sqllogictest/src/test_context.rs
@@ -23,14 +23,15 @@ use std::path::Path;
 use std::sync::Arc;
 
 use arrow::array::{
-    Array, ArrayRef, BinaryArray, Float64Array, Int32Array, LargeBinaryArray,
+    Array, ArrayRef, AsArray, BinaryArray, Float64Array, Int32Array, LargeBinaryArray,
     LargeStringArray, StringArray, TimestampNanosecondArray, UnionArray,
 };
 use arrow::buffer::ScalarBuffer;
 use arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit, UnionFields};
 use arrow::record_batch::RecordBatch;
 use datafusion::catalog::{
-    CatalogProvider, MemoryCatalogProvider, MemorySchemaProvider, Session,
+    batched_function::helpers::materialized_batch_stream, CatalogProvider,
+    MemoryCatalogProvider, MemorySchemaProvider, Session, BatchedTableFunctionImpl,
 };
 use datafusion::common::{not_impl_err, DataFusionError, Result};
 use datafusion::functions::math::abs;
@@ -143,6 +144,10 @@ impl TestContext {
                 info!("Registering dummy async udf");
                 register_async_abs_udf(test_ctx.session_ctx())
             }
+            "lateral.slt" => {
+                info!("Registering test batched table function for LATERAL tests");
+                register_test_batched_table_function(test_ctx.session_ctx())
+            }
             _ => {
                 info!("Using default SessionContext");
             }
@@ -513,3 +518,156 @@ fn register_async_abs_udf(ctx: &SessionContext) {
     let udf = AsyncScalarUDF::new(Arc::new(async_abs));
     ctx.register_udf(udf.into_scalar_udf());
 }
+
+fn register_test_batched_table_function(ctx: &SessionContext) {
+    /// Batched table function that generates a series of integers
+    /// batched_generate_series(start, stop) generates rows from start to stop (inclusive)
+    #[derive(Debug)]
+    struct BatchedGenerateSeriesFn {
+        signature: Signature,
+    }
+
+    impl BatchedGenerateSeriesFn {
+        fn new() -> Self {
+            Self {
+                signature: Signature::exact(
+                    vec![DataType::Int32, DataType::Int32],
+                    Volatility::Immutable,
+                ),
+            }
+        }
+    }
+
+    #[async_trait::async_trait]
+    impl BatchedTableFunctionImpl for BatchedGenerateSeriesFn {
+        fn name(&self) -> &str {
+            "batched_generate_series"
+        }
+
+        fn signature(&self) -> &Signature {
+            &self.signature
+        }
+
+        fn return_type(&self, _arg_types: &[DataType]) -> Result<Schema> {
+            Ok(Schema::new(vec![Field::new("n", DataType::Int32, false)]))
+        }
+
+        fn supports_filters_pushdown(
+            &self,
+            filters: &[&datafusion::prelude::Expr],
+        ) -> Result<Vec<datafusion::logical_expr::TableProviderFilterPushDown>> {
+            // This test function supports all filters
+            Ok(vec![
+                datafusion::logical_expr::TableProviderFilterPushDown::Exact;
+                filters.len()
+            ])
+        }
+
+        async fn invoke_batch(
+            &self,
+            args: &[ArrayRef],
+            _projection: Option<&[usize]>,
+            filters: &[datafusion::prelude::Expr],
+            _limit: Option<usize>,
+        ) -> Result<datafusion::catalog::BatchResultStream> {
+            if args.len() != 2 {
+                return Err(DataFusionError::Internal(
+                    "Expected exactly 2 arguments (start, stop)".to_string(),
+                ));
+            }
+
+            // Support both Int32 and Int64
+            let start_array = args[0].as_ref();
+            let stop_array = args[1].as_ref();
+
+            if start_array.len() != stop_array.len() {
+                return Err(DataFusionError::Internal(
+                    "start and stop arrays must have same length".to_string(),
+                ));
+            }
+
+            // Extract i32 from Int32 or Int64
+            let get_i32 = |arr: &dyn Array, idx: usize| -> Result<i32> {
+                if arr.is_null(idx) {
+                    return Err(DataFusionError::Internal(
+                        "NULL values not supported".to_string(),
+                    ));
+                }
+
+                match arr.data_type() {
+                    DataType::Int32 => {
+                        Ok(arr.as_primitive::<arrow::datatypes::Int32Type>().value(idx))
+                    }
+                    DataType::Int64 => Ok(arr
+                        .as_primitive::<arrow::datatypes::Int64Type>()
+                        .value(idx) as i32),
+                    dt => Err(DataFusionError::Internal(format!(
+                        "Expected Int32/Int64, got {:?}",
+                        dt
+                    ))),
+                }
+            };
+
+            let mut output_values = Vec::new();
+            let mut input_row_indices = Vec::new();
+
+            for row_idx in 0..start_array.len() {
+                let start = get_i32(start_array, row_idx)?;
+                let stop = get_i32(stop_array, row_idx)?;
+
+                // Inclusive range - apply filters during generation for efficiency
+                for n in start..=stop {
+                    // Simple filter evaluation for test purposes
+                    // Only handles basic comparison filters on 'n' column
+                    let passes_filters = filters.is_empty() || filters.iter().all(|filter| {
+                        // This is a simplified filter evaluator for testing
+                        // In a real implementation, you'd use DataFusion's expression evaluator
+                        match filter {
+                            datafusion::prelude::Expr::BinaryExpr(binary) => {
+                                use datafusion::logical_expr::Operator;
+                                if let datafusion::prelude::Expr::Column(col) = &*binary.left {
+                                    if col.name == "n" {
+                                        if let datafusion::prelude::Expr::Literal(val, _) = &*binary.right {
+                                            if let datafusion::common::ScalarValue::Int32(Some(val)) = val {
+                                                return match binary.op {
+                                                    Operator::Gt => n > *val,
+                                                    Operator::Lt => n < *val,
+                                                    Operator::GtEq => n >= *val,
+                                                    Operator::LtEq => n <= *val,
+                                                    Operator::Eq => n == *val,
+                                                    Operator::NotEq => n != *val,
+                                                    _ => true, // Unsupported operators pass through
+                                                };
+                                            }
+                                        }
+                                    }
+                                }
+                                true // Unknown filters pass through (should be handled by FilterExec)
+                            }
+                            _ => true, // Unknown filter types pass through
+                        }
+                    });
+
+                    if passes_filters {
+                        output_values.push(n);
+                        input_row_indices.push(row_idx as u32);
+                    }
+                }
+            }
+
+            let schema = self.return_type(&[DataType::Int32, DataType::Int32])?;
+            let output_array = Int32Array::from(output_values);
+            let output_batch =
+                RecordBatch::try_new(Arc::new(schema), vec![Arc::new(output_array)])?;
+
+            Ok(materialized_batch_stream(output_batch, input_row_indices))
+        }
+    }
+
+    let state_ref = ctx.state_ref();
+    let mut state = state_ref.write();
+    state.register_batched_table_function(
+        "batched_generate_series",
+        Arc::new(BatchedGenerateSeriesFn::new()),
+    );
+}
diff --git a/datafusion/sqllogictest/test_files/lateral.slt b/datafusion/sqllogictest/test_files/lateral.slt
new file mode 100644
index 0000000000000..444ab4893484d
--- /dev/null
+++ b/datafusion/sqllogictest/test_files/lateral.slt
@@ -0,0 +1,661 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+##########
+## LATERAL Table Functions Tests
+## Tests for LATERAL keyword with batched table functions
+##########
+
+statement ok
+CREATE TABLE ranges (start_val INT, end_val INT);
+
+statement ok
+INSERT INTO ranges VALUES (1, 3), (5, 7), (10, 12);
+
+# The 'batched_generate_series' function is registered in the test runner.
+# It generates integers from start to stop (inclusive).
+
+##########
+## Standalone: Direct table function calls with constants
+##########
+
+query I
+SELECT * FROM batched_generate_series(1, 5)
+ORDER BY n
+----
+1
+2
+3
+4
+5
+
+query I
+SELECT COUNT(*) FROM batched_generate_series(1, 100)
+----
+100
+
+query II
+SELECT a.n, b.n
+FROM batched_generate_series(1, 2) a
+CROSS JOIN batched_generate_series(3, 4) b
+ORDER BY a.n, b.n
+----
+1 3
+1 4
+2 3
+2 4
+
+query I
+SELECT series.n FROM batched_generate_series(5, 7) AS series
+ORDER BY n
+----
+5
+6
+7
+
+##########
+## Basic LATERAL functionality
+##########
+
+query II
+SELECT ranges.start_val, n
+FROM ranges
+CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+ORDER BY ranges.start_val, n
+----
+1 1
+1 2
+1 3
+5 5
+5 6
+5 7
+10 10
+10 11
+10 12
+
+query III
+SELECT *
+FROM ranges
+CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+ORDER BY start_val, end_val, n
+----
+1 3 1
+1 3 2
+1 3 3
+5 7 5
+5 7 6
+5 7 7
+10 12 10
+10 12 11
+10 12 12
+
+query I
+SELECT n
+FROM ranges
+CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+ORDER BY n
+----
+1
+2
+3
+5
+6
+7
+10
+11
+12
+
+query II
+SELECT start_val, end_val
+FROM ranges
+CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+ORDER BY start_val, n
+----
+1 3
+1 3
+1 3
+5 7
+5 7
+5 7
+10 12
+10 12
+10 12
+
+##########
+## LATERAL with filters
+##########
+
+query II
+SELECT ranges.start_val, n
+FROM ranges
+CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+WHERE start_val < 10
+ORDER BY start_val, n
+----
+1 1
+1 2
+1 3
+5 5
+5 6
+5 7
+
+query II
+SELECT ranges.start_val, n
+FROM ranges
+CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+WHERE n > 6
+ORDER BY start_val, n
+----
+5 7
+10 10
+10 11
+10 12
+
+query II
+SELECT ranges.start_val, n
+FROM ranges
+CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+WHERE start_val >= 5 AND n <= 7
+ORDER BY start_val, n
+----
+5 5
+5 6
+5 7
+
+##########
+## LATERAL with aggregation
+##########
+
+query I
+SELECT COUNT(*)
+FROM ranges
+CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+----
+9
+
+query I
+SELECT SUM(n)
+FROM ranges
+CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+----
+57
+
+query II
+SELECT start_val, COUNT(*)
+FROM ranges
+CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+GROUP BY start_val
+ORDER BY start_val
+----
+1 3
+5 3
+10 3
+
+query II
+SELECT start_val, SUM(n)
+FROM ranges
+CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+GROUP BY start_val
+ORDER BY start_val
+----
+1 6
+5 18
+10 33
+
+query RII
+SELECT AVG(n), MAX(n), COUNT(n)
+FROM ranges
+CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+----
+6.333333333333 12 9
+
+##########
+## LATERAL with JOIN operations
+##########
+
+statement ok
+CREATE TABLE multipliers (mult INT);
+
+statement ok
+INSERT INTO multipliers VALUES (2), (3);
+
+query IIII
+SELECT ranges.start_val, ranges.end_val, multipliers.mult, n
+FROM ranges
+CROSS JOIN multipliers
+CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+WHERE multipliers.mult = 2 AND start_val < 10
+ORDER BY start_val, n
+----
+1 3 2 1
+1 3 2 2
+1 3 2 3
+5 7 2 5
+5 7 2 6
+5 7 2 7
+
+##########
+## LATERAL with subqueries
+##########
+
+query II
+SELECT sub.start_val, n
+FROM (SELECT start_val, end_val FROM ranges WHERE start_val < 10) sub
+CROSS JOIN LATERAL batched_generate_series(sub.start_val, sub.end_val)
+ORDER BY sub.start_val, n
+----
+1 1
+1 2
+1 3
+5 5
+5 6
+5 7
+
+##########
+## LATERAL with ORDER BY and LIMIT
+##########
+
+query II
+SELECT ranges.start_val, n
+FROM ranges
+CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+ORDER BY start_val DESC, n DESC
+LIMIT 5
+----
+10 12
+10 11
+10 10
+5 7
+5 6
+
+query II
+SELECT ranges.start_val, n
+FROM ranges
+CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+ORDER BY n ASC
+LIMIT 5
+----
+1 1
+1 2
+1 3
+5 5
+5 6
+
+##########
+## LATERAL with aliases
+##########
+
+query II
+SELECT r.start_val, s.n
+FROM ranges r
+CROSS JOIN LATERAL batched_generate_series(r.start_val, r.end_val) AS s
+ORDER BY r.start_val, s.n
+----
+1 1
+1 2
+1 3
+5 5
+5 6
+5 7
+10 10
+10 11
+10 12
+
+##########
+## LATERAL with DISTINCT
+##########
+
+query I
+SELECT DISTINCT n
+FROM ranges
+CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+ORDER BY n
+----
+1
+2
+3
+5
+6
+7
+10
+11
+12
+
+##########
+## LATERAL with HAVING
+##########
+
+query II
+SELECT start_val, COUNT(*) as row_count
+FROM ranges
+CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+GROUP BY start_val
+HAVING COUNT(*) = 3
+ORDER BY start_val
+----
+1 3
+5 3
+10 3
+
+##########
+## LATERAL with EXPLAIN
+##########
+
+statement ok
+EXPLAIN SELECT * FROM ranges CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+
+##########
+## Edge cases
+##########
+
+statement ok
+CREATE TABLE single_range (val INT);
+
+statement ok
+INSERT INTO single_range VALUES (5);
+
+query II
+SELECT val, n
+FROM single_range
+CROSS JOIN LATERAL batched_generate_series(single_range.val, single_range.val)
+----
+5 5
+
+query II
+SELECT start_val, n
+FROM ranges
+CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+WHERE n > 100
+----
+
+##########
+## Projection Pushdown Tests
+##########
+
+# Test that projection pushdown works with batched table functions
+# The optimizer should push down the projection to only select the 'n' column
+
+query I
+SELECT n FROM batched_generate_series(1, 5)
+ORDER BY n
+----
+1
+2
+3
+4
+5
+
+# Test projection pushdown with LIMIT
+query I
+SELECT n FROM batched_generate_series(1, 100) LIMIT 3
+----
+1
+2
+3
+
+# Test projection pushdown with LATERAL
+query I
+SELECT n
+FROM ranges
+CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
+ORDER BY n
+LIMIT 5
+----
+1
+2
+3
+5
+6
+
+##########
+## Filter Pushdown Tests
+##########
+
+# Recreate ranges table for filter tests
+statement ok
+CREATE TABLE ranges_filter_test (start_val INT, end_val INT);
+
+statement ok
+INSERT INTO ranges_filter_test VALUES (1, 10), (5, 15), (10, 20);
+
+# Verify filter pushdown in query plan - non-volatile filters should be pushed down
+query TT
+EXPLAIN SELECT n
+FROM batched_generate_series(1, 20)
+WHERE n > 15 AND n < 18
+----
+logical_plan StandaloneBatchedTableFunctionScan: batched_generate_series(Int64(1), Int64(20)), projection=[0], filters=[batched_generate_series().n > Int32(15), batched_generate_series().n < Int32(18)]
+physical_plan
+01)BatchedTableFunctionExec: function=batched_generate_series, args=[1, 20], projection=[0], filters=[batched_generate_series().n > Int32(15), batched_generate_series().n < Int32(18)]
+02)--PlaceholderRowExec: produces single row
+
+# Verify volatile filters are NOT pushed down (they stay as Filter operator)
+query TT
+EXPLAIN SELECT n
+FROM batched_generate_series(1, 20)
+WHERE n > 15 AND random() > 0.5
+----
+logical_plan
+01)Filter: random() > Float64(0.5)
+02)--StandaloneBatchedTableFunctionScan: batched_generate_series(Int64(1), Int64(20)), projection=[0], filters=[batched_generate_series().n > Int32(15)]
+physical_plan
+01)CoalesceBatchesExec: target_batch_size=8192
+02)--FilterExec: random() > 0.5
+03)----RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
+04)------BatchedTableFunctionExec: function=batched_generate_series, args=[1, 20], projection=[0], filters=[batched_generate_series().n > Int32(15)]
+05)--------PlaceholderRowExec: produces single row
+
+# Verify LATERAL projection pushdown - since generate_series only has 1 column, projection not shown
+query TT
+EXPLAIN SELECT start_val, n
+FROM ranges_filter_test
+CROSS JOIN LATERAL batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
+----
+logical_plan
+01)Projection: ranges_filter_test.start_val, batched_generate_series().n
+02)--LateralTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
+03)----TableScan: ranges_filter_test projection=[start_val, end_val]
+physical_plan
+01)ProjectionExec: expr=[start_val@0 as start_val, n@2 as n]
+02)--BatchedTableFunctionExec: function=batched_generate_series, args=[start_val@0, end_val@1]
+03)----DataSourceExec: partitions=1, partition_sizes=[1]
+
+# Verify LATERAL filter pushdown - filters on table function output ARE pushed down
+query TT
+EXPLAIN SELECT start_val, n
+FROM ranges_filter_test
+CROSS JOIN LATERAL batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
+WHERE n > 12
+----
+logical_plan
+01)Projection: ranges_filter_test.start_val, batched_generate_series().n
+02)--LateralTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val), filters=[batched_generate_series().n > Int32(12)]
+03)----TableScan: ranges_filter_test projection=[start_val, end_val]
+physical_plan
+01)ProjectionExec: expr=[start_val@0 as start_val, n@2 as n]
+02)--BatchedTableFunctionExec: function=batched_generate_series, args=[start_val@0, end_val@1], filters=[batched_generate_series().n > Int32(12)]
+03)----DataSourceExec: partitions=1, partition_sizes=[1]
+
+# Verify LATERAL filter NOT pushed down when referencing input columns
+query TT
+EXPLAIN SELECT start_val, n
+FROM ranges_filter_test
+CROSS JOIN LATERAL batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
+WHERE start_val > 5
+----
+logical_plan
+01)Filter: ranges_filter_test.start_val > Int32(5)
+02)--Projection: ranges_filter_test.start_val, batched_generate_series().n
+03)----LateralTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
+04)------TableScan: ranges_filter_test projection=[start_val, end_val]
+physical_plan
+01)CoalesceBatchesExec: target_batch_size=8192
+02)--FilterExec: start_val@0 > 5
+03)----RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
+04)------ProjectionExec: expr=[start_val@0 as start_val, n@2 as n]
+05)--------BatchedTableFunctionExec: function=batched_generate_series, args=[start_val@0, end_val@1]
+06)----------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# Test filter pushdown on table function output
+query I
+SELECT n
+FROM batched_generate_series(1, 20)
+WHERE n > 15
+ORDER BY n
+----
+16
+17
+18
+19
+20
+
+# Test filter pushdown with LATERAL - filter on function output
+query II
+SELECT start_val, n
+FROM ranges_filter_test
+CROSS JOIN LATERAL batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
+WHERE n > 12
+ORDER BY start_val, n
+----
+5 13
+5 14
+5 15
+10 13
+10 14
+10 15
+10 16
+10 17
+10 18
+10 19
+10 20
+
+# Test filter pushdown with LATERAL - filter on input table
+query II
+SELECT start_val, n
+FROM ranges_filter_test
+CROSS JOIN LATERAL batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
+WHERE start_val >= 5
+ORDER BY start_val, n
+----
+5 5
+5 6
+5 7
+5 8
+5 9
+5 10
+5 11
+5 12
+5 13
+5 14
+5 15
+10 10
+10 11
+10 12
+10 13
+10 14
+10 15
+10 16
+10 17
+10 18
+10 19
+10 20
+
+# Test filter pushdown with multiple filters
+query II
+SELECT start_val, n
+FROM ranges_filter_test
+CROSS JOIN LATERAL batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
+WHERE start_val < 10 AND n >= 8 AND n <= 12
+ORDER BY start_val, n
+----
+1 8
+1 9
+1 10
+5 8
+5 9
+5 10
+5 11
+5 12
+
+# Test filter pushdown with aggregation
+query I
+SELECT COUNT(*)
+FROM batched_generate_series(1, 100)
+WHERE n <= 10
+----
+10
+
+##########
+## Limit Pushdown Tests
+##########
+
+# Verify limit pushdown for StandaloneBatchedTableFunctionScan
+query TT
+EXPLAIN SELECT n FROM batched_generate_series(1, 100) LIMIT 5
+----
+logical_plan
+01)Limit: skip=0, fetch=5
+02)--StandaloneBatchedTableFunctionScan: batched_generate_series(Int64(1), Int64(100)), projection=[0]
+physical_plan
+01)GlobalLimitExec: skip=0, fetch=5
+02)--BatchedTableFunctionExec: function=batched_generate_series, args=[1, 100], projection=[0]
+03)----PlaceholderRowExec: produces single row
+
+# Test limit actually works
+query I
+SELECT n FROM batched_generate_series(1, 100) LIMIT 5
+----
+1
+2
+3
+4
+5
+
+# Verify limit with LATERAL (limit stays above projection)
+query TT
+EXPLAIN SELECT start_val, n
+FROM ranges_filter_test
+CROSS JOIN LATERAL batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
+LIMIT 3
+----
+logical_plan
+01)Projection: ranges_filter_test.start_val, batched_generate_series().n
+02)--Limit: skip=0, fetch=3
+03)----LateralTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
+04)------TableScan: ranges_filter_test projection=[start_val, end_val]
+physical_plan
+01)ProjectionExec: expr=[start_val@0 as start_val, n@2 as n]
+02)--GlobalLimitExec: skip=0, fetch=3
+03)----BatchedTableFunctionExec: function=batched_generate_series, args=[start_val@0, end_val@1]
+04)------DataSourceExec: partitions=1, partition_sizes=[1]
+
+# Cleanup filter test table
+statement ok
+DROP TABLE ranges_filter_test;
+
+##########
+## Cleanup
+##########
+
+statement ok
+DROP TABLE ranges;
+
+statement ok
+DROP TABLE multipliers;
+
+statement ok
+DROP TABLE single_range;
diff --git a/datafusion/substrait/src/logical_plan/producer/rel/mod.rs b/datafusion/substrait/src/logical_plan/producer/rel/mod.rs
index c3599a2635ffa..13701a1fdd546 100644
--- a/datafusion/substrait/src/logical_plan/producer/rel/mod.rs
+++ b/datafusion/substrait/src/logical_plan/producer/rel/mod.rs
@@ -74,5 +74,11 @@ pub fn to_substrait_rel(
         LogicalPlan::RecursiveQuery(plan) => {
             not_impl_err!("Unsupported plan type: {plan:?}")?
         }
+        LogicalPlan::LateralBatchedTableFunction(plan) => {
+            not_impl_err!("Unsupported plan type: {plan:?}")?
+        }
+        LogicalPlan::StandaloneBatchedTableFunction(plan) => {
+            not_impl_err!("Unsupported plan type: {plan:?}")?
+        }
     }
 }

From cefa110a20555d82d127e62663dd2b5dfa8b0a03 Mon Sep 17 00:00:00 2001
From: bubulalabu <bubulalabububu@gmail.com>
Date: Sat, 8 Nov 2025 10:32:47 +0100
Subject: [PATCH 02/18] rollback formatting to original

---
 datafusion/physical-plan/src/placeholder_row.rs | 12 ++++--------
 1 file changed, 4 insertions(+), 8 deletions(-)

diff --git a/datafusion/physical-plan/src/placeholder_row.rs b/datafusion/physical-plan/src/placeholder_row.rs
index c1bee0327b2fc..4d00b73cff39c 100644
--- a/datafusion/physical-plan/src/placeholder_row.rs
+++ b/datafusion/physical-plan/src/placeholder_row.rs
@@ -113,15 +113,11 @@ impl DisplayAs for PlaceholderRowExec {
         f: &mut std::fmt::Formatter,
     ) -> std::fmt::Result {
         match t {
-            DisplayFormatType::Default => {
-                write!(f, "PlaceholderRowExec: produces single row")
-            }
-            DisplayFormatType::Verbose => {
-                write!(f, "PlaceholderRowExec: produces single row")
-            }
-            DisplayFormatType::TreeRender => {
-                write!(f, "produces single row")
+            DisplayFormatType::Default | DisplayFormatType::Verbose => {
+                write!(f, "PlaceholderRowExec")
             }
+
+            DisplayFormatType::TreeRender => Ok(()),
         }
     }
 }

From b4963f968d6614fa0ee0f5e893600e8fee2eca9d Mon Sep 17 00:00:00 2001
From: bubulalabu <bubulalabububu@gmail.com>
Date: Sat, 8 Nov 2025 11:30:10 +0100
Subject: [PATCH 03/18] misc fixes

---
 Cargo.lock                                    |  2 -
 .../catalog/src/batched_function/exec.rs      |  1 +
 datafusion/core/tests/sql/lateral_simple.rs   |  7 +-
 .../core/tests/sql/lateral_table_functions.rs |  3 +-
 datafusion/expr/src/logical_plan/display.rs   | 19 +++---
 datafusion/expr/src/logical_plan/plan.rs      | 67 ++++++++++---------
 datafusion/expr/src/logical_plan/tree_node.rs | 35 ++++++----
 datafusion/expr/src/planner.rs                |  2 +-
 datafusion/functions-table/Cargo.toml         |  2 -
 .../optimizer/src/optimize_projections/mod.rs |  9 +--
 datafusion/optimizer/src/push_down_filter.rs  |  3 +-
 datafusion/optimizer/src/push_down_limit.rs   | 24 +++++--
 datafusion/sql/src/relation/join.rs           | 23 ++++---
 datafusion/sqllogictest/src/test_context.rs   |  4 +-
 14 files changed, 118 insertions(+), 83 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index e99fa1818818e..1114e5b859c26 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2413,9 +2413,7 @@ dependencies = [
  "async-trait",
  "datafusion-catalog",
  "datafusion-common",
- "datafusion-execution",
  "datafusion-expr",
- "datafusion-physical-expr",
  "datafusion-physical-plan",
  "futures",
  "parking_lot",
diff --git a/datafusion/catalog/src/batched_function/exec.rs b/datafusion/catalog/src/batched_function/exec.rs
index 18b78ad57f06c..7ecea9fa5be34 100644
--- a/datafusion/catalog/src/batched_function/exec.rs
+++ b/datafusion/catalog/src/batched_function/exec.rs
@@ -77,6 +77,7 @@ pub struct BatchedTableFunctionExec {
 }
 
 impl BatchedTableFunctionExec {
+    #[allow(clippy::too_many_arguments)]
     pub fn new(
         func: Arc<dyn BatchedTableFunctionImpl>,
         args: Vec<Arc<dyn PhysicalExpr>>,
diff --git a/datafusion/core/tests/sql/lateral_simple.rs b/datafusion/core/tests/sql/lateral_simple.rs
index 8ed72d149353b..508251a7167aa 100644
--- a/datafusion/core/tests/sql/lateral_simple.rs
+++ b/datafusion/core/tests/sql/lateral_simple.rs
@@ -42,6 +42,7 @@ impl DoubleFn {
     }
 }
 
+#[async_trait::async_trait]
 impl BatchedTableFunctionImpl for DoubleFn {
     fn name(&self) -> &str {
         "double"
@@ -59,7 +60,7 @@ impl BatchedTableFunctionImpl for DoubleFn {
         )]))
     }
 
-    fn invoke_batch(
+    async fn invoke_batch(
         &self,
         args: &[ArrayRef],
         _projection: Option<&[usize]>,
@@ -123,7 +124,7 @@ async fn test_lateral_no_select() -> Result<()> {
             Ok(())
         }
         Err(e) => {
-            println!("Error: {}", e);
+            println!("Error: {e}");
             Err(e)
         }
     }
@@ -160,7 +161,7 @@ async fn test_lateral_simple_explain() -> Result<()> {
 
     println!("EXPLAIN results:");
     for batch in &results {
-        println!("{:?}", batch);
+        println!("{batch:?}");
     }
 
     Ok(())
diff --git a/datafusion/core/tests/sql/lateral_table_functions.rs b/datafusion/core/tests/sql/lateral_table_functions.rs
index b0a5ecd4f4c03..f1ed6d8ce932f 100644
--- a/datafusion/core/tests/sql/lateral_table_functions.rs
+++ b/datafusion/core/tests/sql/lateral_table_functions.rs
@@ -44,6 +44,7 @@ impl DoubleFn {
     }
 }
 
+#[async_trait::async_trait]
 impl BatchedTableFunctionImpl for DoubleFn {
     fn name(&self) -> &str {
         "double"
@@ -61,7 +62,7 @@ impl BatchedTableFunctionImpl for DoubleFn {
         )]))
     }
 
-    fn invoke_batch(
+    async fn invoke_batch(
         &self,
         args: &[ArrayRef],
         _projection: Option<&[usize]>,
diff --git a/datafusion/expr/src/logical_plan/display.rs b/datafusion/expr/src/logical_plan/display.rs
index f0f2f62770df7..38c95ae09f9f5 100644
--- a/datafusion/expr/src/logical_plan/display.rs
+++ b/datafusion/expr/src/logical_plan/display.rs
@@ -22,9 +22,10 @@ use std::fmt;
 
 use crate::{
     expr_vec_fmt, Aggregate, DescribeTable, Distinct, DistinctOn, DmlStatement, Expr,
-    Filter, Join, LateralBatchedTableFunction, Limit, LogicalPlan, Partitioning, Projection,
-    RecursiveQuery, Repartition, Sort, StandaloneBatchedTableFunction, Subquery,
-    SubqueryAlias, TableProviderFilterPushDown, TableScan, Unnest, Values, Window,
+    Filter, Join, LateralBatchedTableFunction, Limit, LogicalPlan, Partitioning,
+    Projection, RecursiveQuery, Repartition, Sort, StandaloneBatchedTableFunction,
+    Subquery, SubqueryAlias, TableProviderFilterPushDown, TableScan, Unnest, Values,
+    Window,
 };
 
 use crate::dml::CopyTo;
@@ -651,11 +652,13 @@ impl<'a, 'b> PgJsonVisitor<'a, 'b> {
                     "Arguments": expr_vec_fmt!(args),
                 })
             }
-            LogicalPlan::StandaloneBatchedTableFunction(StandaloneBatchedTableFunction {
-                function_name,
-                args,
-                ..
-            }) => {
+            LogicalPlan::StandaloneBatchedTableFunction(
+                StandaloneBatchedTableFunction {
+                    function_name,
+                    args,
+                    ..
+                },
+            ) => {
                 json!({
                     "Node Type": "StandaloneBatchedTableFunction",
                     "Function": function_name,
diff --git a/datafusion/expr/src/logical_plan/plan.rs b/datafusion/expr/src/logical_plan/plan.rs
index 67392a3b6c578..d7e1dfb1ba20f 100644
--- a/datafusion/expr/src/logical_plan/plan.rs
+++ b/datafusion/expr/src/logical_plan/plan.rs
@@ -45,10 +45,10 @@ use crate::utils::{
     grouping_set_expr_count, grouping_set_to_exprlist, split_conjunction,
 };
 use crate::{
-    BinaryExpr, CreateMemoryTable, CreateView, Execute, Expr, ExprSchemable,
-    LogicalPlanBuilder, Operator, Prepare, BatchedTableFunctionSource, TableProviderFilterPushDown,
-    TableSource,
-    WindowFunctionDefinition, build_join_schema, expr_vec_fmt, requalify_sides_if_needed,
+    build_join_schema, expr_vec_fmt, requalify_sides_if_needed,
+    BatchedTableFunctionSource, BinaryExpr, CreateMemoryTable, CreateView, Execute, Expr,
+    ExprSchemable, LogicalPlanBuilder, Operator, Prepare, TableProviderFilterPushDown,
+    TableSource, WindowFunctionDefinition,
 };
 
 use arrow::datatypes::{DataType, Field, FieldRef, Schema, SchemaRef};
@@ -358,12 +358,12 @@ impl LogicalPlan {
             LogicalPlan::Ddl(ddl) => ddl.schema(),
             LogicalPlan::Unnest(Unnest { schema, .. }) => schema,
             LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction {
-                schema, ..
-            }) => schema,
-            LogicalPlan::StandaloneBatchedTableFunction(StandaloneBatchedTableFunction {
                 schema,
                 ..
             }) => schema,
+            LogicalPlan::StandaloneBatchedTableFunction(
+                StandaloneBatchedTableFunction { schema, .. },
+            ) => schema,
             LogicalPlan::RecursiveQuery(RecursiveQuery { static_term, .. }) => {
                 // we take the schema of the static term as the schema of the entire recursive query
                 static_term.schema()
@@ -486,7 +486,10 @@ impl LogicalPlan {
             LogicalPlan::Copy(copy) => vec![&copy.input],
             LogicalPlan::Ddl(ddl) => ddl.inputs(),
             LogicalPlan::Unnest(Unnest { input, .. }) => vec![input],
-            LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction { input, .. }) => {
+            LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction {
+                input,
+                ..
+            }) => {
                 vec![input]
             }
             LogicalPlan::RecursiveQuery(RecursiveQuery {
@@ -1158,15 +1161,17 @@ impl LogicalPlan {
                 self.assert_no_inputs(inputs)?;
                 Ok(self.clone())
             }
-            LogicalPlan::StandaloneBatchedTableFunction(StandaloneBatchedTableFunction {
-                function_name,
-                source,
-                schema,
-                projection,
-                filters,
-                fetch,
-                ..
-            }) => {
+            LogicalPlan::StandaloneBatchedTableFunction(
+                StandaloneBatchedTableFunction {
+                    function_name,
+                    source,
+                    schema,
+                    projection,
+                    filters,
+                    fetch,
+                    ..
+                },
+            ) => {
                 self.assert_no_inputs(inputs)?;
                 Ok(LogicalPlan::StandaloneBatchedTableFunction(
                     StandaloneBatchedTableFunction {
@@ -1191,17 +1196,19 @@ impl LogicalPlan {
                 ..
             }) => {
                 let input = self.only_input(inputs)?;
-                Ok(LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction {
-                    input: Arc::new(input),
-                    function_name: function_name.clone(),
-                    source: Arc::clone(source),
-                    args: expr.to_vec(),
-                    schema: Arc::clone(schema),
-                    table_function_schema: Arc::clone(table_function_schema),
-                    projection: projection.clone(),
-                    filters: filters.clone(),
-                    fetch: *fetch,
-                }))
+                Ok(LogicalPlan::LateralBatchedTableFunction(
+                    LateralBatchedTableFunction {
+                        input: Arc::new(input),
+                        function_name: function_name.clone(),
+                        source: Arc::clone(source),
+                        args: expr.to_vec(),
+                        schema: Arc::clone(schema),
+                        table_function_schema: Arc::clone(table_function_schema),
+                        projection: projection.clone(),
+                        filters: filters.clone(),
+                        fetch: *fetch,
+                    },
+                ))
             }
             LogicalPlan::Unnest(Unnest {
                 exec_columns: columns,
@@ -2177,7 +2184,7 @@ impl LogicalPlan {
                     }) => {
                         write!(f, "LateralBatchedTableFunction: {}({})", function_name, expr_vec_fmt!(args))?;
                         if let Some(proj) = projection {
-                            write!(f, ", projection={:?}", proj)?;
+                            write!(f, ", projection={proj:?}")?;
                         }
                         if !filters.is_empty() {
                             write!(f, ", filters=[{}]", expr_vec_fmt!(filters))?;
@@ -2193,7 +2200,7 @@ impl LogicalPlan {
                     }) => {
                         write!(f, "StandaloneBatchedTableFunction: {}({})", function_name, expr_vec_fmt!(args))?;
                         if let Some(proj) = projection {
-                            write!(f, ", projection={:?}", proj)?;
+                            write!(f, ", projection={proj:?}")?;
                         }
                         if !filters.is_empty() {
                             write!(f, ", filters=[{}]", expr_vec_fmt!(filters))?;
diff --git a/datafusion/expr/src/logical_plan/tree_node.rs b/datafusion/expr/src/logical_plan/tree_node.rs
index c0cc806f3e4bd..e79dfd0004b99 100644
--- a/datafusion/expr/src/logical_plan/tree_node.rs
+++ b/datafusion/expr/src/logical_plan/tree_node.rs
@@ -41,8 +41,9 @@ use crate::{
     dml::CopyTo, Aggregate, Analyze, CreateMemoryTable, CreateView, DdlStatement,
     Distinct, DistinctOn, DmlStatement, Execute, Explain, Expr, Extension, Filter, Join,
     LateralBatchedTableFunction, Limit, LogicalPlan, Partitioning, Prepare, Projection,
-    RecursiveQuery, Repartition, Sort, Statement, StandaloneBatchedTableFunction, Subquery,
-    SubqueryAlias, TableScan, Union, Unnest, UserDefinedLogicalNode, Values, Window,
+    RecursiveQuery, Repartition, Sort, StandaloneBatchedTableFunction, Statement,
+    Subquery, SubqueryAlias, TableScan, Union, Unnest, UserDefinedLogicalNode, Values,
+    Window,
 };
 use datafusion_common::tree_node::TreeNodeRefContainer;
 
@@ -473,7 +474,9 @@ impl LogicalPlan {
             LogicalPlan::StandaloneBatchedTableFunction(batched_scan) => {
                 batched_scan.args.apply_elements(f)
             }
-            LogicalPlan::LateralBatchedTableFunction(lateral) => lateral.args.apply_elements(f),
+            LogicalPlan::LateralBatchedTableFunction(lateral) => {
+                lateral.args.apply_elements(f)
+            }
             LogicalPlan::Distinct(Distinct::On(DistinctOn {
                 on_expr,
                 select_expr,
@@ -668,16 +671,8 @@ impl LogicalPlan {
                 _ => Transformed::no(stmt),
             }
             .update_data(LogicalPlan::Statement),
-            LogicalPlan::StandaloneBatchedTableFunction(StandaloneBatchedTableFunction {
-                function_name,
-                source,
-                args,
-                schema,
-                projection,
-                filters,
-                fetch,
-            }) => args.map_elements(f)?.update_data(|args| {
-                LogicalPlan::StandaloneBatchedTableFunction(StandaloneBatchedTableFunction {
+            LogicalPlan::StandaloneBatchedTableFunction(
+                StandaloneBatchedTableFunction {
                     function_name,
                     source,
                     args,
@@ -685,7 +680,19 @@ impl LogicalPlan {
                     projection,
                     filters,
                     fetch,
-                })
+                },
+            ) => args.map_elements(f)?.update_data(|args| {
+                LogicalPlan::StandaloneBatchedTableFunction(
+                    StandaloneBatchedTableFunction {
+                        function_name,
+                        source,
+                        args,
+                        schema,
+                        projection,
+                        filters,
+                        fetch,
+                    },
+                )
             }),
             LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction {
                 input,
diff --git a/datafusion/expr/src/planner.rs b/datafusion/expr/src/planner.rs
index 7d00a3932767c..b26279036b803 100644
--- a/datafusion/expr/src/planner.rs
+++ b/datafusion/expr/src/planner.rs
@@ -24,7 +24,7 @@ use crate::expr::NullTreatment;
 #[cfg(feature = "sql")]
 use crate::logical_plan::LogicalPlan;
 use crate::{
-    AggregateUDF, Expr, GetFieldAccess, ScalarUDF, SortExpr, BatchedTableFunctionSource,
+    AggregateUDF, BatchedTableFunctionSource, Expr, GetFieldAccess, ScalarUDF, SortExpr,
     TableSource, WindowFrame, WindowFunctionDefinition, WindowUDF,
 };
 use arrow::datatypes::{DataType, Field, FieldRef, SchemaRef};
diff --git a/datafusion/functions-table/Cargo.toml b/datafusion/functions-table/Cargo.toml
index 1173e56781062..6fabe19f83c30 100644
--- a/datafusion/functions-table/Cargo.toml
+++ b/datafusion/functions-table/Cargo.toml
@@ -47,12 +47,10 @@ datafusion-catalog = { workspace = true }
 datafusion-common = { workspace = true }
 datafusion-expr = { workspace = true }
 datafusion-physical-plan = { workspace = true }
-datafusion-physical-expr = { workspace = true }
 futures = { workspace = true }
 parking_lot = { workspace = true }
 paste = { workspace = true }
 
 [dev-dependencies]
 arrow = { workspace = true, features = ["test_utils"] }
-datafusion-execution = { workspace = true }
 tokio = { workspace = true }
diff --git a/datafusion/optimizer/src/optimize_projections/mod.rs b/datafusion/optimizer/src/optimize_projections/mod.rs
index 027f92959ecb8..dbbdd094dea0d 100644
--- a/datafusion/optimizer/src/optimize_projections/mod.rs
+++ b/datafusion/optimizer/src/optimize_projections/mod.rs
@@ -306,7 +306,8 @@ fn optimize_projections(
                     let qualified_fields: Vec<_> = proj
                         .iter()
                         .map(|&i| {
-                            let (qualifier, field) = lateral.table_function_schema.qualified_field(i);
+                            let (qualifier, field) =
+                                lateral.table_function_schema.qualified_field(i);
                             (qualifier.cloned(), Arc::new(field.clone()))
                         })
                         .collect();
@@ -458,9 +459,9 @@ fn optimize_projections(
 
             let mut new_scan = scan.clone();
             new_scan.projection = Some(new_projection);
-            return Ok(Transformed::yes(LogicalPlan::StandaloneBatchedTableFunction(
-                new_scan,
-            )));
+            return Ok(Transformed::yes(
+                LogicalPlan::StandaloneBatchedTableFunction(new_scan),
+            ));
         }
         LogicalPlan::EmptyRelation(_)
         | LogicalPlan::Values(_)
diff --git a/datafusion/optimizer/src/push_down_filter.rs b/datafusion/optimizer/src/push_down_filter.rs
index 7f498c9e79798..2b52af98d2711 100644
--- a/datafusion/optimizer/src/push_down_filter.rs
+++ b/datafusion/optimizer/src/push_down_filter.rs
@@ -1331,7 +1331,8 @@ impl OptimizerRule for PushDownFilter {
 
                 Transformed::yes(new_lateral).transform_data(|new_lateral| {
                     if let Some(predicate) = conjunction(remaining_predicates) {
-                        make_filter(predicate, Arc::new(new_lateral)).map(Transformed::yes)
+                        make_filter(predicate, Arc::new(new_lateral))
+                            .map(Transformed::yes)
                     } else {
                         Ok(Transformed::no(new_lateral))
                     }
diff --git a/datafusion/optimizer/src/push_down_limit.rs b/datafusion/optimizer/src/push_down_limit.rs
index 0d5a91852f2c5..5d80eed731709 100644
--- a/datafusion/optimizer/src/push_down_limit.rs
+++ b/datafusion/optimizer/src/push_down_limit.rs
@@ -161,14 +161,22 @@ impl OptimizerRule for PushDownLimit {
                     .map(|x| min(x, rows_needed))
                     .or(Some(rows_needed));
                 if new_fetch == scan.fetch {
-                    original_limit(skip, fetch, LogicalPlan::StandaloneBatchedTableFunction(scan))
+                    original_limit(
+                        skip,
+                        fetch,
+                        LogicalPlan::StandaloneBatchedTableFunction(scan),
+                    )
                 } else {
                     // push limit into the table function scan itself
                     scan.fetch = scan
                         .fetch
                         .map(|x| min(x, rows_needed))
                         .or(Some(rows_needed));
-                    transformed_limit(skip, fetch, LogicalPlan::StandaloneBatchedTableFunction(scan))
+                    transformed_limit(
+                        skip,
+                        fetch,
+                        LogicalPlan::StandaloneBatchedTableFunction(scan),
+                    )
                 }
             }
             LogicalPlan::LateralBatchedTableFunction(mut lateral) => {
@@ -178,14 +186,22 @@ impl OptimizerRule for PushDownLimit {
                     .map(|x| min(x, rows_needed))
                     .or(Some(rows_needed));
                 if new_fetch == lateral.fetch {
-                    original_limit(skip, fetch, LogicalPlan::LateralBatchedTableFunction(lateral))
+                    original_limit(
+                        skip,
+                        fetch,
+                        LogicalPlan::LateralBatchedTableFunction(lateral),
+                    )
                 } else {
                     // push limit into the lateral table function itself
                     lateral.fetch = lateral
                         .fetch
                         .map(|x| min(x, rows_needed))
                         .or(Some(rows_needed));
-                    transformed_limit(skip, fetch, LogicalPlan::LateralBatchedTableFunction(lateral))
+                    transformed_limit(
+                        skip,
+                        fetch,
+                        LogicalPlan::LateralBatchedTableFunction(lateral),
+                    )
                 }
             }
             LogicalPlan::Extension(extension_plan)
diff --git a/datafusion/sql/src/relation/join.rs b/datafusion/sql/src/relation/join.rs
index 971e099737327..38e45aa3ea6db 100644
--- a/datafusion/sql/src/relation/join.rs
+++ b/datafusion/sql/src/relation/join.rs
@@ -298,17 +298,18 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
 
         let combined_schema = input_schema.as_ref().join(&qualified_func_schema)?;
 
-        let lateral_plan = LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction {
-            input: Arc::new(input_plan.clone()),
-            function_name: func_name.to_string(),
-            source,
-            args: func_args,
-            schema: Arc::new(combined_schema),
-            table_function_schema: Arc::new(qualified_func_schema),
-            projection: None,
-            filters: vec![],
-            fetch: None,
-        });
+        let lateral_plan =
+            LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction {
+                input: Arc::new(input_plan.clone()),
+                function_name: func_name.to_string(),
+                source,
+                args: func_args,
+                schema: Arc::new(combined_schema),
+                table_function_schema: Arc::new(qualified_func_schema),
+                projection: None,
+                filters: vec![],
+                fetch: None,
+            });
 
         Ok(Some(lateral_plan))
     }
diff --git a/datafusion/sqllogictest/src/test_context.rs b/datafusion/sqllogictest/src/test_context.rs
index b06dd6c92589d..9da349eb799ec 100644
--- a/datafusion/sqllogictest/src/test_context.rs
+++ b/datafusion/sqllogictest/src/test_context.rs
@@ -30,8 +30,8 @@ use arrow::buffer::ScalarBuffer;
 use arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit, UnionFields};
 use arrow::record_batch::RecordBatch;
 use datafusion::catalog::{
-    batched_function::helpers::materialized_batch_stream, CatalogProvider,
-    MemoryCatalogProvider, MemorySchemaProvider, Session, BatchedTableFunctionImpl,
+    batched_function::helpers::materialized_batch_stream, BatchedTableFunctionImpl,
+    CatalogProvider, MemoryCatalogProvider, MemorySchemaProvider, Session,
 };
 use datafusion::common::{not_impl_err, DataFusionError, Result};
 use datafusion::functions::math::abs;

From 67064cb965946a6cfab08775d7259655ad59f7ea Mon Sep 17 00:00:00 2001
From: bubulalabu <bubulalabububu@gmail.com>
Date: Sat, 8 Nov 2025 12:44:44 +0100
Subject: [PATCH 04/18] misc fixes

---
 datafusion/catalog/src/table.rs               | 14 +++----
 datafusion/sqllogictest/src/test_context.rs   | 38 +++++++++++--------
 .../sqllogictest/test_files/lateral.slt       | 22 +++++------
 3 files changed, 40 insertions(+), 34 deletions(-)

diff --git a/datafusion/catalog/src/table.rs b/datafusion/catalog/src/table.rs
index 2952830b5c1c9..461a4a6b6907c 100644
--- a/datafusion/catalog/src/table.rs
+++ b/datafusion/catalog/src/table.rs
@@ -495,7 +495,7 @@ impl TableFunction {
 /// Implementation of a table function that can process batched inputs
 ///
 /// This is the trait that table function implementors should implement.
-/// It receives already-evaluated arguments (similar to [`ScalarUDFImpl`]).
+/// It receives already-evaluated arguments (similar to `ScalarUDFImpl`).
 ///
 /// Unlike the legacy `TableFunctionImpl` trait which only accepts constant arguments,
 /// `BatchedTableFunctionImpl` can process batched inputs, enabling:
@@ -562,8 +562,8 @@ pub struct BatchResultChunk {
 
     /// Maps each output row to its source input row
     ///
-    /// For output row i, it was generated from input row input_row_indices[i].
-    /// Length must equal output.num_rows().
+    /// For output row `i`, it was generated from input row `input_row_indices[i]`.
+    /// Length must equal `output.num_rows()`.
     ///
     /// For standalone invocations with a single input, this is typically
     /// vec![0; output.num_rows()] (all rows from input row 0).
@@ -623,9 +623,9 @@ pub trait BatchedTableFunctionImpl: Send + Sync + Debug {
     ///
     /// Default implementation returns [`Unsupported`] for all filters.
     ///
-    /// [`Unsupported`]: crate::TableProviderFilterPushDown::Unsupported
-    /// [`Exact`]: crate::TableProviderFilterPushDown::Exact
-    /// [`Inexact`]: crate::TableProviderFilterPushDown::Inexact
+    /// [`Unsupported`]: TableProviderFilterPushDown::Unsupported
+    /// [`Exact`]: TableProviderFilterPushDown::Exact
+    /// [`Inexact`]: TableProviderFilterPushDown::Inexact
     fn supports_filters_pushdown(
         &self,
         filters: &[&Expr],
@@ -716,7 +716,7 @@ pub trait BatchedTableFunctionImpl: Send + Sync + Debug {
 /// This is a wrapper around [`BatchedTableFunctionImpl`] that provides
 /// a `create_plan` method for integration with DataFusion's planning.
 ///
-/// Similar to how [`ScalarUDF`] wraps [`ScalarUDFImpl`].
+/// Similar to how `ScalarUDF` wraps `ScalarUDFImpl`.
 #[derive(Debug, Clone)]
 pub struct BatchedTableFunction {
     inner: Arc<dyn BatchedTableFunctionImpl>,
diff --git a/datafusion/sqllogictest/src/test_context.rs b/datafusion/sqllogictest/src/test_context.rs
index 9da349eb799ec..58e7dcbb24405 100644
--- a/datafusion/sqllogictest/src/test_context.rs
+++ b/datafusion/sqllogictest/src/test_context.rs
@@ -602,8 +602,7 @@ fn register_test_batched_table_function(ctx: &SessionContext) {
                         .as_primitive::<arrow::datatypes::Int64Type>()
                         .value(idx) as i32),
                     dt => Err(DataFusionError::Internal(format!(
-                        "Expected Int32/Int64, got {:?}",
-                        dt
+                        "Expected Int32/Int64, got {dt:?}"
                     ))),
                 }
             };
@@ -619,16 +618,24 @@ fn register_test_batched_table_function(ctx: &SessionContext) {
                 for n in start..=stop {
                     // Simple filter evaluation for test purposes
                     // Only handles basic comparison filters on 'n' column
-                    let passes_filters = filters.is_empty() || filters.iter().all(|filter| {
-                        // This is a simplified filter evaluator for testing
-                        // In a real implementation, you'd use DataFusion's expression evaluator
-                        match filter {
-                            datafusion::prelude::Expr::BinaryExpr(binary) => {
-                                use datafusion::logical_expr::Operator;
-                                if let datafusion::prelude::Expr::Column(col) = &*binary.left {
-                                    if col.name == "n" {
-                                        if let datafusion::prelude::Expr::Literal(val, _) = &*binary.right {
-                                            if let datafusion::common::ScalarValue::Int32(Some(val)) = val {
+                    let passes_filters = filters.is_empty()
+                        || filters.iter().all(|filter| {
+                            // This is a simplified filter evaluator for testing
+                            // In a real implementation, you'd use DataFusion's expression evaluator
+                            match filter {
+                                datafusion::prelude::Expr::BinaryExpr(binary) => {
+                                    use datafusion::logical_expr::Operator;
+                                    if let datafusion::prelude::Expr::Column(col) =
+                                        &*binary.left
+                                    {
+                                        if col.name == "n" {
+                                            if let datafusion::prelude::Expr::Literal(
+                                                datafusion::common::ScalarValue::Int32(
+                                                    Some(val),
+                                                ),
+                                                _,
+                                            ) = &*binary.right
+                                            {
                                                 return match binary.op {
                                                     Operator::Gt => n > *val,
                                                     Operator::Lt => n < *val,
@@ -641,12 +648,11 @@ fn register_test_batched_table_function(ctx: &SessionContext) {
                                             }
                                         }
                                     }
+                                    true // Unknown filters pass through (should be handled by FilterExec)
                                 }
-                                true // Unknown filters pass through (should be handled by FilterExec)
+                                _ => true, // Unknown filter types pass through
                             }
-                            _ => true, // Unknown filter types pass through
-                        }
-                    });
+                        });
 
                     if passes_filters {
                         output_values.push(n);
diff --git a/datafusion/sqllogictest/test_files/lateral.slt b/datafusion/sqllogictest/test_files/lateral.slt
index 444ab4893484d..83f822293ee58 100644
--- a/datafusion/sqllogictest/test_files/lateral.slt
+++ b/datafusion/sqllogictest/test_files/lateral.slt
@@ -438,10 +438,10 @@ EXPLAIN SELECT n
 FROM batched_generate_series(1, 20)
 WHERE n > 15 AND n < 18
 ----
-logical_plan StandaloneBatchedTableFunctionScan: batched_generate_series(Int64(1), Int64(20)), projection=[0], filters=[batched_generate_series().n > Int32(15), batched_generate_series().n < Int32(18)]
+logical_plan StandaloneBatchedTableFunction: batched_generate_series(Int64(1), Int64(20)), projection=[0], filters=[batched_generate_series().n > Int32(15), batched_generate_series().n < Int32(18)]
 physical_plan
 01)BatchedTableFunctionExec: function=batched_generate_series, args=[1, 20], projection=[0], filters=[batched_generate_series().n > Int32(15), batched_generate_series().n < Int32(18)]
-02)--PlaceholderRowExec: produces single row
+02)--PlaceholderRowExec
 
 # Verify volatile filters are NOT pushed down (they stay as Filter operator)
 query TT
@@ -451,13 +451,13 @@ WHERE n > 15 AND random() > 0.5
 ----
 logical_plan
 01)Filter: random() > Float64(0.5)
-02)--StandaloneBatchedTableFunctionScan: batched_generate_series(Int64(1), Int64(20)), projection=[0], filters=[batched_generate_series().n > Int32(15)]
+02)--StandaloneBatchedTableFunction: batched_generate_series(Int64(1), Int64(20)), projection=[0], filters=[batched_generate_series().n > Int32(15)]
 physical_plan
 01)CoalesceBatchesExec: target_batch_size=8192
 02)--FilterExec: random() > 0.5
 03)----RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
 04)------BatchedTableFunctionExec: function=batched_generate_series, args=[1, 20], projection=[0], filters=[batched_generate_series().n > Int32(15)]
-05)--------PlaceholderRowExec: produces single row
+05)--------PlaceholderRowExec
 
 # Verify LATERAL projection pushdown - since generate_series only has 1 column, projection not shown
 query TT
@@ -467,7 +467,7 @@ CROSS JOIN LATERAL batched_generate_series(ranges_filter_test.start_val, ranges_
 ----
 logical_plan
 01)Projection: ranges_filter_test.start_val, batched_generate_series().n
-02)--LateralTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
+02)--LateralBatchedTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
 03)----TableScan: ranges_filter_test projection=[start_val, end_val]
 physical_plan
 01)ProjectionExec: expr=[start_val@0 as start_val, n@2 as n]
@@ -483,7 +483,7 @@ WHERE n > 12
 ----
 logical_plan
 01)Projection: ranges_filter_test.start_val, batched_generate_series().n
-02)--LateralTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val), filters=[batched_generate_series().n > Int32(12)]
+02)--LateralBatchedTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val), filters=[batched_generate_series().n > Int32(12)]
 03)----TableScan: ranges_filter_test projection=[start_val, end_val]
 physical_plan
 01)ProjectionExec: expr=[start_val@0 as start_val, n@2 as n]
@@ -500,7 +500,7 @@ WHERE start_val > 5
 logical_plan
 01)Filter: ranges_filter_test.start_val > Int32(5)
 02)--Projection: ranges_filter_test.start_val, batched_generate_series().n
-03)----LateralTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
+03)----LateralBatchedTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
 04)------TableScan: ranges_filter_test projection=[start_val, end_val]
 physical_plan
 01)CoalesceBatchesExec: target_batch_size=8192
@@ -603,17 +603,17 @@ WHERE n <= 10
 ## Limit Pushdown Tests
 ##########
 
-# Verify limit pushdown for StandaloneBatchedTableFunctionScan
+# Verify limit pushdown for StandaloneBatchedTableFunction
 query TT
 EXPLAIN SELECT n FROM batched_generate_series(1, 100) LIMIT 5
 ----
 logical_plan
 01)Limit: skip=0, fetch=5
-02)--StandaloneBatchedTableFunctionScan: batched_generate_series(Int64(1), Int64(100)), projection=[0]
+02)--StandaloneBatchedTableFunction: batched_generate_series(Int64(1), Int64(100)), projection=[0]
 physical_plan
 01)GlobalLimitExec: skip=0, fetch=5
 02)--BatchedTableFunctionExec: function=batched_generate_series, args=[1, 100], projection=[0]
-03)----PlaceholderRowExec: produces single row
+03)----PlaceholderRowExec
 
 # Test limit actually works
 query I
@@ -635,7 +635,7 @@ LIMIT 3
 logical_plan
 01)Projection: ranges_filter_test.start_val, batched_generate_series().n
 02)--Limit: skip=0, fetch=3
-03)----LateralTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
+03)----LateralBatchedTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
 04)------TableScan: ranges_filter_test projection=[start_val, end_val]
 physical_plan
 01)ProjectionExec: expr=[start_val@0 as start_val, n@2 as n]

From 74730aec158915c99fc532b692e0e0170700ae06 Mon Sep 17 00:00:00 2001
From: bubulalabu <bubulalabububu@gmail.com>
Date: Sat, 8 Nov 2025 14:00:24 +0100
Subject: [PATCH 05/18] misc fixes

---
 datafusion/core/src/execution/session_state_defaults.rs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/datafusion/core/src/execution/session_state_defaults.rs b/datafusion/core/src/execution/session_state_defaults.rs
index 4ee717f5c8f15..5be6d8dbea381 100644
--- a/datafusion/core/src/execution/session_state_defaults.rs
+++ b/datafusion/core/src/execution/session_state_defaults.rs
@@ -136,7 +136,7 @@ impl SessionStateDefaults {
         functions_table::all_default_batched_table_functions()
     }
 
-    /// returns the list of default [`FileFormatFactory']'s
+    /// returns the list of default [`FileFormatFactory`]s
     pub fn default_file_formats() -> Vec<Arc<dyn FileFormatFactory>> {
         let file_formats: Vec<Arc<dyn FileFormatFactory>> = vec![
             #[cfg(feature = "parquet")]

From c25e7a9b371fbf6e8e485ad6a4a1f5bb8f573bfe Mon Sep 17 00:00:00 2001
From: bubulalabu <bubulalabububu@gmail.com>
Date: Sun, 7 Dec 2025 10:50:58 +0100
Subject: [PATCH 06/18] misc fixes after rebase

---
 .../optimizer/src/optimize_projections/mod.rs |  2 +-
 .../sqllogictest/test_files/lateral.slt       | 20 +++++++++----------
 2 files changed, 10 insertions(+), 12 deletions(-)

diff --git a/datafusion/optimizer/src/optimize_projections/mod.rs b/datafusion/optimizer/src/optimize_projections/mod.rs
index dbbdd094dea0d..d96257523670a 100644
--- a/datafusion/optimizer/src/optimize_projections/mod.rs
+++ b/datafusion/optimizer/src/optimize_projections/mod.rs
@@ -308,7 +308,7 @@ fn optimize_projections(
                         .map(|&i| {
                             let (qualifier, field) =
                                 lateral.table_function_schema.qualified_field(i);
-                            (qualifier.cloned(), Arc::new(field.clone()))
+                            (qualifier.cloned(), field.clone())
                         })
                         .collect();
                     Arc::new(DFSchema::new_with_metadata(
diff --git a/datafusion/sqllogictest/test_files/lateral.slt b/datafusion/sqllogictest/test_files/lateral.slt
index 83f822293ee58..d738ad4a18b46 100644
--- a/datafusion/sqllogictest/test_files/lateral.slt
+++ b/datafusion/sqllogictest/test_files/lateral.slt
@@ -453,11 +453,10 @@ logical_plan
 01)Filter: random() > Float64(0.5)
 02)--StandaloneBatchedTableFunction: batched_generate_series(Int64(1), Int64(20)), projection=[0], filters=[batched_generate_series().n > Int32(15)]
 physical_plan
-01)CoalesceBatchesExec: target_batch_size=8192
-02)--FilterExec: random() > 0.5
-03)----RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
-04)------BatchedTableFunctionExec: function=batched_generate_series, args=[1, 20], projection=[0], filters=[batched_generate_series().n > Int32(15)]
-05)--------PlaceholderRowExec
+01)FilterExec: random() > 0.5
+02)--RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
+03)----BatchedTableFunctionExec: function=batched_generate_series, args=[1, 20], projection=[0], filters=[batched_generate_series().n > Int32(15)]
+04)------PlaceholderRowExec
 
 # Verify LATERAL projection pushdown - since generate_series only has 1 column, projection not shown
 query TT
@@ -503,12 +502,11 @@ logical_plan
 03)----LateralBatchedTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
 04)------TableScan: ranges_filter_test projection=[start_val, end_val]
 physical_plan
-01)CoalesceBatchesExec: target_batch_size=8192
-02)--FilterExec: start_val@0 > 5
-03)----RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
-04)------ProjectionExec: expr=[start_val@0 as start_val, n@2 as n]
-05)--------BatchedTableFunctionExec: function=batched_generate_series, args=[start_val@0, end_val@1]
-06)----------DataSourceExec: partitions=1, partition_sizes=[1]
+01)FilterExec: start_val@0 > 5
+02)--RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
+03)----ProjectionExec: expr=[start_val@0 as start_val, n@2 as n]
+04)------BatchedTableFunctionExec: function=batched_generate_series, args=[start_val@0, end_val@1]
+05)--------DataSourceExec: partitions=1, partition_sizes=[1]
 
 # Test filter pushdown on table function output
 query I

From 47ba1117643aef331e20a4a93a431c8c94b0b08f Mon Sep 17 00:00:00 2001
From: bubulalabu <bubulalabububu@gmail.com>
Date: Sun, 7 Dec 2025 14:14:03 +0100
Subject: [PATCH 07/18] clippy and fmt after rebase

---
 .../catalog/src/batched_function/exec.rs      |  4 +-
 .../catalog/src/batched_function/helpers.rs   |  6 +--
 datafusion/core/src/physical_planner.rs       |  2 +-
 datafusion/expr/src/logical_plan/display.rs   | 10 ++--
 datafusion/expr/src/logical_plan/mod.rs       |  8 +--
 datafusion/expr/src/logical_plan/plan.rs      | 50 ++++++++++++-------
 datafusion/expr/src/logical_plan/tree_node.rs |  6 +--
 .../optimizer/src/optimize_projections/mod.rs |  2 +-
 8 files changed, 51 insertions(+), 37 deletions(-)

diff --git a/datafusion/catalog/src/batched_function/exec.rs b/datafusion/catalog/src/batched_function/exec.rs
index 7ecea9fa5be34..a86d661b59214 100644
--- a/datafusion/catalog/src/batched_function/exec.rs
+++ b/datafusion/catalog/src/batched_function/exec.rs
@@ -77,7 +77,7 @@ pub struct BatchedTableFunctionExec {
 }
 
 impl BatchedTableFunctionExec {
-    #[allow(clippy::too_many_arguments)]
+    #[expect(clippy::too_many_arguments)]
     pub fn new(
         func: Arc<dyn BatchedTableFunctionImpl>,
         args: Vec<Arc<dyn PhysicalExpr>>,
@@ -303,7 +303,7 @@ impl BatchedTableFunctionStream {
         match self.mode {
             BatchedTableFunctionMode::Lateral => combine_lateral_result(
                 input_batch,
-                chunk.output,
+                &chunk.output,
                 &chunk.input_row_indices,
             ),
             BatchedTableFunctionMode::Standalone => Ok(chunk.output),
diff --git a/datafusion/catalog/src/batched_function/helpers.rs b/datafusion/catalog/src/batched_function/helpers.rs
index 1d04229df12d3..d3fc98dd6c244 100644
--- a/datafusion/catalog/src/batched_function/helpers.rs
+++ b/datafusion/catalog/src/batched_function/helpers.rs
@@ -102,7 +102,7 @@ pub fn materialized_batch_stream(
 /// ```
 pub fn combine_lateral_result(
     input_batch: &RecordBatch,
-    output_batch: RecordBatch,
+    output_batch: &RecordBatch,
     input_row_indices: &[u32],
 ) -> Result<RecordBatch> {
     if input_row_indices.len() != output_batch.num_rows() {
@@ -168,7 +168,7 @@ mod tests {
         let input_row_indices = vec![0, 0, 1, 1];
 
         let result =
-            combine_lateral_result(&input_batch, output_batch, &input_row_indices)
+            combine_lateral_result(&input_batch, &output_batch, &input_row_indices)
                 .unwrap();
 
         assert_eq!(result.num_columns(), 3);
@@ -235,7 +235,7 @@ mod tests {
         let input_row_indices = vec![0, 0, 0];
 
         let result =
-            combine_lateral_result(&input_batch, output_batch, &input_row_indices);
+            combine_lateral_result(&input_batch, &output_batch, &input_row_indices);
         assert!(result.is_err());
         assert!(result
             .unwrap_err()
diff --git a/datafusion/core/src/physical_planner.rs b/datafusion/core/src/physical_planner.rs
index a881b5300cf4e..cc112f788a103 100644
--- a/datafusion/core/src/physical_planner.rs
+++ b/datafusion/core/src/physical_planner.rs
@@ -61,8 +61,8 @@ use crate::schema_equivalence::schema_satisfied_by;
 use arrow::array::{builder::StringBuilder, RecordBatch};
 use arrow::compute::SortOptions;
 use arrow::datatypes::Schema;
-use datafusion_catalog::batched_function::BatchedTableFunctionExec;
 use arrow_schema::Field;
+use datafusion_catalog::batched_function::BatchedTableFunctionExec;
 use datafusion_catalog::ScanArgs;
 use datafusion_common::display::ToStringifiedPlan;
 use datafusion_common::format::ExplainAnalyzeLevel;
diff --git a/datafusion/expr/src/logical_plan/display.rs b/datafusion/expr/src/logical_plan/display.rs
index 38c95ae09f9f5..87794e2965f62 100644
--- a/datafusion/expr/src/logical_plan/display.rs
+++ b/datafusion/expr/src/logical_plan/display.rs
@@ -21,11 +21,11 @@ use std::collections::HashMap;
 use std::fmt;
 
 use crate::{
-    expr_vec_fmt, Aggregate, DescribeTable, Distinct, DistinctOn, DmlStatement, Expr,
-    Filter, Join, LateralBatchedTableFunction, Limit, LogicalPlan, Partitioning,
-    Projection, RecursiveQuery, Repartition, Sort, StandaloneBatchedTableFunction,
-    Subquery, SubqueryAlias, TableProviderFilterPushDown, TableScan, Unnest, Values,
-    Window,
+    Aggregate, DescribeTable, Distinct, DistinctOn, DmlStatement, Expr, Filter, Join,
+    LateralBatchedTableFunction, Limit, LogicalPlan, Partitioning, Projection,
+    RecursiveQuery, Repartition, Sort, StandaloneBatchedTableFunction, Subquery,
+    SubqueryAlias, TableProviderFilterPushDown, TableScan, Unnest, Values, Window,
+    expr_vec_fmt,
 };
 
 use crate::dml::CopyTo;
diff --git a/datafusion/expr/src/logical_plan/mod.rs b/datafusion/expr/src/logical_plan/mod.rs
index 49b87d8924961..5214eb09e67d8 100644
--- a/datafusion/expr/src/logical_plan/mod.rs
+++ b/datafusion/expr/src/logical_plan/mod.rs
@@ -38,12 +38,12 @@ pub use ddl::{
 };
 pub use dml::{DmlStatement, WriteOp};
 pub use plan::{
-    projection_schema, Aggregate, Analyze, ColumnUnnestList, DescribeTable, Distinct,
-    DistinctOn, EmptyRelation, Explain, ExplainOption, Extension, FetchType, Filter,
-    Join, JoinConstraint, JoinType, LateralBatchedTableFunction, Limit, LogicalPlan,
+    Aggregate, Analyze, ColumnUnnestList, DescribeTable, Distinct, DistinctOn,
+    EmptyRelation, Explain, ExplainOption, Extension, FetchType, Filter, Join,
+    JoinConstraint, JoinType, LateralBatchedTableFunction, Limit, LogicalPlan,
     Partitioning, PlanType, Projection, RecursiveQuery, Repartition, SkipType, Sort,
     StandaloneBatchedTableFunction, StringifiedPlan, Subquery, SubqueryAlias, TableScan,
-    ToStringifiedPlan, Union, Unnest, Values, Window,
+    ToStringifiedPlan, Union, Unnest, Values, Window, projection_schema,
 };
 pub use statement::{
     Deallocate, Execute, Prepare, ResetVariable, SetVariable, Statement,
diff --git a/datafusion/expr/src/logical_plan/plan.rs b/datafusion/expr/src/logical_plan/plan.rs
index d7e1dfb1ba20f..163290e6f755a 100644
--- a/datafusion/expr/src/logical_plan/plan.rs
+++ b/datafusion/expr/src/logical_plan/plan.rs
@@ -45,10 +45,10 @@ use crate::utils::{
     grouping_set_expr_count, grouping_set_to_exprlist, split_conjunction,
 };
 use crate::{
-    build_join_schema, expr_vec_fmt, requalify_sides_if_needed,
     BatchedTableFunctionSource, BinaryExpr, CreateMemoryTable, CreateView, Execute, Expr,
     ExprSchemable, LogicalPlanBuilder, Operator, Prepare, TableProviderFilterPushDown,
-    TableSource, WindowFunctionDefinition,
+    TableSource, WindowFunctionDefinition, build_join_schema, expr_vec_fmt,
+    requalify_sides_if_needed,
 };
 
 use arrow::datatypes::{DataType, Field, FieldRef, Schema, SchemaRef};
@@ -2175,14 +2175,21 @@ impl LogicalPlan {
                             expr_vec_fmt!(struct_type_columns)
                         )
                     }
-                    LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction {
-                        function_name,
-                        args,
-                        projection,
-                        filters,
-                        ..
-                    }) => {
-                        write!(f, "LateralBatchedTableFunction: {}({})", function_name, expr_vec_fmt!(args))?;
+                    LogicalPlan::LateralBatchedTableFunction(
+                        LateralBatchedTableFunction {
+                            function_name,
+                            args,
+                            projection,
+                            filters,
+                            ..
+                        },
+                    ) => {
+                        write!(
+                            f,
+                            "LateralBatchedTableFunction: {}({})",
+                            function_name,
+                            expr_vec_fmt!(args)
+                        )?;
                         if let Some(proj) = projection {
                             write!(f, ", projection={proj:?}")?;
                         }
@@ -2191,14 +2198,21 @@ impl LogicalPlan {
                         }
                         Ok(())
                     }
-                    LogicalPlan::StandaloneBatchedTableFunction(StandaloneBatchedTableFunction {
-                        function_name,
-                        args,
-                        projection,
-                        filters,
-                        ..
-                    }) => {
-                        write!(f, "StandaloneBatchedTableFunction: {}({})", function_name, expr_vec_fmt!(args))?;
+                    LogicalPlan::StandaloneBatchedTableFunction(
+                        StandaloneBatchedTableFunction {
+                            function_name,
+                            args,
+                            projection,
+                            filters,
+                            ..
+                        },
+                    ) => {
+                        write!(
+                            f,
+                            "StandaloneBatchedTableFunction: {}({})",
+                            function_name,
+                            expr_vec_fmt!(args)
+                        )?;
                         if let Some(proj) = projection {
                             write!(f, ", projection={proj:?}")?;
                         }
diff --git a/datafusion/expr/src/logical_plan/tree_node.rs b/datafusion/expr/src/logical_plan/tree_node.rs
index e79dfd0004b99..f2af716cf3f5f 100644
--- a/datafusion/expr/src/logical_plan/tree_node.rs
+++ b/datafusion/expr/src/logical_plan/tree_node.rs
@@ -38,12 +38,12 @@
 //! * [`LogicalPlan::expressions`]: Return a copy of the plan's expressions
 
 use crate::{
-    dml::CopyTo, Aggregate, Analyze, CreateMemoryTable, CreateView, DdlStatement,
-    Distinct, DistinctOn, DmlStatement, Execute, Explain, Expr, Extension, Filter, Join,
+    Aggregate, Analyze, CreateMemoryTable, CreateView, DdlStatement, Distinct,
+    DistinctOn, DmlStatement, Execute, Explain, Expr, Extension, Filter, Join,
     LateralBatchedTableFunction, Limit, LogicalPlan, Partitioning, Prepare, Projection,
     RecursiveQuery, Repartition, Sort, StandaloneBatchedTableFunction, Statement,
     Subquery, SubqueryAlias, TableScan, Union, Unnest, UserDefinedLogicalNode, Values,
-    Window,
+    Window, dml::CopyTo,
 };
 use datafusion_common::tree_node::TreeNodeRefContainer;
 
diff --git a/datafusion/optimizer/src/optimize_projections/mod.rs b/datafusion/optimizer/src/optimize_projections/mod.rs
index d96257523670a..0813bb41dd959 100644
--- a/datafusion/optimizer/src/optimize_projections/mod.rs
+++ b/datafusion/optimizer/src/optimize_projections/mod.rs
@@ -308,7 +308,7 @@ fn optimize_projections(
                         .map(|&i| {
                             let (qualifier, field) =
                                 lateral.table_function_schema.qualified_field(i);
-                            (qualifier.cloned(), field.clone())
+                            (qualifier.cloned(), Arc::clone(field))
                         })
                         .collect();
                     Arc::new(DFSchema::new_with_metadata(

From 0fd3bc666d26a02d892165de2cb0270643aef459 Mon Sep 17 00:00:00 2001
From: bubulalabu <bubulalabububu@gmail.com>
Date: Sun, 7 Dec 2025 18:42:50 +0100
Subject: [PATCH 08/18] fixed projection schema bug

---
 datafusion/core/src/physical_planner.rs       |   8 +-
 datafusion/expr/src/logical_plan/plan.rs      |  12 +-
 datafusion/expr/src/logical_plan/tree_node.rs |   2 +
 .../optimizer/src/optimize_projections/mod.rs |  47 ++++--
 datafusion/sql/src/relation/mod.rs            |   8 +-
 datafusion/sqllogictest/src/test_context.rs   | 149 +++++++++++++++++-
 .../sqllogictest/test_files/lateral.slt       |  57 +++++++
 7 files changed, 263 insertions(+), 20 deletions(-)

diff --git a/datafusion/core/src/physical_planner.rs b/datafusion/core/src/physical_planner.rs
index cc112f788a103..85b639c168675 100644
--- a/datafusion/core/src/physical_planner.rs
+++ b/datafusion/core/src/physical_planner.rs
@@ -510,6 +510,7 @@ impl DefaultPhysicalPlanner {
                     args,
                     schema,
                     projection,
+                    projected_schema,
                     filters,
                     fetch,
                 },
@@ -529,7 +530,8 @@ impl DefaultPhysicalPlanner {
                     .map(|e| self.create_physical_expr(e, &empty_schema, session_state))
                     .collect::<Result<Vec<_>>>()?;
 
-                let function_schema = Arc::clone(schema.inner());
+                let full_function_schema = Arc::clone(schema.inner());
+                let projected_function_schema = Arc::clone(projected_schema.inner());
 
                 // All filters have been pre-filtered by the logical optimizer
                 // based on supports_filters_pushdown(). Just pass them all through.
@@ -540,8 +542,8 @@ impl DefaultPhysicalPlanner {
                     Arc::clone(batched_tf.inner()),
                     physical_args,
                     placeholder_exec,
-                    Arc::clone(&function_schema), // projected_schema
-                    function_schema,              // table_function_schema
+                    projected_function_schema,
+                    full_function_schema,
                     datafusion_catalog::BatchedTableFunctionMode::Standalone,
                     projection.clone(),
                     filters.clone(),
diff --git a/datafusion/expr/src/logical_plan/plan.rs b/datafusion/expr/src/logical_plan/plan.rs
index 163290e6f755a..35e58618467c0 100644
--- a/datafusion/expr/src/logical_plan/plan.rs
+++ b/datafusion/expr/src/logical_plan/plan.rs
@@ -362,8 +362,8 @@ impl LogicalPlan {
                 ..
             }) => schema,
             LogicalPlan::StandaloneBatchedTableFunction(
-                StandaloneBatchedTableFunction { schema, .. },
-            ) => schema,
+                StandaloneBatchedTableFunction { projected_schema, .. },
+            ) => projected_schema,
             LogicalPlan::RecursiveQuery(RecursiveQuery { static_term, .. }) => {
                 // we take the schema of the static term as the schema of the entire recursive query
                 static_term.schema()
@@ -1167,6 +1167,7 @@ impl LogicalPlan {
                     source,
                     schema,
                     projection,
+                    projected_schema,
                     filters,
                     fetch,
                     ..
@@ -1180,6 +1181,7 @@ impl LogicalPlan {
                         args: expr,
                         schema: Arc::clone(schema),
                         projection: projection.clone(),
+                        projected_schema: Arc::clone(projected_schema),
                         filters: filters.clone(),
                         fetch: *fetch,
                     },
@@ -4439,10 +4441,12 @@ pub struct StandaloneBatchedTableFunction {
     pub source: Arc<dyn BatchedTableFunctionSource>,
     /// Expressions for table function arguments (should be constants for standalone mode)
     pub args: Vec<Expr>,
-    /// The output schema (only table function output columns)
+    /// The full output schema (all columns from table function)
     pub schema: DFSchemaRef,
-    /// Optional column indices to use as a projection
+    /// Optional column indices to use as a projection (indices into `schema`)
     pub projection: Option<Vec<usize>>,
+    /// The schema after applying projection (this is what gets returned by LogicalPlan::schema())
+    pub projected_schema: DFSchemaRef,
     /// Optional expressions to be used as filters by the table function
     pub filters: Vec<Expr>,
     /// Optional limit to push down to the function (number of rows to fetch)
diff --git a/datafusion/expr/src/logical_plan/tree_node.rs b/datafusion/expr/src/logical_plan/tree_node.rs
index f2af716cf3f5f..2c96e0b320a75 100644
--- a/datafusion/expr/src/logical_plan/tree_node.rs
+++ b/datafusion/expr/src/logical_plan/tree_node.rs
@@ -678,6 +678,7 @@ impl LogicalPlan {
                     args,
                     schema,
                     projection,
+                    projected_schema,
                     filters,
                     fetch,
                 },
@@ -689,6 +690,7 @@ impl LogicalPlan {
                         args,
                         schema,
                         projection,
+                        projected_schema,
                         filters,
                         fetch,
                     },
diff --git a/datafusion/optimizer/src/optimize_projections/mod.rs b/datafusion/optimizer/src/optimize_projections/mod.rs
index 0813bb41dd959..044883f825671 100644
--- a/datafusion/optimizer/src/optimize_projections/mod.rs
+++ b/datafusion/optimizer/src/optimize_projections/mod.rs
@@ -255,6 +255,7 @@ fn optimize_projections(
         LogicalPlan::LateralBatchedTableFunction(ref lateral) => {
             // Schema: [input_fields..., function_output_fields...]
             let input_len = lateral.input.schema().fields().len();
+            let original_tf_schema = lateral.source.schema();
             let tf_len = lateral.table_function_schema.fields().len();
 
             // Determine which input columns and function output columns are needed
@@ -288,27 +289,36 @@ fn optimize_projections(
                     // No function output columns needed
                     None
                 } else if tf_indices.len() == tf_len {
-                    // All function output columns needed
+                    // All current function output columns needed - keep existing projection
                     lateral.projection.clone()
                 } else {
                     // Subset of function output columns needed
                     Some(match &lateral.projection {
                         Some(existing_proj) => {
-                            // Chain projections: map through existing projection
+                            // Chain projections: tf_indices are into current projected schema,
+                            // existing_proj maps current projected schema to original schema
                             tf_indices.iter().map(|&idx| existing_proj[idx]).collect()
                         }
-                        None => tf_indices,
+                        None => {
+                            // No existing projection, tf_indices are already into original schema
+                            tf_indices
+                        }
                     })
                 };
 
+                let qualifier = if lateral.table_function_schema.fields().is_empty() {
+                    None
+                } else {
+                    lateral.table_function_schema.qualified_field(0).0.cloned()
+                };
+
                 // Update table function schema if projection changed
                 let new_tf_schema = if let Some(proj) = &tf_projection {
                     let qualified_fields: Vec<_> = proj
                         .iter()
                         .map(|&i| {
-                            let (qualifier, field) =
-                                lateral.table_function_schema.qualified_field(i);
-                            (qualifier.cloned(), Arc::clone(field))
+                            let field = original_tf_schema.field(i);
+                            (qualifier.clone(), Arc::new(field.clone()))
                         })
                         .collect();
                     Arc::new(DFSchema::new_with_metadata(
@@ -450,15 +460,32 @@ fn optimize_projections(
                 .collect::<Result<Vec<_>>>()?
         }
         LogicalPlan::StandaloneBatchedTableFunction(scan) => {
-            // Get indices referred to in the original (schema with all fields)
-            // given projected indices.
             let new_projection = match &scan.projection {
-                Some(projection) => indices.into_mapped_indices(|idx| projection[idx]),
-                None => indices.into_inner(),
+                Some(existing_proj) => {
+                    indices.into_mapped_indices(|idx| existing_proj[idx])
+                }
+                None => {
+                    indices.into_inner()
+                }
+            };
+
+            let new_projected_schema = {
+                let qualified_fields: Vec<_> = new_projection
+                    .iter()
+                    .map(|&i| {
+                        let (qualifier, field) = scan.schema.qualified_field(i);
+                        (qualifier.cloned(), Arc::clone(field))
+                    })
+                    .collect();
+                Arc::new(DFSchema::new_with_metadata(
+                    qualified_fields,
+                    scan.schema.metadata().clone(),
+                )?)
             };
 
             let mut new_scan = scan.clone();
             new_scan.projection = Some(new_projection);
+            new_scan.projected_schema = new_projected_schema;
             return Ok(Transformed::yes(
                 LogicalPlan::StandaloneBatchedTableFunction(new_scan),
             ));
diff --git a/datafusion/sql/src/relation/mod.rs b/datafusion/sql/src/relation/mod.rs
index 4222b108abd35..376191120ec9d 100644
--- a/datafusion/sql/src/relation/mod.rs
+++ b/datafusion/sql/src/relation/mod.rs
@@ -209,13 +209,15 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
                             &func_schema,
                         )?;
 
+                        let qualified_func_schema = Arc::new(qualified_func_schema);
                         let plan = LogicalPlan::StandaloneBatchedTableFunction(
                             StandaloneBatchedTableFunction {
                                 function_name: tbl_func_name.clone(),
                                 source,
                                 args,
-                                schema: Arc::new(qualified_func_schema),
+                                schema: Arc::clone(&qualified_func_schema),
                                 projection: None,
+                                projected_schema: qualified_func_schema,
                                 filters: vec![],
                                 fetch: None,
                             },
@@ -393,14 +395,16 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
 
                     let qualified_func_schema =
                         DFSchema::try_from_qualified_schema(&qualifier, &func_schema)?;
+                    let qualified_func_schema = Arc::new(qualified_func_schema);
 
                     let plan = LogicalPlan::StandaloneBatchedTableFunction(
                         StandaloneBatchedTableFunction {
                             function_name: func_name.to_string(),
                             source,
                             args: func_args,
-                            schema: Arc::new(qualified_func_schema),
+                            schema: Arc::clone(&qualified_func_schema),
                             projection: None,
+                            projected_schema: qualified_func_schema,
                             filters: vec![],
                             fetch: None,
                         },
diff --git a/datafusion/sqllogictest/src/test_context.rs b/datafusion/sqllogictest/src/test_context.rs
index 58e7dcbb24405..970237c5ad0f2 100644
--- a/datafusion/sqllogictest/src/test_context.rs
+++ b/datafusion/sqllogictest/src/test_context.rs
@@ -146,7 +146,8 @@ impl TestContext {
             }
             "lateral.slt" => {
                 info!("Registering test batched table function for LATERAL tests");
-                register_test_batched_table_function(test_ctx.session_ctx())
+                register_test_batched_table_function(test_ctx.session_ctx());
+                register_multi_column_batched_function(test_ctx.session_ctx());
             }
             _ => {
                 info!("Using default SessionContext");
@@ -677,3 +678,149 @@ fn register_test_batched_table_function(ctx: &SessionContext) {
         Arc::new(BatchedGenerateSeriesFn::new()),
     );
 }
+
+fn register_multi_column_batched_function(ctx: &SessionContext) {
+    /// Multi-column batched table function for testing projection optimizer
+    /// batched_multi_column(start, stop) generates rows with multiple columns:
+    /// - n: the value
+    /// - n_squared: n * n
+    /// - n_cubed: n * n * n
+    /// - n_doubled: n * 2
+    /// - n_mod_3: n % 3
+    #[derive(Debug)]
+    struct MultiColumnBatchedFn {
+        signature: Signature,
+    }
+
+    impl MultiColumnBatchedFn {
+        fn new() -> Self {
+            Self {
+                signature: Signature::exact(
+                    vec![DataType::Int32, DataType::Int32],
+                    Volatility::Immutable,
+                ),
+            }
+        }
+    }
+
+    #[async_trait::async_trait]
+    impl BatchedTableFunctionImpl for MultiColumnBatchedFn {
+        fn name(&self) -> &str {
+            "batched_multi_column"
+        }
+
+        fn signature(&self) -> &Signature {
+            &self.signature
+        }
+
+        fn return_type(&self, _arg_types: &[DataType]) -> Result<Schema> {
+            Ok(Schema::new(vec![
+                Field::new("n", DataType::Int32, false),
+                Field::new("n_squared", DataType::Int32, false),
+                Field::new("n_cubed", DataType::Int32, false),
+                Field::new("n_doubled", DataType::Int32, false),
+                Field::new("n_mod_3", DataType::Int32, false),
+            ]))
+        }
+
+        async fn invoke_batch(
+            &self,
+            args: &[ArrayRef],
+            projection: Option<&[usize]>,
+            _filters: &[datafusion::prelude::Expr],
+            _limit: Option<usize>,
+        ) -> Result<datafusion::catalog::BatchResultStream> {
+            if args.len() != 2 {
+                return Err(DataFusionError::Internal(
+                    "Expected exactly 2 arguments (start, stop)".to_string(),
+                ));
+            }
+
+            let start_array = args[0].as_ref();
+            let stop_array = args[1].as_ref();
+
+            if start_array.len() != stop_array.len() {
+                return Err(DataFusionError::Internal(
+                    "start and stop arrays must have same length".to_string(),
+                ));
+            }
+
+            let get_i32 = |arr: &dyn Array, idx: usize| -> Result<i32> {
+                if arr.is_null(idx) {
+                    return Err(DataFusionError::Internal(
+                        "NULL values not supported".to_string(),
+                    ));
+                }
+                match arr.data_type() {
+                    DataType::Int32 => {
+                        Ok(arr.as_primitive::<arrow::datatypes::Int32Type>().value(idx))
+                    }
+                    DataType::Int64 => Ok(arr
+                        .as_primitive::<arrow::datatypes::Int64Type>()
+                        .value(idx) as i32),
+                    dt => Err(DataFusionError::Internal(format!(
+                        "Expected Int32/Int64, got {dt:?}"
+                    ))),
+                }
+            };
+
+            let mut n_values = Vec::new();
+            let mut n_squared_values = Vec::new();
+            let mut n_cubed_values = Vec::new();
+            let mut n_doubled_values = Vec::new();
+            let mut n_mod_3_values = Vec::new();
+            let mut input_row_indices = Vec::new();
+
+            for row_idx in 0..start_array.len() {
+                let start = get_i32(start_array, row_idx)?;
+                let stop = get_i32(stop_array, row_idx)?;
+
+                for n in start..=stop {
+                    n_values.push(n);
+                    n_squared_values.push(n * n);
+                    n_cubed_values.push(n * n * n);
+                    n_doubled_values.push(n * 2);
+                    n_mod_3_values.push(n % 3);
+                    input_row_indices.push(row_idx as u32);
+                }
+            }
+
+            // Build output batch with all columns or just projected columns
+            let schema = self.return_type(&[DataType::Int32, DataType::Int32])?;
+
+            let all_columns: Vec<ArrayRef> = vec![
+                Arc::new(Int32Array::from(n_values)),
+                Arc::new(Int32Array::from(n_squared_values)),
+                Arc::new(Int32Array::from(n_cubed_values)),
+                Arc::new(Int32Array::from(n_doubled_values)),
+                Arc::new(Int32Array::from(n_mod_3_values)),
+            ];
+
+            let (output_columns, output_schema) = if let Some(proj_indices) = projection {
+                // Apply projection
+                let projected_columns: Vec<ArrayRef> = proj_indices
+                    .iter()
+                    .map(|&idx| Arc::clone(&all_columns[idx]))
+                    .collect();
+                let projected_fields: Vec<Field> = proj_indices
+                    .iter()
+                    .map(|&idx| schema.field(idx).clone())
+                    .collect();
+                (projected_columns, Arc::new(Schema::new(projected_fields)))
+            } else {
+                (all_columns, Arc::new(schema))
+            };
+
+            let output_batch = RecordBatch::try_new(output_schema, output_columns)?;
+
+            Ok(materialized_batch_stream(output_batch, input_row_indices))
+        }
+    }
+
+    let state_ref = ctx.state_ref();
+    let mut state = state_ref.write();
+    state.register_batched_table_function(
+        "batched_multi_column",
+        Arc::new(MultiColumnBatchedFn::new()),
+    );
+}
diff --git a/datafusion/sqllogictest/test_files/lateral.slt b/datafusion/sqllogictest/test_files/lateral.slt
index d738ad4a18b46..e32c565b93fa0 100644
--- a/datafusion/sqllogictest/test_files/lateral.slt
+++ b/datafusion/sqllogictest/test_files/lateral.slt
@@ -407,6 +407,63 @@ SELECT n FROM batched_generate_series(1, 100) LIMIT 3
 2
 3
 
+##########
+## Multi-Column Table Function Projection Tests
+##########
+
+# Standalone: projection of subset of columns from 5-column function
+query II
+SELECT n_squared, n_cubed FROM batched_multi_column(1, 3)
+ORDER BY n_squared
+----
+1 1
+4 8
+9 27
+
+# Standalone: non-sequential column selection
+query III
+SELECT n, n_cubed, n_mod_3 FROM batched_multi_column(1, 5)
+ORDER BY n
+----
+1 1 1
+2 8 2
+3 27 0
+4 64 1
+5 125 2
+
+# LATERAL: create test table
+statement ok
+CREATE TABLE test_lateral_multi (id INT, max_val INT);
+
+statement ok
+INSERT INTO test_lateral_multi VALUES (1, 2), (2, 3);
+
+# LATERAL: projection of subset of columns
+query III
+SELECT id, n_squared, n_cubed
+FROM test_lateral_multi
+CROSS JOIN LATERAL batched_multi_column(1, test_lateral_multi.max_val)
+ORDER BY id, n_squared
+----
+1 1 1
+1 4 8
+2 1 1
+2 4 8
+2 9 27
+
+# LATERAL: non-sequential column selection
+query III
+SELECT id, n, n_mod_3
+FROM test_lateral_multi
+CROSS JOIN LATERAL batched_multi_column(1, test_lateral_multi.max_val)
+ORDER BY id, n
+----
+1 1 1
+1 2 2
+2 1 1
+2 2 2
+2 3 0
+
 # Test projection pushdown with LATERAL
 query I
 SELECT n

From 619598624ad2f3e623a08bda5c57ce402c56424b Mon Sep 17 00:00:00 2001
From: bubulalabu <bubulalabububu@gmail.com>
Date: Wed, 10 Dec 2025 09:59:28 +0100
Subject: [PATCH 09/18] remove test batched_generate_series

---
 Cargo.lock                                    |   1 +
 .../src/generate_series_batched.rs            |  32 ++-
 datafusion/sqllogictest/Cargo.toml            |   1 +
 datafusion/sqllogictest/src/test_context.rs   | 170 +---------------
 .../sqllogictest/test_files/lateral.slt       | 183 +++++++++---------
 5 files changed, 133 insertions(+), 254 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 1114e5b859c26..50eee5cdfbbdb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2727,6 +2727,7 @@ dependencies = [
  "chrono",
  "clap 4.5.50",
  "datafusion",
+ "datafusion-functions-table",
  "datafusion-spark",
  "datafusion-substrait",
  "env_logger",
diff --git a/datafusion/functions-table/src/generate_series_batched.rs b/datafusion/functions-table/src/generate_series_batched.rs
index ebe8933ca2a98..882021593d6bb 100644
--- a/datafusion/functions-table/src/generate_series_batched.rs
+++ b/datafusion/functions-table/src/generate_series_batched.rs
@@ -45,9 +45,18 @@ pub struct GenerateSeriesFunction {
 
 impl GenerateSeriesFunction {
     pub fn new() -> Self {
+        use datafusion_common::types::{logical_int64, NativeType};
+        use datafusion_expr::{Coercion, TypeSignatureClass};
+
+        let integer = Coercion::new_implicit(
+            TypeSignatureClass::Native(logical_int64()),
+            vec![TypeSignatureClass::Integer],
+            NativeType::Int64,
+        );
+
         Self {
-            signature: Signature::exact(
-                vec![DataType::Int64, DataType::Int64],
+            signature: Signature::coercible(
+                vec![integer.clone(), integer.clone()],
                 datafusion_expr::Volatility::Immutable,
             ),
         }
@@ -63,7 +72,7 @@ impl Default for GenerateSeriesFunction {
 #[async_trait::async_trait]
 impl BatchedTableFunctionImpl for GenerateSeriesFunction {
     fn name(&self) -> &str {
-        "generate_series"
+        "batched_generate_series"
     }
 
     fn signature(&self) -> &Signature {
@@ -89,8 +98,21 @@ impl BatchedTableFunctionImpl for GenerateSeriesFunction {
             return exec_err!("generate_series expects 2 arguments, got {}", args.len());
         }
 
-        let start_array = args[0].as_primitive::<arrow::datatypes::Int64Type>();
-        let end_array = args[1].as_primitive::<arrow::datatypes::Int64Type>();
+        // Cast inputs to Int64 if needed (for coercible signature support)
+        use arrow::compute::cast;
+        let start_array = if args[0].data_type() != &DataType::Int64 {
+            cast(&args[0], &DataType::Int64)?
+        } else {
+            Arc::clone(&args[0])
+        };
+        let end_array = if args[1].data_type() != &DataType::Int64 {
+            cast(&args[1], &DataType::Int64)?
+        } else {
+            Arc::clone(&args[1])
+        };
+
+        let start_array = start_array.as_primitive::<arrow::datatypes::Int64Type>();
+        let end_array = end_array.as_primitive::<arrow::datatypes::Int64Type>();
 
         if start_array.len() != end_array.len() {
             return exec_err!("start and end arrays must have same length");
diff --git a/datafusion/sqllogictest/Cargo.toml b/datafusion/sqllogictest/Cargo.toml
index 177761e4af54e..53d5c0a4fbc3b 100644
--- a/datafusion/sqllogictest/Cargo.toml
+++ b/datafusion/sqllogictest/Cargo.toml
@@ -47,6 +47,7 @@ bytes = { workspace = true, optional = true }
 chrono = { workspace = true, optional = true }
 clap = { version = "4.5.50", features = ["derive", "env"] }
 datafusion = { workspace = true, default-features = true, features = ["avro"] }
+datafusion-functions-table = { workspace = true, default-features = true }
 datafusion-spark = { workspace = true, default-features = true }
 datafusion-substrait = { workspace = true, default-features = true }
 futures = { workspace = true }
diff --git a/datafusion/sqllogictest/src/test_context.rs b/datafusion/sqllogictest/src/test_context.rs
index 970237c5ad0f2..1b524f6165d21 100644
--- a/datafusion/sqllogictest/src/test_context.rs
+++ b/datafusion/sqllogictest/src/test_context.rs
@@ -33,6 +33,7 @@ use datafusion::catalog::{
     batched_function::helpers::materialized_batch_stream, BatchedTableFunctionImpl,
     CatalogProvider, MemoryCatalogProvider, MemorySchemaProvider, Session,
 };
+use datafusion_functions_table::generate_series_batched::GenerateSeriesFunction;
 use datafusion::common::{not_impl_err, DataFusionError, Result};
 use datafusion::functions::math::abs;
 use datafusion::logical_expr::async_udf::{AsyncScalarUDF, AsyncScalarUDFImpl};
@@ -145,8 +146,14 @@ impl TestContext {
                 register_async_abs_udf(test_ctx.session_ctx())
             }
             "lateral.slt" => {
-                info!("Registering test batched table function for LATERAL tests");
-                register_test_batched_table_function(test_ctx.session_ctx());
+                info!("Registering batched table functions for LATERAL tests");
+                let state_ref = test_ctx.session_ctx().state_ref();
+                let mut state = state_ref.write();
+                state.register_batched_table_function(
+                    "batched_generate_series",
+                    Arc::new(GenerateSeriesFunction::new()),
+                );
+                drop(state);
                 register_multi_column_batched_function(test_ctx.session_ctx());
             }
             _ => {
@@ -520,165 +527,6 @@ fn register_async_abs_udf(ctx: &SessionContext) {
     ctx.register_udf(udf.into_scalar_udf());
 }
 
-fn register_test_batched_table_function(ctx: &SessionContext) {
-    /// Batched table function that generates a series of integers
-    /// batched_generate_series(start, stop) generates rows from start to stop (inclusive)
-    #[derive(Debug)]
-    struct BatchedGenerateSeriesFn {
-        signature: Signature,
-    }
-
-    impl BatchedGenerateSeriesFn {
-        fn new() -> Self {
-            Self {
-                signature: Signature::exact(
-                    vec![DataType::Int32, DataType::Int32],
-                    Volatility::Immutable,
-                ),
-            }
-        }
-    }
-
-    #[async_trait::async_trait]
-    impl BatchedTableFunctionImpl for BatchedGenerateSeriesFn {
-        fn name(&self) -> &str {
-            "batched_generate_series"
-        }
-
-        fn signature(&self) -> &Signature {
-            &self.signature
-        }
-
-        fn return_type(&self, _arg_types: &[DataType]) -> Result<Schema> {
-            Ok(Schema::new(vec![Field::new("n", DataType::Int32, false)]))
-        }
-
-        fn supports_filters_pushdown(
-            &self,
-            filters: &[&datafusion::prelude::Expr],
-        ) -> Result<Vec<datafusion::logical_expr::TableProviderFilterPushDown>> {
-            // This test function supports all filters
-            Ok(vec![
-                datafusion::logical_expr::TableProviderFilterPushDown::Exact;
-                filters.len()
-            ])
-        }
-
-        async fn invoke_batch(
-            &self,
-            args: &[ArrayRef],
-            _projection: Option<&[usize]>,
-            filters: &[datafusion::prelude::Expr],
-            _limit: Option<usize>,
-        ) -> Result<datafusion::catalog::BatchResultStream> {
-            if args.len() != 2 {
-                return Err(DataFusionError::Internal(
-                    "Expected exactly 2 arguments (start, stop)".to_string(),
-                ));
-            }
-
-            // Support both Int32 and Int64
-            let start_array = args[0].as_ref();
-            let stop_array = args[1].as_ref();
-
-            if start_array.len() != stop_array.len() {
-                return Err(DataFusionError::Internal(
-                    "start and stop arrays must have same length".to_string(),
-                ));
-            }
-
-            // Extract i32 from Int32 or Int64
-            let get_i32 = |arr: &dyn Array, idx: usize| -> Result<i32> {
-                if arr.is_null(idx) {
-                    return Err(DataFusionError::Internal(
-                        "NULL values not supported".to_string(),
-                    ));
-                }
-
-                match arr.data_type() {
-                    DataType::Int32 => {
-                        Ok(arr.as_primitive::<arrow::datatypes::Int32Type>().value(idx))
-                    }
-                    DataType::Int64 => Ok(arr
-                        .as_primitive::<arrow::datatypes::Int64Type>()
-                        .value(idx) as i32),
-                    dt => Err(DataFusionError::Internal(format!(
-                        "Expected Int32/Int64, got {dt:?}"
-                    ))),
-                }
-            };
-
-            let mut output_values = Vec::new();
-            let mut input_row_indices = Vec::new();
-
-            for row_idx in 0..start_array.len() {
-                let start = get_i32(start_array, row_idx)?;
-                let stop = get_i32(stop_array, row_idx)?;
-
-                // Inclusive range - apply filters during generation for efficiency
-                for n in start..=stop {
-                    // Simple filter evaluation for test purposes
-                    // Only handles basic comparison filters on 'n' column
-                    let passes_filters = filters.is_empty()
-                        || filters.iter().all(|filter| {
-                            // This is a simplified filter evaluator for testing
-                            // In a real implementation, you'd use DataFusion's expression evaluator
-                            match filter {
-                                datafusion::prelude::Expr::BinaryExpr(binary) => {
-                                    use datafusion::logical_expr::Operator;
-                                    if let datafusion::prelude::Expr::Column(col) =
-                                        &*binary.left
-                                    {
-                                        if col.name == "n" {
-                                            if let datafusion::prelude::Expr::Literal(
-                                                datafusion::common::ScalarValue::Int32(
-                                                    Some(val),
-                                                ),
-                                                _,
-                                            ) = &*binary.right
-                                            {
-                                                return match binary.op {
-                                                    Operator::Gt => n > *val,
-                                                    Operator::Lt => n < *val,
-                                                    Operator::GtEq => n >= *val,
-                                                    Operator::LtEq => n <= *val,
-                                                    Operator::Eq => n == *val,
-                                                    Operator::NotEq => n != *val,
-                                                    _ => true, // Unsupported operators pass through
-                                                };
-                                            }
-                                        }
-                                    }
-                                    true // Unknown filters pass through (should be handled by FilterExec)
-                                }
-                                _ => true, // Unknown filter types pass through
-                            }
-                        });
-
-                    if passes_filters {
-                        output_values.push(n);
-                        input_row_indices.push(row_idx as u32);
-                    }
-                }
-            }
-
-            let schema = self.return_type(&[DataType::Int32, DataType::Int32])?;
-            let output_array = Int32Array::from(output_values);
-            let output_batch =
-                RecordBatch::try_new(Arc::new(schema), vec![Arc::new(output_array)])?;
-
-            Ok(materialized_batch_stream(output_batch, input_row_indices))
-        }
-    }
-
-    let state_ref = ctx.state_ref();
-    let mut state = state_ref.write();
-    state.register_batched_table_function(
-        "batched_generate_series",
-        Arc::new(BatchedGenerateSeriesFn::new()),
-    );
-}
-
 fn register_multi_column_batched_function(ctx: &SessionContext) {
     /// Multi-column batched table function for testing projection optimizer
     /// batched_multi_column(start, stop) generates rows with multiple columns:
diff --git a/datafusion/sqllogictest/test_files/lateral.slt b/datafusion/sqllogictest/test_files/lateral.slt
index e32c565b93fa0..e83340c827f19 100644
--- a/datafusion/sqllogictest/test_files/lateral.slt
+++ b/datafusion/sqllogictest/test_files/lateral.slt
@@ -35,7 +35,7 @@ INSERT INTO ranges VALUES (1, 3), (5, 7), (10, 12);
 
 query I
 SELECT * FROM batched_generate_series(1, 5)
-ORDER BY n
+ORDER BY value
 ----
 1
 2
@@ -49,10 +49,10 @@ SELECT COUNT(*) FROM batched_generate_series(1, 100)
 100
 
 query II
-SELECT a.n, b.n
+SELECT a.value, b.value
 FROM batched_generate_series(1, 2) a
 CROSS JOIN batched_generate_series(3, 4) b
-ORDER BY a.n, b.n
+ORDER BY a.value, b.value
 ----
 1 3
 1 4
@@ -60,8 +60,8 @@ ORDER BY a.n, b.n
 2 4
 
 query I
-SELECT series.n FROM batched_generate_series(5, 7) AS series
-ORDER BY n
+SELECT series.value FROM batched_generate_series(5, 7) AS series
+ORDER BY value
 ----
 5
 6
@@ -72,10 +72,10 @@ ORDER BY n
 ##########
 
 query II
-SELECT ranges.start_val, n
+SELECT ranges.start_val, value
 FROM ranges
 CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
-ORDER BY ranges.start_val, n
+ORDER BY ranges.start_val, value
 ----
 1 1
 1 2
@@ -91,7 +91,7 @@ query III
 SELECT *
 FROM ranges
 CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
-ORDER BY start_val, end_val, n
+ORDER BY start_val, end_val, value
 ----
 1 3 1
 1 3 2
@@ -104,10 +104,10 @@ ORDER BY start_val, end_val, n
 10 12 12
 
 query I
-SELECT n
+SELECT value
 FROM ranges
 CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
-ORDER BY n
+ORDER BY value
 ----
 1
 2
@@ -123,7 +123,7 @@ query II
 SELECT start_val, end_val
 FROM ranges
 CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
-ORDER BY start_val, n
+ORDER BY start_val, value
 ----
 1 3
 1 3
@@ -140,11 +140,11 @@ ORDER BY start_val, n
 ##########
 
 query II
-SELECT ranges.start_val, n
+SELECT ranges.start_val, value
 FROM ranges
 CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
 WHERE start_val < 10
-ORDER BY start_val, n
+ORDER BY start_val, value
 ----
 1 1
 1 2
@@ -154,11 +154,11 @@ ORDER BY start_val, n
 5 7
 
 query II
-SELECT ranges.start_val, n
+SELECT ranges.start_val, value
 FROM ranges
 CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
-WHERE n > 6
-ORDER BY start_val, n
+WHERE value > 6
+ORDER BY start_val, value
 ----
 5 7
 10 10
@@ -166,11 +166,11 @@ ORDER BY start_val, n
 10 12
 
 query II
-SELECT ranges.start_val, n
+SELECT ranges.start_val, value
 FROM ranges
 CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
-WHERE start_val >= 5 AND n <= 7
-ORDER BY start_val, n
+WHERE start_val >= 5 AND value <= 7
+ORDER BY start_val, value
 ----
 5 5
 5 6
@@ -188,7 +188,7 @@ CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
 9
 
 query I
-SELECT SUM(n)
+SELECT SUM(value)
 FROM ranges
 CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
 ----
@@ -206,7 +206,7 @@ ORDER BY start_val
 10 3
 
 query II
-SELECT start_val, SUM(n)
+SELECT start_val, SUM(value)
 FROM ranges
 CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
 GROUP BY start_val
@@ -217,7 +217,7 @@ ORDER BY start_val
 10 33
 
 query RII
-SELECT AVG(n), MAX(n), COUNT(n)
+SELECT AVG(value), MAX(value), COUNT(value)
 FROM ranges
 CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
 ----
@@ -234,12 +234,12 @@ statement ok
 INSERT INTO multipliers VALUES (2), (3);
 
 query IIII
-SELECT ranges.start_val, ranges.end_val, multipliers.mult, n
+SELECT ranges.start_val, ranges.end_val, multipliers.mult, value
 FROM ranges
 CROSS JOIN multipliers
 CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
 WHERE multipliers.mult = 2 AND start_val < 10
-ORDER BY start_val, n
+ORDER BY start_val, value
 ----
 1 3 2 1
 1 3 2 2
@@ -253,10 +253,10 @@ ORDER BY start_val, n
 ##########
 
 query II
-SELECT sub.start_val, n
+SELECT sub.start_val, value
 FROM (SELECT start_val, end_val FROM ranges WHERE start_val < 10) sub
 CROSS JOIN LATERAL batched_generate_series(sub.start_val, sub.end_val)
-ORDER BY sub.start_val, n
+ORDER BY sub.start_val, value
 ----
 1 1
 1 2
@@ -270,10 +270,10 @@ ORDER BY sub.start_val, n
 ##########
 
 query II
-SELECT ranges.start_val, n
+SELECT ranges.start_val, value
 FROM ranges
 CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
-ORDER BY start_val DESC, n DESC
+ORDER BY start_val DESC, value DESC
 LIMIT 5
 ----
 10 12
@@ -283,10 +283,10 @@ LIMIT 5
 5 6
 
 query II
-SELECT ranges.start_val, n
+SELECT ranges.start_val, value
 FROM ranges
 CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
-ORDER BY n ASC
+ORDER BY value ASC
 LIMIT 5
 ----
 1 1
@@ -300,10 +300,10 @@ LIMIT 5
 ##########
 
 query II
-SELECT r.start_val, s.n
+SELECT r.start_val, s.value
 FROM ranges r
 CROSS JOIN LATERAL batched_generate_series(r.start_val, r.end_val) AS s
-ORDER BY r.start_val, s.n
+ORDER BY r.start_val, s.value
 ----
 1 1
 1 2
@@ -320,10 +320,10 @@ ORDER BY r.start_val, s.n
 ##########
 
 query I
-SELECT DISTINCT n
+SELECT DISTINCT value
 FROM ranges
 CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
-ORDER BY n
+ORDER BY value
 ----
 1
 2
@@ -369,17 +369,17 @@ statement ok
 INSERT INTO single_range VALUES (5);
 
 query II
-SELECT val, n
+SELECT val, value
 FROM single_range
 CROSS JOIN LATERAL batched_generate_series(single_range.val, single_range.val)
 ----
 5 5
 
 query II
-SELECT start_val, n
+SELECT start_val, value
 FROM ranges
 CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
-WHERE n > 100
+WHERE value > 100
 ----
 
 ##########
@@ -387,11 +387,11 @@ WHERE n > 100
 ##########
 
 # Test that projection pushdown works with batched table functions
-# The optimizer should push down the projection to only select the 'n' column
+# The optimizer should push down the projection to only select the 'value' column
 
 query I
-SELECT n FROM batched_generate_series(1, 5)
-ORDER BY n
+SELECT value FROM batched_generate_series(1, 5)
+ORDER BY value
 ----
 1
 2
@@ -401,7 +401,7 @@ ORDER BY n
 
 # Test projection pushdown with LIMIT
 query I
-SELECT n FROM batched_generate_series(1, 100) LIMIT 3
+SELECT value FROM batched_generate_series(1, 100) LIMIT 3
 ----
 1
 2
@@ -466,10 +466,10 @@ ORDER BY id, n
 
 # Test projection pushdown with LATERAL
 query I
-SELECT n
+SELECT value
 FROM ranges
 CROSS JOIN LATERAL batched_generate_series(ranges.start_val, ranges.end_val)
-ORDER BY n
+ORDER BY value
 LIMIT 5
 ----
 1
@@ -489,88 +489,95 @@ CREATE TABLE ranges_filter_test (start_val INT, end_val INT);
 statement ok
 INSERT INTO ranges_filter_test VALUES (1, 10), (5, 15), (10, 20);
 
-# Verify filter pushdown in query plan - non-volatile filters should be pushed down
+# Verify filter handling in query plan - filters are not pushed down
 query TT
-EXPLAIN SELECT n
+EXPLAIN SELECT value
 FROM batched_generate_series(1, 20)
-WHERE n > 15 AND n < 18
+WHERE value > 15 AND value < 18
 ----
-logical_plan StandaloneBatchedTableFunction: batched_generate_series(Int64(1), Int64(20)), projection=[0], filters=[batched_generate_series().n > Int32(15), batched_generate_series().n < Int32(18)]
+logical_plan
+01)Filter: batched_generate_series().value > Int64(15) AND batched_generate_series().value < Int64(18)
+02)--StandaloneBatchedTableFunction: batched_generate_series(Int64(1), Int64(20)), projection=[0]
 physical_plan
-01)BatchedTableFunctionExec: function=batched_generate_series, args=[1, 20], projection=[0], filters=[batched_generate_series().n > Int32(15), batched_generate_series().n < Int32(18)]
-02)--PlaceholderRowExec
+01)FilterExec: value@0 > 15 AND value@0 < 18
+02)--RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
+03)----BatchedTableFunctionExec: function=batched_generate_series, args=[1, 20], projection=[0]
+04)------PlaceholderRowExec
 
-# Verify volatile filters are NOT pushed down (they stay as Filter operator)
+# Verify volatile filters stay as Filter operator
 query TT
-EXPLAIN SELECT n
+EXPLAIN SELECT value
 FROM batched_generate_series(1, 20)
-WHERE n > 15 AND random() > 0.5
+WHERE value > 15 AND random() > 0.5
 ----
 logical_plan
-01)Filter: random() > Float64(0.5)
-02)--StandaloneBatchedTableFunction: batched_generate_series(Int64(1), Int64(20)), projection=[0], filters=[batched_generate_series().n > Int32(15)]
+01)Filter: batched_generate_series().value > Int64(15) AND random() > Float64(0.5)
+02)--StandaloneBatchedTableFunction: batched_generate_series(Int64(1), Int64(20)), projection=[0]
 physical_plan
-01)FilterExec: random() > 0.5
+01)FilterExec: value@0 > 15 AND random() > 0.5
 02)--RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
-03)----BatchedTableFunctionExec: function=batched_generate_series, args=[1, 20], projection=[0], filters=[batched_generate_series().n > Int32(15)]
+03)----BatchedTableFunctionExec: function=batched_generate_series, args=[1, 20], projection=[0]
 04)------PlaceholderRowExec
 
 # Verify LATERAL projection pushdown - since generate_series only has 1 column, projection not shown
 query TT
-EXPLAIN SELECT start_val, n
+EXPLAIN SELECT start_val, value
 FROM ranges_filter_test
 CROSS JOIN LATERAL batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
 ----
 logical_plan
-01)Projection: ranges_filter_test.start_val, batched_generate_series().n
+01)Projection: ranges_filter_test.start_val, batched_generate_series().value
 02)--LateralBatchedTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
 03)----TableScan: ranges_filter_test projection=[start_val, end_val]
 physical_plan
-01)ProjectionExec: expr=[start_val@0 as start_val, n@2 as n]
+01)ProjectionExec: expr=[start_val@0 as start_val, value@2 as value]
 02)--BatchedTableFunctionExec: function=batched_generate_series, args=[start_val@0, end_val@1]
 03)----DataSourceExec: partitions=1, partition_sizes=[1]
 
-# Verify LATERAL filter pushdown - filters on table function output ARE pushed down
+# Verify LATERAL filter handling - filters on table function output are not pushed down
 query TT
-EXPLAIN SELECT start_val, n
+EXPLAIN SELECT start_val, value
 FROM ranges_filter_test
 CROSS JOIN LATERAL batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
-WHERE n > 12
+WHERE value > 12
 ----
 logical_plan
-01)Projection: ranges_filter_test.start_val, batched_generate_series().n
-02)--LateralBatchedTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val), filters=[batched_generate_series().n > Int32(12)]
-03)----TableScan: ranges_filter_test projection=[start_val, end_val]
+01)Filter: batched_generate_series().value > Int64(12)
+02)--Projection: ranges_filter_test.start_val, batched_generate_series().value
+03)----LateralBatchedTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
+04)------TableScan: ranges_filter_test projection=[start_val, end_val]
 physical_plan
-01)ProjectionExec: expr=[start_val@0 as start_val, n@2 as n]
-02)--BatchedTableFunctionExec: function=batched_generate_series, args=[start_val@0, end_val@1], filters=[batched_generate_series().n > Int32(12)]
-03)----DataSourceExec: partitions=1, partition_sizes=[1]
+01)FilterExec: value@1 > 12
+02)--RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
+03)----ProjectionExec: expr=[start_val@0 as start_val, value@2 as value]
+04)------BatchedTableFunctionExec: function=batched_generate_series, args=[start_val@0, end_val@1]
+05)--------DataSourceExec: partitions=1, partition_sizes=[1]
 
 # Verify LATERAL filter NOT pushed down when referencing input columns
 query TT
-EXPLAIN SELECT start_val, n
+EXPLAIN SELECT start_val, value
 FROM ranges_filter_test
 CROSS JOIN LATERAL batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
 WHERE start_val > 5
 ----
 logical_plan
 01)Filter: ranges_filter_test.start_val > Int32(5)
-02)--Projection: ranges_filter_test.start_val, batched_generate_series().n
+02)--Projection: ranges_filter_test.start_val, batched_generate_series().value
 03)----LateralBatchedTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
 04)------TableScan: ranges_filter_test projection=[start_val, end_val]
 physical_plan
 01)FilterExec: start_val@0 > 5
 02)--RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
-03)----ProjectionExec: expr=[start_val@0 as start_val, n@2 as n]
+03)----ProjectionExec: expr=[start_val@0 as start_val, value@2 as value]
 04)------BatchedTableFunctionExec: function=batched_generate_series, args=[start_val@0, end_val@1]
 05)--------DataSourceExec: partitions=1, partition_sizes=[1]
 
 # Test filter pushdown on table function output
 query I
-SELECT n
+SELECT value
 FROM batched_generate_series(1, 20)
-WHERE n > 15
-ORDER BY n
+WHERE value > 15
+ORDER BY value
 ----
 16
 17
@@ -580,11 +587,11 @@ ORDER BY n
 
 # Test filter pushdown with LATERAL - filter on function output
 query II
-SELECT start_val, n
+SELECT start_val, value
 FROM ranges_filter_test
 CROSS JOIN LATERAL batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
-WHERE n > 12
-ORDER BY start_val, n
+WHERE value > 12
+ORDER BY start_val, value
 ----
 5 13
 5 14
@@ -600,11 +607,11 @@ ORDER BY start_val, n
 
 # Test filter pushdown with LATERAL - filter on input table
 query II
-SELECT start_val, n
+SELECT start_val, value
 FROM ranges_filter_test
 CROSS JOIN LATERAL batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
 WHERE start_val >= 5
-ORDER BY start_val, n
+ORDER BY start_val, value
 ----
 5 5
 5 6
@@ -631,11 +638,11 @@ ORDER BY start_val, n
 
 # Test filter pushdown with multiple filters
 query II
-SELECT start_val, n
+SELECT start_val, value
 FROM ranges_filter_test
 CROSS JOIN LATERAL batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
-WHERE start_val < 10 AND n >= 8 AND n <= 12
-ORDER BY start_val, n
+WHERE start_val < 10 AND value >= 8 AND value <= 12
+ORDER BY start_val, value
 ----
 1 8
 1 9
@@ -650,7 +657,7 @@ ORDER BY start_val, n
 query I
 SELECT COUNT(*)
 FROM batched_generate_series(1, 100)
-WHERE n <= 10
+WHERE value <= 10
 ----
 10
 
@@ -660,7 +667,7 @@ WHERE n <= 10
 
 # Verify limit pushdown for StandaloneBatchedTableFunction
 query TT
-EXPLAIN SELECT n FROM batched_generate_series(1, 100) LIMIT 5
+EXPLAIN SELECT value FROM batched_generate_series(1, 100) LIMIT 5
 ----
 logical_plan
 01)Limit: skip=0, fetch=5
@@ -672,7 +679,7 @@ physical_plan
 
 # Test limit actually works
 query I
-SELECT n FROM batched_generate_series(1, 100) LIMIT 5
+SELECT value FROM batched_generate_series(1, 100) LIMIT 5
 ----
 1
 2
@@ -682,18 +689,18 @@ SELECT n FROM batched_generate_series(1, 100) LIMIT 5
 
 # Verify limit with LATERAL (limit stays above projection)
 query TT
-EXPLAIN SELECT start_val, n
+EXPLAIN SELECT start_val, value
 FROM ranges_filter_test
 CROSS JOIN LATERAL batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
 LIMIT 3
 ----
 logical_plan
-01)Projection: ranges_filter_test.start_val, batched_generate_series().n
+01)Projection: ranges_filter_test.start_val, batched_generate_series().value
 02)--Limit: skip=0, fetch=3
 03)----LateralBatchedTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
 04)------TableScan: ranges_filter_test projection=[start_val, end_val]
 physical_plan
-01)ProjectionExec: expr=[start_val@0 as start_val, n@2 as n]
+01)ProjectionExec: expr=[start_val@0 as start_val, value@2 as value]
 02)--GlobalLimitExec: skip=0, fetch=3
 03)----BatchedTableFunctionExec: function=batched_generate_series, args=[start_val@0, end_val@1]
 04)------DataSourceExec: partitions=1, partition_sizes=[1]

From 90f7c6358a8e15da14ff1e9ce5954400aa0014fa Mon Sep 17 00:00:00 2001
From: bubulalabu <bubulalabububu@gmail.com>
Date: Wed, 10 Dec 2025 11:29:00 +0100
Subject: [PATCH 10/18] added automatic type coercion for batched table
 functions

---
 .../catalog/src/default_table_source.rs       |  7 +-
 datafusion/expr/src/table_source.rs           | 13 +++-
 .../src/generate_series_batched.rs            | 20 +----
 .../optimizer/src/analyzer/type_coercion.rs   | 77 +++++++++++++++++++
 .../sqllogictest/test_files/lateral.slt       | 25 +++---
 5 files changed, 112 insertions(+), 30 deletions(-)

diff --git a/datafusion/catalog/src/default_table_source.rs b/datafusion/catalog/src/default_table_source.rs
index e9177184ecd11..d27e07610d0e0 100644
--- a/datafusion/catalog/src/default_table_source.rs
+++ b/datafusion/catalog/src/default_table_source.rs
@@ -25,7 +25,8 @@ use crate::{BatchedTableFunctionImpl, TableProvider};
 use arrow::datatypes::SchemaRef;
 use datafusion_common::{internal_err, Constraints};
 use datafusion_expr::{
-    BatchedTableFunctionSource, Expr, TableProviderFilterPushDown, TableSource, TableType,
+    BatchedTableFunctionSource, Expr, Signature, TableProviderFilterPushDown, TableSource,
+    TableType,
 };
 
 /// Implements [`TableSource`] for a [`TableProvider`]
@@ -193,6 +194,10 @@ impl BatchedTableFunctionSource for DefaultBatchedTableFunctionSource {
         }
     }
 
+    fn signature(&self) -> &Signature {
+        self.function_impl.signature()
+    }
+
     fn supports_filters_pushdown(
         &self,
         filters: &[&Expr],
diff --git a/datafusion/expr/src/table_source.rs b/datafusion/expr/src/table_source.rs
index 2f4ad8264f738..932e4eb501352 100644
--- a/datafusion/expr/src/table_source.rs
+++ b/datafusion/expr/src/table_source.rs
@@ -17,7 +17,7 @@
 
 //! Table source
 
-use crate::{Expr, LogicalPlan};
+use crate::{Expr, LogicalPlan, Signature, Volatility};
 
 use arrow::datatypes::SchemaRef;
 use datafusion_common::{Constraints, Result};
@@ -151,6 +151,17 @@ pub trait BatchedTableFunctionSource: Sync + Send {
     /// Get a reference to the schema for this table function's output
     fn schema(&self) -> SchemaRef;
 
+    /// Get the signature of this table function for type coercion
+    ///
+    /// The default implementation returns a permissive signature that accepts
+    /// any types and any number of arguments. Custom implementations should
+    /// override this to provide proper type checking and coercion.
+    fn signature(&self) -> &Signature {
+        // Default permissive signature for backward compatibility
+        static DEFAULT_SIGNATURE: std::sync::OnceLock<Signature> = std::sync::OnceLock::new();
+        DEFAULT_SIGNATURE.get_or_init(|| Signature::any(usize::MAX, Volatility::Volatile))
+    }
+
     /// Tests whether the batched table function can make use of any or all filter
     /// expressions to optimize data retrieval. Only non-volatile expressions are passed
     /// to this function.
diff --git a/datafusion/functions-table/src/generate_series_batched.rs b/datafusion/functions-table/src/generate_series_batched.rs
index 882021593d6bb..47d4c6b48384c 100644
--- a/datafusion/functions-table/src/generate_series_batched.rs
+++ b/datafusion/functions-table/src/generate_series_batched.rs
@@ -98,21 +98,9 @@ impl BatchedTableFunctionImpl for GenerateSeriesFunction {
             return exec_err!("generate_series expects 2 arguments, got {}", args.len());
         }
 
-        // Cast inputs to Int64 if needed (for coercible signature support)
-        use arrow::compute::cast;
-        let start_array = if args[0].data_type() != &DataType::Int64 {
-            cast(&args[0], &DataType::Int64)?
-        } else {
-            Arc::clone(&args[0])
-        };
-        let end_array = if args[1].data_type() != &DataType::Int64 {
-            cast(&args[1], &DataType::Int64)?
-        } else {
-            Arc::clone(&args[1])
-        };
-
-        let start_array = start_array.as_primitive::<arrow::datatypes::Int64Type>();
-        let end_array = end_array.as_primitive::<arrow::datatypes::Int64Type>();
+        // Type coercion analyzer ensures arguments are already Int64
+        let start_array = args[0].as_primitive::<arrow::datatypes::Int64Type>();
+        let end_array = args[1].as_primitive::<arrow::datatypes::Int64Type>();
 
         if start_array.len() != end_array.len() {
             return exec_err!("start and end arrays must have same length");
@@ -244,7 +232,7 @@ mod tests {
     fn test_function_metadata() {
         let function = GenerateSeriesFunction::new();
 
-        assert_eq!(function.name(), "generate_series");
+        assert_eq!(function.name(), "batched_generate_series");
 
         let sig = function.signature();
         assert_eq!(sig.volatility, datafusion_expr::Volatility::Immutable);
diff --git a/datafusion/optimizer/src/analyzer/type_coercion.rs b/datafusion/optimizer/src/analyzer/type_coercion.rs
index bc317e9c201ce..095b7d257e49d 100644
--- a/datafusion/optimizer/src/analyzer/type_coercion.rs
+++ b/datafusion/optimizer/src/analyzer/type_coercion.rs
@@ -33,6 +33,7 @@ use datafusion_common::{
     exec_err, internal_datafusion_err, internal_err, not_impl_err, plan_datafusion_err,
     plan_err,
 };
+use datafusion_expr::BatchedTableFunctionSource;
 use datafusion_expr::expr::{
     self, AggregateFunctionParams, Alias, Between, BinaryExpr, Case, Exists, InList,
     InSubquery, Like, ScalarFunction, Sort, WindowFunction,
@@ -170,6 +171,12 @@ impl<'a> TypeCoercionRewriter<'a> {
             LogicalPlan::Join(join) => self.coerce_join(join),
             LogicalPlan::Union(union) => Self::coerce_union(union),
             LogicalPlan::Limit(limit) => Self::coerce_limit(limit),
+            LogicalPlan::StandaloneBatchedTableFunction(tf) => {
+                self.coerce_standalone_batched_table_function(tf)
+            }
+            LogicalPlan::LateralBatchedTableFunction(tf) => {
+                self.coerce_lateral_batched_table_function(tf)
+            }
             _ => Ok(plan),
         }
     }
@@ -273,6 +280,45 @@ impl<'a> TypeCoercionRewriter<'a> {
         }))
     }
 
+    /// Coerce arguments for standalone batched table functions
+    ///
+    /// Standalone mode means the table function is called with constant arguments
+    /// (not referencing any input tables), so we use an empty schema for type checking.
+    fn coerce_standalone_batched_table_function(
+        &mut self,
+        mut tf: datafusion_expr::logical_plan::StandaloneBatchedTableFunction,
+    ) -> Result<LogicalPlan> {
+        // For standalone mode, args are evaluated with empty schema (no input columns)
+        let empty_schema = DFSchema::empty();
+
+        tf.args = coerce_arguments_for_batched_table_function(
+            tf.args,
+            &empty_schema,
+            &tf.source,
+        )?;
+
+        Ok(LogicalPlan::StandaloneBatchedTableFunction(tf))
+    }
+
+    /// Coerce arguments for lateral batched table functions
+    ///
+    /// Lateral mode means the table function can reference columns from preceding tables
+    /// in the FROM clause, so we use the current schema context for type checking.
+    fn coerce_lateral_batched_table_function(
+        &mut self,
+        mut tf: datafusion_expr::logical_plan::LateralBatchedTableFunction,
+    ) -> Result<LogicalPlan> {
+        // For lateral mode, args can reference input columns
+        // Use the schema from the rewriter which includes input columns
+        tf.args = coerce_arguments_for_batched_table_function(
+            tf.args,
+            self.schema,
+            &tf.source,
+        )?;
+
+        Ok(LogicalPlan::LateralBatchedTableFunction(tf))
+    }
+
     fn coerce_join_filter(&self, expr: Expr) -> Result<Expr> {
         let expr_type = expr.get_type(self.schema)?;
         match expr_type {
@@ -838,6 +884,37 @@ fn coerce_arguments_for_signature_with_aggregate_udf(
         .collect()
 }
 
+/// Coerce arguments for batched table functions based on their signature
+///
+/// This function performs type coercion for table function arguments similar to how
+/// scalar and aggregate functions are coerced. It uses the function's signature to
+/// determine the target types and inserts CAST expressions where needed.
+fn coerce_arguments_for_batched_table_function(
+    expressions: Vec<Expr>,
+    schema: &DFSchema,
+    source: &Arc<dyn BatchedTableFunctionSource>,
+) -> Result<Vec<Expr>> {
+    if expressions.is_empty() {
+        return Ok(expressions);
+    }
+
+    let signature = source.signature();
+    let current_types = expressions
+        .iter()
+        .map(|e| e.get_type(schema))
+        .collect::<Result<Vec<_>>>()?;
+
+    // Use the same type coercion logic as scalar functions
+    use datafusion_expr::type_coercion::functions::data_types;
+    let new_types = data_types(source.name(), &current_types, signature)?;
+
+    expressions
+        .into_iter()
+        .enumerate()
+        .map(|(i, expr)| expr.cast_to(&new_types[i], schema))
+        .collect()
+}
+
 fn coerce_case_expression(case: Case, schema: &DFSchema) -> Result<Case> {
     // Given expressions like:
     //
diff --git a/datafusion/sqllogictest/test_files/lateral.slt b/datafusion/sqllogictest/test_files/lateral.slt
index e83340c827f19..2e49f64966a05 100644
--- a/datafusion/sqllogictest/test_files/lateral.slt
+++ b/datafusion/sqllogictest/test_files/lateral.slt
@@ -413,7 +413,7 @@ SELECT value FROM batched_generate_series(1, 100) LIMIT 3
 
 # Standalone: projection of subset of columns from 5-column function
 query II
-SELECT n_squared, n_cubed FROM batched_multi_column(1, 3)
+SELECT n_squared, n_cubed FROM batched_multi_column(CAST(1 AS INT), CAST(3 AS INT))
 ORDER BY n_squared
 ----
 1 1
@@ -422,7 +422,7 @@ ORDER BY n_squared
 
 # Standalone: non-sequential column selection
 query III
-SELECT n, n_cubed, n_mod_3 FROM batched_multi_column(1, 5)
+SELECT n, n_cubed, n_mod_3 FROM batched_multi_column(CAST(1 AS INT), CAST(5 AS INT))
 ORDER BY n
 ----
 1 1 1
@@ -442,7 +442,7 @@ INSERT INTO test_lateral_multi VALUES (1, 2), (2, 3);
 query III
 SELECT id, n_squared, n_cubed
 FROM test_lateral_multi
-CROSS JOIN LATERAL batched_multi_column(1, test_lateral_multi.max_val)
+CROSS JOIN LATERAL batched_multi_column(CAST(1 AS INT), test_lateral_multi.max_val)
 ORDER BY id, n_squared
 ----
 1 1 1
@@ -455,7 +455,7 @@ ORDER BY id, n_squared
 query III
 SELECT id, n, n_mod_3
 FROM test_lateral_multi
-CROSS JOIN LATERAL batched_multi_column(1, test_lateral_multi.max_val)
+CROSS JOIN LATERAL batched_multi_column(CAST(1 AS INT), test_lateral_multi.max_val)
 ORDER BY id, n
 ----
 1 1 1
@@ -520,6 +520,7 @@ physical_plan
 04)------PlaceholderRowExec
 
 # Verify LATERAL projection pushdown - since generate_series only has 1 column, projection not shown
+# Note: Type coercion automatically adds CAST expressions to convert Int32 to Int64
 query TT
 EXPLAIN SELECT start_val, value
 FROM ranges_filter_test
@@ -527,11 +528,11 @@ CROSS JOIN LATERAL batched_generate_series(ranges_filter_test.start_val, ranges_
 ----
 logical_plan
 01)Projection: ranges_filter_test.start_val, batched_generate_series().value
-02)--LateralBatchedTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
+02)--LateralBatchedTableFunction: batched_generate_series(CAST(ranges_filter_test.start_val AS Int64), CAST(ranges_filter_test.end_val AS Int64))
 03)----TableScan: ranges_filter_test projection=[start_val, end_val]
 physical_plan
 01)ProjectionExec: expr=[start_val@0 as start_val, value@2 as value]
-02)--BatchedTableFunctionExec: function=batched_generate_series, args=[start_val@0, end_val@1]
+02)--BatchedTableFunctionExec: function=batched_generate_series, args=[CAST(start_val@0 AS Int64), CAST(end_val@1 AS Int64)]
 03)----DataSourceExec: partitions=1, partition_sizes=[1]
 
 # Verify LATERAL filter handling - filters on table function output are not pushed down
@@ -544,13 +545,13 @@ WHERE value > 12
 logical_plan
 01)Filter: batched_generate_series().value > Int64(12)
 02)--Projection: ranges_filter_test.start_val, batched_generate_series().value
-03)----LateralBatchedTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
+03)----LateralBatchedTableFunction: batched_generate_series(CAST(ranges_filter_test.start_val AS Int64), CAST(ranges_filter_test.end_val AS Int64))
 04)------TableScan: ranges_filter_test projection=[start_val, end_val]
 physical_plan
 01)FilterExec: value@1 > 12
 02)--RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
 03)----ProjectionExec: expr=[start_val@0 as start_val, value@2 as value]
-04)------BatchedTableFunctionExec: function=batched_generate_series, args=[start_val@0, end_val@1]
+04)------BatchedTableFunctionExec: function=batched_generate_series, args=[CAST(start_val@0 AS Int64), CAST(end_val@1 AS Int64)]
 05)--------DataSourceExec: partitions=1, partition_sizes=[1]
 
 # Verify LATERAL filter NOT pushed down when referencing input columns
@@ -563,13 +564,13 @@ WHERE start_val > 5
 logical_plan
 01)Filter: ranges_filter_test.start_val > Int32(5)
 02)--Projection: ranges_filter_test.start_val, batched_generate_series().value
-03)----LateralBatchedTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
+03)----LateralBatchedTableFunction: batched_generate_series(CAST(ranges_filter_test.start_val AS Int64), CAST(ranges_filter_test.end_val AS Int64))
 04)------TableScan: ranges_filter_test projection=[start_val, end_val]
 physical_plan
 01)FilterExec: start_val@0 > 5
 02)--RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
 03)----ProjectionExec: expr=[start_val@0 as start_val, value@2 as value]
-04)------BatchedTableFunctionExec: function=batched_generate_series, args=[start_val@0, end_val@1]
+04)------BatchedTableFunctionExec: function=batched_generate_series, args=[CAST(start_val@0 AS Int64), CAST(end_val@1 AS Int64)]
 05)--------DataSourceExec: partitions=1, partition_sizes=[1]
 
 # Test filter pushdown on table function output
@@ -697,12 +698,12 @@ LIMIT 3
 logical_plan
 01)Projection: ranges_filter_test.start_val, batched_generate_series().value
 02)--Limit: skip=0, fetch=3
-03)----LateralBatchedTableFunction: batched_generate_series(ranges_filter_test.start_val, ranges_filter_test.end_val)
+03)----LateralBatchedTableFunction: batched_generate_series(CAST(ranges_filter_test.start_val AS Int64), CAST(ranges_filter_test.end_val AS Int64))
 04)------TableScan: ranges_filter_test projection=[start_val, end_val]
 physical_plan
 01)ProjectionExec: expr=[start_val@0 as start_val, value@2 as value]
 02)--GlobalLimitExec: skip=0, fetch=3
-03)----BatchedTableFunctionExec: function=batched_generate_series, args=[start_val@0, end_val@1]
+03)----BatchedTableFunctionExec: function=batched_generate_series, args=[CAST(start_val@0 AS Int64), CAST(end_val@1 AS Int64)]
 04)------DataSourceExec: partitions=1, partition_sizes=[1]
 
 # Cleanup filter test table

From ab885f53aadd0737a4fbd34f4576eee7f5b0c613 Mon Sep 17 00:00:00 2001
From: bubulalabu <bubulalabububu@gmail.com>
Date: Wed, 10 Dec 2025 12:14:27 +0100
Subject: [PATCH 11/18] added automatic type coercion for batched table
 functions

---
 datafusion/expr/src/table_source.rs | 13 ++++---------
 1 file changed, 4 insertions(+), 9 deletions(-)

diff --git a/datafusion/expr/src/table_source.rs b/datafusion/expr/src/table_source.rs
index 932e4eb501352..7df87d52df2d0 100644
--- a/datafusion/expr/src/table_source.rs
+++ b/datafusion/expr/src/table_source.rs
@@ -17,7 +17,7 @@
 
 //! Table source
 
-use crate::{Expr, LogicalPlan, Signature, Volatility};
+use crate::{Expr, LogicalPlan, Signature};
 
 use arrow::datatypes::SchemaRef;
 use datafusion_common::{Constraints, Result};
@@ -153,14 +153,9 @@ pub trait BatchedTableFunctionSource: Sync + Send {
 
     /// Get the signature of this table function for type coercion
     ///
-    /// The default implementation returns a permissive signature that accepts
-    /// any types and any number of arguments. Custom implementations should
-    /// override this to provide proper type checking and coercion.
-    fn signature(&self) -> &Signature {
-        // Default permissive signature for backward compatibility
-        static DEFAULT_SIGNATURE: std::sync::OnceLock<Signature> = std::sync::OnceLock::new();
-        DEFAULT_SIGNATURE.get_or_init(|| Signature::any(usize::MAX, Volatility::Volatile))
-    }
+    /// This signature is used by the type coercion analyzer to automatically
+    /// insert CAST expressions when arguments don't match the expected types.
+    fn signature(&self) -> &Signature;
 
     /// Tests whether the batched table function can make use of any or all filter
     /// expressions to optimize data retrieval. Only non-volatile expressions are passed

From 6183c0422e58ce5fa655715f0f4b935e09ae52e7 Mon Sep 17 00:00:00 2001
From: bubulalabu <bubulalabububu@gmail.com>
Date: Thu, 11 Dec 2025 10:46:26 +0100
Subject: [PATCH 12/18] fmt and clippy

---
 datafusion/catalog/src/default_table_source.rs       | 4 ++--
 datafusion/expr/src/logical_plan/plan.rs             | 4 +++-
 datafusion/optimizer/src/analyzer/type_coercion.rs   | 2 +-
 datafusion/optimizer/src/optimize_projections/mod.rs | 4 +---
 datafusion/sqllogictest/src/test_context.rs          | 2 +-
 5 files changed, 8 insertions(+), 8 deletions(-)

diff --git a/datafusion/catalog/src/default_table_source.rs b/datafusion/catalog/src/default_table_source.rs
index d27e07610d0e0..52638fa8e91c2 100644
--- a/datafusion/catalog/src/default_table_source.rs
+++ b/datafusion/catalog/src/default_table_source.rs
@@ -25,8 +25,8 @@ use crate::{BatchedTableFunctionImpl, TableProvider};
 use arrow::datatypes::SchemaRef;
 use datafusion_common::{internal_err, Constraints};
 use datafusion_expr::{
-    BatchedTableFunctionSource, Expr, Signature, TableProviderFilterPushDown, TableSource,
-    TableType,
+    BatchedTableFunctionSource, Expr, Signature, TableProviderFilterPushDown,
+    TableSource, TableType,
 };
 
 /// Implements [`TableSource`] for a [`TableProvider`]
diff --git a/datafusion/expr/src/logical_plan/plan.rs b/datafusion/expr/src/logical_plan/plan.rs
index 35e58618467c0..e520b98b949a0 100644
--- a/datafusion/expr/src/logical_plan/plan.rs
+++ b/datafusion/expr/src/logical_plan/plan.rs
@@ -362,7 +362,9 @@ impl LogicalPlan {
                 ..
             }) => schema,
             LogicalPlan::StandaloneBatchedTableFunction(
-                StandaloneBatchedTableFunction { projected_schema, .. },
+                StandaloneBatchedTableFunction {
+                    projected_schema, ..
+                },
             ) => projected_schema,
             LogicalPlan::RecursiveQuery(RecursiveQuery { static_term, .. }) => {
                 // we take the schema of the static term as the schema of the entire recursive query
diff --git a/datafusion/optimizer/src/analyzer/type_coercion.rs b/datafusion/optimizer/src/analyzer/type_coercion.rs
index 095b7d257e49d..2d7a554c67a29 100644
--- a/datafusion/optimizer/src/analyzer/type_coercion.rs
+++ b/datafusion/optimizer/src/analyzer/type_coercion.rs
@@ -33,7 +33,6 @@ use datafusion_common::{
     exec_err, internal_datafusion_err, internal_err, not_impl_err, plan_datafusion_err,
     plan_err,
 };
-use datafusion_expr::BatchedTableFunctionSource;
 use datafusion_expr::expr::{
     self, AggregateFunctionParams, Alias, Between, BinaryExpr, Case, Exists, InList,
     InSubquery, Like, ScalarFunction, Sort, WindowFunction,
@@ -50,6 +49,7 @@ use datafusion_expr::type_coercion::other::{
 };
 use datafusion_expr::type_coercion::{is_datetime, is_utf8_or_utf8view_or_large_utf8};
 use datafusion_expr::utils::merge_schema;
+use datafusion_expr::BatchedTableFunctionSource;
 use datafusion_expr::{
     AggregateUDF, Expr, ExprSchemable, Join, Limit, LogicalPlan, Operator, Projection,
     ScalarUDF, Union, WindowFrame, WindowFrameBound, WindowFrameUnits, is_false,
diff --git a/datafusion/optimizer/src/optimize_projections/mod.rs b/datafusion/optimizer/src/optimize_projections/mod.rs
index 044883f825671..f3049e24e1b4d 100644
--- a/datafusion/optimizer/src/optimize_projections/mod.rs
+++ b/datafusion/optimizer/src/optimize_projections/mod.rs
@@ -464,9 +464,7 @@ fn optimize_projections(
                 Some(existing_proj) => {
                     indices.into_mapped_indices(|idx| existing_proj[idx])
                 }
-                None => {
-                    indices.into_inner()
-                }
+                None => indices.into_inner(),
             };
 
             let new_projected_schema = {
diff --git a/datafusion/sqllogictest/src/test_context.rs b/datafusion/sqllogictest/src/test_context.rs
index 1b524f6165d21..1f4d4ec907a46 100644
--- a/datafusion/sqllogictest/src/test_context.rs
+++ b/datafusion/sqllogictest/src/test_context.rs
@@ -33,7 +33,6 @@ use datafusion::catalog::{
     batched_function::helpers::materialized_batch_stream, BatchedTableFunctionImpl,
     CatalogProvider, MemoryCatalogProvider, MemorySchemaProvider, Session,
 };
-use datafusion_functions_table::generate_series_batched::GenerateSeriesFunction;
 use datafusion::common::{not_impl_err, DataFusionError, Result};
 use datafusion::functions::math::abs;
 use datafusion::logical_expr::async_udf::{AsyncScalarUDF, AsyncScalarUDFImpl};
@@ -47,6 +46,7 @@ use datafusion::{
     datasource::{MemTable, TableProvider, TableType},
     prelude::{CsvReadOptions, SessionContext},
 };
+use datafusion_functions_table::generate_series_batched::GenerateSeriesFunction;
 
 use crate::is_spark_path;
 use async_trait::async_trait;

From f2e39368fda3570cf8fb191d4eccba347adfa7a3 Mon Sep 17 00:00:00 2001
From: bubulalabu <bubulalabububu@gmail.com>
Date: Fri, 12 Dec 2025 10:51:56 +0100
Subject: [PATCH 13/18] added projection verified coercion

---
 .../catalog/src/batched_function/exec.rs      |  39 ++-
 datafusion/core/src/physical_planner.rs       |  14 +-
 datafusion/sqllogictest/src/test_context.rs   | 278 +++++++++++++++++-
 .../sqllogictest/test_files/lateral.slt       |  89 ++++++
 4 files changed, 410 insertions(+), 10 deletions(-)

diff --git a/datafusion/catalog/src/batched_function/exec.rs b/datafusion/catalog/src/batched_function/exec.rs
index a86d661b59214..f497570ba3917 100644
--- a/datafusion/catalog/src/batched_function/exec.rs
+++ b/datafusion/catalog/src/batched_function/exec.rs
@@ -257,6 +257,7 @@ impl ExecutionPlan for BatchedTableFunctionExec {
             args: self.args.clone(),
             input_stream,
             schema: Arc::clone(&self.projected_schema),
+            table_function_schema: Arc::clone(&self.table_function_schema),
             mode: self.mode,
             projection: self.projection.clone(),
             filters: self.filters.clone(),
@@ -286,6 +287,7 @@ struct BatchedTableFunctionStream {
     args: Vec<Arc<dyn PhysicalExpr>>,
     input_stream: SendableRecordBatchStream,
     schema: SchemaRef,
+    table_function_schema: SchemaRef,
     mode: BatchedTableFunctionMode,
     projection: Option<Vec<usize>>,
     filters: Vec<Expr>,
@@ -300,13 +302,46 @@ impl BatchedTableFunctionStream {
         chunk: BatchResultChunk,
         input_batch: &RecordBatch,
     ) -> Result<RecordBatch> {
+        let chunk_schema = chunk.output.schema();
+
+        // Apply projection if function didn't apply it
+        // This follows the same pattern as MemoryStream and other ExecutionPlans
+        let function_output = if let Some(proj) = &self.projection {
+            // Compute what the projected function schema should be
+            let projected_func_schema = self.table_function_schema.project(proj)?;
+
+            // Check if function already applied projection by comparing schemas
+            if chunk_schema.as_ref() == self.table_function_schema.as_ref() {
+                // Function returned full schema, apply projection automatically
+                chunk.output.project(proj)?
+            } else if chunk_schema.as_ref() == &projected_func_schema {
+                // Function already applied projection (optimization)
+                chunk.output
+            } else {
+                return exec_err!(
+                    "Function '{}' returned unexpected schema. Expected either full schema ({} columns: {:?}) or projected schema ({} columns: {:?}), got {} columns: {:?}. Projection: {:?}",
+                    self.func.name(),
+                    self.table_function_schema.fields().len(),
+                    self.table_function_schema.fields().iter().map(|f| f.name()).collect::<Vec<_>>(),
+                    projected_func_schema.fields().len(),
+                    projected_func_schema.fields().iter().map(|f| f.name()).collect::<Vec<_>>(),
+                    chunk_schema.fields().len(),
+                    chunk_schema.fields().iter().map(|f| f.name()).collect::<Vec<_>>(),
+                    self.projection
+                );
+            }
+        } else {
+            // No projection requested
+            chunk.output
+        };
+
         match self.mode {
             BatchedTableFunctionMode::Lateral => combine_lateral_result(
                 input_batch,
-                &chunk.output,
+                &function_output,
                 &chunk.input_row_indices,
             ),
-            BatchedTableFunctionMode::Standalone => Ok(chunk.output),
+            BatchedTableFunctionMode::Standalone => Ok(function_output),
         }
     }
 }
diff --git a/datafusion/core/src/physical_planner.rs b/datafusion/core/src/physical_planner.rs
index 85b639c168675..30ecd0a1de7e1 100644
--- a/datafusion/core/src/physical_planner.rs
+++ b/datafusion/core/src/physical_planner.rs
@@ -506,9 +506,9 @@ impl DefaultPhysicalPlanner {
             LogicalPlan::StandaloneBatchedTableFunction(
                 StandaloneBatchedTableFunction {
                     function_name,
-                    source: _source,
+                    source,
                     args,
-                    schema,
+                    schema: _schema,
                     projection,
                     projected_schema,
                     filters,
@@ -530,7 +530,8 @@ impl DefaultPhysicalPlanner {
                     .map(|e| self.create_physical_expr(e, &empty_schema, session_state))
                     .collect::<Result<Vec<_>>>()?;
 
-                let full_function_schema = Arc::clone(schema.inner());
+                // Get full schema from source (not from logical plan which may be projected)
+                let full_function_schema = source.schema();
                 let projected_function_schema = Arc::clone(projected_schema.inner());
 
                 // All filters have been pre-filtered by the logical optimizer
@@ -1558,10 +1559,10 @@ impl DefaultPhysicalPlanner {
             LogicalPlan::LateralBatchedTableFunction(LateralBatchedTableFunction {
                 input,
                 function_name,
-                source: _source,
+                source,
                 args,
                 schema,
-                table_function_schema,
+                table_function_schema: _table_function_schema,
                 projection,
                 filters,
                 fetch,
@@ -1583,7 +1584,8 @@ impl DefaultPhysicalPlanner {
                     .collect::<Result<Vec<_>>>()?;
 
                 let combined_schema = Arc::clone(schema.inner());
-                let function_schema = Arc::clone(table_function_schema.inner());
+                // Get full schema from source (not from logical plan which may be projected)
+                let function_schema = source.schema();
 
                 // All filters have been pre-filtered by the logical optimizer
                 // based on supports_filters_pushdown(). Just pass them all through.
diff --git a/datafusion/sqllogictest/src/test_context.rs b/datafusion/sqllogictest/src/test_context.rs
index 1f4d4ec907a46..6adfba9890857 100644
--- a/datafusion/sqllogictest/src/test_context.rs
+++ b/datafusion/sqllogictest/src/test_context.rs
@@ -23,8 +23,9 @@ use std::path::Path;
 use std::sync::Arc;
 
 use arrow::array::{
-    Array, ArrayRef, AsArray, BinaryArray, Float64Array, Int32Array, LargeBinaryArray,
-    LargeStringArray, StringArray, TimestampNanosecondArray, UnionArray,
+    Array, ArrayRef, AsArray, BinaryArray, Float64Array, Int32Array, Int64Array,
+    LargeBinaryArray, LargeStringArray, StringArray, TimestampNanosecondArray,
+    UnionArray,
 };
 use arrow::buffer::ScalarBuffer;
 use arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit, UnionFields};
@@ -155,6 +156,8 @@ impl TestContext {
                 );
                 drop(state);
                 register_multi_column_batched_function(test_ctx.session_ctx());
+                register_multi_column_no_projection_function(test_ctx.session_ctx());
+                register_coercion_test_functions(test_ctx.session_ctx());
             }
             _ => {
                 info!("Using default SessionContext");
@@ -672,3 +675,274 @@ fn register_multi_column_batched_function(ctx: &SessionContext) {
         Arc::new(MultiColumnBatchedFn::new()),
     );
 }
+
+fn register_multi_column_no_projection_function(ctx: &SessionContext) {
+    /// Multi-column batched table function that does NOT apply projection
+    /// This demonstrates that DataFusion applies projection automatically
+    #[derive(Debug)]
+    struct MultiColumnNoProjFn {
+        signature: Signature,
+    }
+
+    impl MultiColumnNoProjFn {
+        fn new() -> Self {
+            Self {
+                signature: Signature::exact(
+                    vec![DataType::Int64, DataType::Int64],
+                    Volatility::Immutable,
+                ),
+            }
+        }
+    }
+
+    #[async_trait::async_trait]
+    impl BatchedTableFunctionImpl for MultiColumnNoProjFn {
+        fn name(&self) -> &str {
+            "batched_multi_column_no_proj"
+        }
+
+        fn signature(&self) -> &Signature {
+            &self.signature
+        }
+
+        fn return_type(&self, _arg_types: &[DataType]) -> Result<Schema> {
+            Ok(Schema::new(vec![
+                Field::new("n", DataType::Int64, false),
+                Field::new("n_squared", DataType::Int64, false),
+                Field::new("n_cubed", DataType::Int64, false),
+            ]))
+        }
+
+        async fn invoke_batch(
+            &self,
+            args: &[ArrayRef],
+            _projection: Option<&[usize]>, // ← Ignored! DataFusion will handle it
+            _filters: &[datafusion::prelude::Expr],
+            _limit: Option<usize>,
+        ) -> Result<datafusion::catalog::BatchResultStream> {
+            if args.len() != 2 {
+                return Err(DataFusionError::Internal(
+                    "Expected exactly 2 arguments (start, stop)".to_string(),
+                ));
+            }
+
+            let start_array = args[0].as_primitive::<arrow::datatypes::Int64Type>();
+            let stop_array = args[1].as_primitive::<arrow::datatypes::Int64Type>();
+
+            let mut n_values = Vec::new();
+            let mut n_squared_values = Vec::new();
+            let mut n_cubed_values = Vec::new();
+            let mut input_row_indices = Vec::new();
+
+            for row_idx in 0..start_array.len() {
+                let start = start_array.value(row_idx);
+                let stop = stop_array.value(row_idx);
+
+                for n in start..=stop {
+                    n_values.push(n);
+                    n_squared_values.push(n * n);
+                    n_cubed_values.push(n * n * n);
+                    input_row_indices.push(row_idx as u32);
+                }
+            }
+
+            // Always return ALL columns - projection will be applied automatically by exec
+            let schema = Arc::new(self.return_type(&[DataType::Int64, DataType::Int64])?);
+            let output_batch = RecordBatch::try_new(
+                schema,
+                vec![
+                    Arc::new(Int64Array::from(n_values)),
+                    Arc::new(Int64Array::from(n_squared_values)),
+                    Arc::new(Int64Array::from(n_cubed_values)),
+                ],
+            )?;
+
+            Ok(materialized_batch_stream(output_batch, input_row_indices))
+        }
+    }
+
+    let state_ref = ctx.state_ref();
+    let mut state = state_ref.write();
+    state.register_batched_table_function(
+        "batched_multi_column_no_proj",
+        Arc::new(MultiColumnNoProjFn::new()),
+    );
+}
+
+fn register_coercion_test_functions(ctx: &SessionContext) {
+    /// Test function with Signature::exact for Int64
+    /// Tests that NULL → Int64 coercion works with exact signature
+    #[derive(Debug)]
+    struct CoercionExactFn {
+        signature: Signature,
+    }
+
+    impl CoercionExactFn {
+        fn new() -> Self {
+            Self {
+                signature: Signature::exact(
+                    vec![DataType::Int64, DataType::Int64],
+                    Volatility::Immutable,
+                ),
+            }
+        }
+    }
+
+    #[async_trait::async_trait]
+    impl BatchedTableFunctionImpl for CoercionExactFn {
+        fn name(&self) -> &str {
+            "batched_coercion_exact"
+        }
+
+        fn signature(&self) -> &Signature {
+            &self.signature
+        }
+
+        fn return_type(&self, _arg_types: &[DataType]) -> Result<Schema> {
+            Ok(Schema::new(vec![Field::new(
+                "result",
+                DataType::Int64,
+                false,
+            )]))
+        }
+
+        async fn invoke_batch(
+            &self,
+            args: &[ArrayRef],
+            _projection: Option<&[usize]>,
+            _filters: &[datafusion::prelude::Expr],
+            _limit: Option<usize>,
+        ) -> Result<datafusion::catalog::BatchResultStream> {
+            if args.len() != 2 {
+                return Err(DataFusionError::Internal(
+                    "Expected exactly 2 arguments".to_string(),
+                ));
+            }
+
+            let a_array = args[0].as_primitive::<arrow::datatypes::Int64Type>();
+            let b_array = args[1].as_primitive::<arrow::datatypes::Int64Type>();
+
+            if a_array.len() != b_array.len() {
+                return Err(DataFusionError::Internal(
+                    "Arguments must have same length".to_string(),
+                ));
+            }
+
+            let mut results = Vec::new();
+            let mut input_row_indices = Vec::new();
+
+            for idx in 0..a_array.len() {
+                let a = a_array.value(idx);
+                let b = b_array.value(idx);
+                results.push(a + b);
+                input_row_indices.push(idx as u32);
+            }
+
+            let schema = Arc::new(self.return_type(&[DataType::Int64, DataType::Int64])?);
+            let output_batch = RecordBatch::try_new(
+                Arc::clone(&schema),
+                vec![Arc::new(Int64Array::from(results))],
+            )?;
+
+            Ok(materialized_batch_stream(output_batch, input_row_indices))
+        }
+    }
+
+    /// Test function with Signature::coercible for Int32
+    /// Tests that NULL → Int32 and Int64 → Int32 coercion works
+    #[derive(Debug)]
+    struct CoercionCoercibleFn {
+        signature: Signature,
+    }
+
+    impl CoercionCoercibleFn {
+        fn new() -> Self {
+            use datafusion::common::types::{logical_int32, NativeType};
+            use datafusion::logical_expr::{Coercion, TypeSignatureClass};
+
+            let int32_coercion = Coercion::new_implicit(
+                TypeSignatureClass::Native(logical_int32()),
+                vec![TypeSignatureClass::Integer],
+                NativeType::Int32,
+            );
+
+            Self {
+                signature: Signature::coercible(
+                    vec![int32_coercion.clone(), int32_coercion],
+                    Volatility::Immutable,
+                ),
+            }
+        }
+    }
+
+    #[async_trait::async_trait]
+    impl BatchedTableFunctionImpl for CoercionCoercibleFn {
+        fn name(&self) -> &str {
+            "batched_coercion_coercible"
+        }
+
+        fn signature(&self) -> &Signature {
+            &self.signature
+        }
+
+        fn return_type(&self, _arg_types: &[DataType]) -> Result<Schema> {
+            Ok(Schema::new(vec![Field::new(
+                "result",
+                DataType::Int32,
+                false,
+            )]))
+        }
+
+        async fn invoke_batch(
+            &self,
+            args: &[ArrayRef],
+            _projection: Option<&[usize]>,
+            _filters: &[datafusion::prelude::Expr],
+            _limit: Option<usize>,
+        ) -> Result<datafusion::catalog::BatchResultStream> {
+            if args.len() != 2 {
+                return Err(DataFusionError::Internal(
+                    "Expected exactly 2 arguments".to_string(),
+                ));
+            }
+
+            let a_array = args[0].as_primitive::<arrow::datatypes::Int32Type>();
+            let b_array = args[1].as_primitive::<arrow::datatypes::Int32Type>();
+
+            if a_array.len() != b_array.len() {
+                return Err(DataFusionError::Internal(
+                    "Arguments must have same length".to_string(),
+                ));
+            }
+
+            let mut results = Vec::new();
+            let mut input_row_indices = Vec::new();
+
+            for idx in 0..a_array.len() {
+                let a = a_array.value(idx);
+                let b = b_array.value(idx);
+                results.push(a + b);
+                input_row_indices.push(idx as u32);
+            }
+
+            let schema = Arc::new(self.return_type(&[DataType::Int32, DataType::Int32])?);
+            let output_batch = RecordBatch::try_new(
+                Arc::clone(&schema),
+                vec![Arc::new(Int32Array::from(results))],
+            )?;
+
+            Ok(materialized_batch_stream(output_batch, input_row_indices))
+        }
+    }
+
+    let state_ref = ctx.state_ref();
+    let mut state = state_ref.write();
+    state.register_batched_table_function(
+        "batched_coercion_exact",
+        Arc::new(CoercionExactFn::new()),
+    );
+    state.register_batched_table_function(
+        "batched_coercion_coercible",
+        Arc::new(CoercionCoercibleFn::new()),
+    );
+}
diff --git a/datafusion/sqllogictest/test_files/lateral.slt b/datafusion/sqllogictest/test_files/lateral.slt
index 2e49f64966a05..88bb96c0d0d15 100644
--- a/datafusion/sqllogictest/test_files/lateral.slt
+++ b/datafusion/sqllogictest/test_files/lateral.slt
@@ -710,6 +710,95 @@ physical_plan
 statement ok
 DROP TABLE ranges_filter_test;
 
+##########
+## Type Coercion Tests
+##########
+
+# Signature::exact - NULL and exact type matches work
+query I
+SELECT result FROM batched_coercion_exact(NULL, 42);
+----
+42
+
+query I
+SELECT result FROM batched_coercion_exact(10, 32);
+----
+42
+
+# Signature::coercible - supports NULL and Int64→Int32 narrowing
+query I
+SELECT result FROM batched_coercion_coercible(NULL, 42);
+----
+42
+
+query I
+SELECT result FROM batched_coercion_coercible(10, 32);
+----
+42
+
+# Coercion in LATERAL joins
+statement ok
+CREATE TABLE coercion_test(a BIGINT, b BIGINT) AS VALUES (10, 20), (15, 25), (5, 10);
+
+query III
+SELECT a, b, result
+FROM coercion_test
+CROSS JOIN LATERAL batched_coercion_coercible(a, b)
+ORDER BY a;
+----
+5 10 15
+10 20 30
+15 25 40
+
+statement ok
+DROP TABLE coercion_test;
+
+##########
+## Projection Handling Tests
+## Functions can optionally apply projection; DataFusion applies it automatically if not
+##########
+
+# Both functions produce identical results
+query III
+SELECT n, n_squared, n_cubed FROM batched_multi_column(CAST(1 AS INT), CAST(3 AS INT)) ORDER BY n;
+----
+1 1 1
+2 4 8
+3 9 27
+
+query III
+SELECT n, n_squared, n_cubed FROM batched_multi_column_no_proj(1, 3) ORDER BY n;
+----
+1 1 1
+2 4 8
+3 9 27
+
+# Automatic projection when function doesn't apply it
+query II
+SELECT n, n_squared FROM batched_multi_column_no_proj(1, 3) ORDER BY n;
+----
+1 1
+2 4
+3 9
+
+# Projection with LATERAL join
+statement ok
+CREATE TABLE proj_test(start BIGINT, stop BIGINT) AS VALUES (1, 2), (3, 4);
+
+query IIII
+SELECT start, stop, n, n_squared
+FROM proj_test
+CROSS JOIN LATERAL batched_multi_column_no_proj(start, stop)
+ORDER BY start, n;
+----
+1 2 1 1
+1 2 2 4
+3 4 3 9
+3 4 4 16
+
+statement ok
+DROP TABLE proj_test;
+
 ##########
 ## Cleanup
 ##########

From 922af6cbefc89a5f8693b559926cd20ffef7f741 Mon Sep 17 00:00:00 2001
From: bubulalabu <bubulalabububu@gmail.com>
Date: Fri, 12 Dec 2025 11:38:19 +0100
Subject: [PATCH 14/18] added support for FROM x, LATERAL syntax

---
 datafusion/sql/src/relation/join.rs           |   2 +-
 datafusion/sql/src/relation/mod.rs            |   1 +
 datafusion/sql/src/select.rs                  |  28 +++-
 .../sqllogictest/test_files/lateral.slt       | 128 ++++++++++++++++++
 4 files changed, 155 insertions(+), 4 deletions(-)

diff --git a/datafusion/sql/src/relation/join.rs b/datafusion/sql/src/relation/join.rs
index 38e45aa3ea6db..11196db603beb 100644
--- a/datafusion/sql/src/relation/join.rs
+++ b/datafusion/sql/src/relation/join.rs
@@ -226,7 +226,7 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
     }
 
     /// Try to create a LateralBatchedTableFunction node if this is a lateral batched table function
-    fn try_create_lateral_table_function(
+    pub(crate) fn try_create_lateral_table_function(
         &self,
         input_plan: &LogicalPlan,
         relation: &TableFactor,
diff --git a/datafusion/sql/src/relation/mod.rs b/datafusion/sql/src/relation/mod.rs
index 376191120ec9d..1ddf999db14ae 100644
--- a/datafusion/sql/src/relation/mod.rs
+++ b/datafusion/sql/src/relation/mod.rs
@@ -36,6 +36,7 @@ use datafusion_expr::{Subquery, SubqueryAlias};
 use sqlparser::ast::{FunctionArg, FunctionArgExpr, Spanned, TableFactor};
 
 mod join;
+pub(crate) use join::is_lateral;
 
 struct SqlToRelRelationContext<'a, 'b, S: ContextProvider> {
     planner: &'a SqlToRel<'b, S>,
diff --git a/datafusion/sql/src/select.rs b/datafusion/sql/src/select.rs
index c5a635cac9563..d47b4352c7ffb 100644
--- a/datafusion/sql/src/select.rs
+++ b/datafusion/sql/src/select.rs
@@ -21,6 +21,7 @@ use std::sync::Arc;
 
 use crate::planner::{ContextProvider, PlannerContext, SqlToRel};
 use crate::query::to_order_by_exprs_with_select;
+use crate::relation::is_lateral;
 use crate::utils::{
     CheckColumnsMustReferenceAggregatePurpose, CheckColumnsSatisfyExprsPurpose,
     check_columns_satisfy_exprs, extract_aliases, rebase_expr, resolve_aliases_to_exprs,
@@ -695,9 +696,30 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
                     planner_context.set_outer_from_schema(left_schema)
                 };
                 for input in from {
-                    // Join `input` with the current result (`left`).
-                    let right = self.plan_table_with_joins(input, planner_context)?;
-                    left = left.cross_join(right)?;
+                    // Check if this is a LATERAL relation (and not already part of a JOIN clause)
+                    if is_lateral(&input.relation) && input.joins.is_empty() {
+                        // Try to create a lateral batched table function
+                        let left_plan = left.clone().build()?;
+                        if let Some(lateral_plan) = self
+                            .try_create_lateral_table_function(
+                                &left_plan,
+                                &input.relation,
+                                planner_context,
+                            )?
+                        {
+                            // Use the lateral table function plan
+                            left = LogicalPlanBuilder::from(lateral_plan);
+                        } else {
+                            // Not a batched table function - fall back to subquery handling
+                            let right =
+                                self.plan_table_with_joins(input, planner_context)?;
+                            left = left.cross_join(right)?;
+                        }
+                    } else {
+                        // Regular cross join
+                        let right = self.plan_table_with_joins(input, planner_context)?;
+                        left = left.cross_join(right)?;
+                    }
                     // Update the outer FROM schema.
                     let left_schema = Some(Arc::clone(left.schema()));
                     planner_context.set_outer_from_schema(left_schema);
diff --git a/datafusion/sqllogictest/test_files/lateral.slt b/datafusion/sqllogictest/test_files/lateral.slt
index 88bb96c0d0d15..0669699bdc8b2 100644
--- a/datafusion/sqllogictest/test_files/lateral.slt
+++ b/datafusion/sqllogictest/test_files/lateral.slt
@@ -799,6 +799,134 @@ ORDER BY start, n;
 statement ok
 DROP TABLE proj_test;
 
+##########
+## Comma LATERAL Syntax Tests
+## Test that `FROM x, LATERAL y` works the same as `FROM x CROSS JOIN LATERAL y`
+##########
+
+# Test basic comma LATERAL syntax
+query II
+SELECT start_val, value
+FROM ranges, LATERAL batched_generate_series(start_val, end_val)
+ORDER BY start_val, value;
+----
+1 1
+1 2
+1 3
+5 5
+5 6
+5 7
+10 10
+10 11
+10 12
+
+# Verify it produces the same results as CROSS JOIN LATERAL
+query II
+SELECT start_val, value
+FROM ranges CROSS JOIN LATERAL batched_generate_series(start_val, end_val)
+ORDER BY start_val, value;
+----
+1 1
+1 2
+1 3
+5 5
+5 6
+5 7
+10 10
+10 11
+10 12
+
+# Test multiple LATERAL functions with comma syntax
+statement ok
+CREATE TABLE lateral_multi_test (start_val INT, end_val INT);
+
+statement ok
+INSERT INTO lateral_multi_test VALUES (10, 12);
+
+query IIII
+SELECT s.start_val, s.end_val, g1.value as value1, g2.value as value2
+FROM lateral_multi_test s,
+     LATERAL batched_generate_series(s.start_val, s.end_val) g1,
+     LATERAL batched_generate_series(s.start_val, s.start_val + 1) g2
+ORDER BY value1, value2;
+----
+10 12 10 10
+10 12 10 11
+10 12 11 10
+10 12 11 11
+10 12 12 10
+10 12 12 11
+
+statement ok
+DROP TABLE lateral_multi_test;
+
+# Test chained LATERAL functions where second references first
+statement ok
+CREATE TABLE lateral_chain_test (start_val INT);
+
+statement ok
+INSERT INTO lateral_chain_test VALUES (1), (3);
+
+query III
+SELECT s.start_val, g1.value as first_value, g2.value as second_value
+FROM lateral_chain_test s,
+     LATERAL batched_generate_series(s.start_val, s.start_val + 1) g1,
+     LATERAL batched_generate_series(g1.value, g1.value + 1) g2
+ORDER BY start_val, first_value, second_value;
+----
+1 1 1
+1 1 2
+1 2 2
+1 2 3
+3 3 3
+3 3 4
+3 4 4
+3 4 5
+
+statement ok
+DROP TABLE lateral_chain_test;
+
+# Test comma LATERAL with multi-column function
+statement ok
+CREATE TABLE comma_test(start BIGINT, stop BIGINT) AS VALUES (1, 2), (3, 4);
+
+query IIII
+SELECT start, stop, n, n_squared
+FROM comma_test, LATERAL batched_multi_column_no_proj(start, stop)
+ORDER BY start, n;
+----
+1 2 1 1
+1 2 2 4
+3 4 3 9
+3 4 4 16
+
+# Verify same as CROSS JOIN LATERAL
+query IIII
+SELECT start, stop, n, n_squared
+FROM comma_test CROSS JOIN LATERAL batched_multi_column_no_proj(start, stop)
+ORDER BY start, n;
+----
+1 2 1 1
+1 2 2 4
+3 4 3 9
+3 4 4 16
+
+# Test EXPLAIN shows correct plan for comma LATERAL
+query TT
+EXPLAIN SELECT start, stop, value
+FROM comma_test, LATERAL batched_generate_series(start, stop);
+----
+logical_plan
+01)LateralBatchedTableFunction: batched_generate_series(comma_test.start, comma_test.stop)
+02)--TableScan: comma_test projection=[start, stop]
+physical_plan
+01)BatchedTableFunctionExec: function=batched_generate_series, args=[start@0, stop@1]
+02)--DataSourceExec: partitions=1, partition_sizes=[1]
+
+# Cleanup
+statement ok
+DROP TABLE comma_test;
+
 ##########
 ## Cleanup
 ##########

From 6bd016213c7e8fbd0448799be2d62b0633f06bab Mon Sep 17 00:00:00 2001
From: bubulalabu <bubulalabububu@gmail.com>
Date: Sat, 13 Dec 2025 11:22:26 +0100
Subject: [PATCH 15/18] misc

---
 .../optimizer/src/analyzer/type_coercion.rs   |  2 +-
 .../optimizer/src/optimize_projections/mod.rs |  4 +-
 datafusion/optimizer/src/push_down_filter.rs  |  6 ++-
 datafusion/sql/src/relation/join.rs           | 38 +++++++++----------
 datafusion/sql/src/relation/mod.rs            |  8 ++--
 5 files changed, 30 insertions(+), 28 deletions(-)

diff --git a/datafusion/optimizer/src/analyzer/type_coercion.rs b/datafusion/optimizer/src/analyzer/type_coercion.rs
index 2d7a554c67a29..095b7d257e49d 100644
--- a/datafusion/optimizer/src/analyzer/type_coercion.rs
+++ b/datafusion/optimizer/src/analyzer/type_coercion.rs
@@ -33,6 +33,7 @@ use datafusion_common::{
     exec_err, internal_datafusion_err, internal_err, not_impl_err, plan_datafusion_err,
     plan_err,
 };
+use datafusion_expr::BatchedTableFunctionSource;
 use datafusion_expr::expr::{
     self, AggregateFunctionParams, Alias, Between, BinaryExpr, Case, Exists, InList,
     InSubquery, Like, ScalarFunction, Sort, WindowFunction,
@@ -49,7 +50,6 @@ use datafusion_expr::type_coercion::other::{
 };
 use datafusion_expr::type_coercion::{is_datetime, is_utf8_or_utf8view_or_large_utf8};
 use datafusion_expr::utils::merge_schema;
-use datafusion_expr::BatchedTableFunctionSource;
 use datafusion_expr::{
     AggregateUDF, Expr, ExprSchemable, Join, Limit, LogicalPlan, Operator, Projection,
     ScalarUDF, Union, WindowFrame, WindowFrameBound, WindowFrameUnits, is_false,
diff --git a/datafusion/optimizer/src/optimize_projections/mod.rs b/datafusion/optimizer/src/optimize_projections/mod.rs
index f3049e24e1b4d..1c8fe23cf381b 100644
--- a/datafusion/optimizer/src/optimize_projections/mod.rs
+++ b/datafusion/optimizer/src/optimize_projections/mod.rs
@@ -30,8 +30,8 @@ use datafusion_common::{
 };
 use datafusion_expr::expr::Alias;
 use datafusion_expr::{
-     Aggregate, Distinct, EmptyRelation, Expr,
-    LateralBatchedTableFunction, Projection, TableScan, Unnest, Window,logical_plan::LogicalPlan,
+    Aggregate, Distinct, EmptyRelation, Expr, LateralBatchedTableFunction, Projection,
+    TableScan, Unnest, Window, logical_plan::LogicalPlan,
 };
 
 use crate::optimize_projections::required_indices::RequiredIndices;
diff --git a/datafusion/optimizer/src/push_down_filter.rs b/datafusion/optimizer/src/push_down_filter.rs
index 2b52af98d2711..1b29e0ce0fcfa 100644
--- a/datafusion/optimizer/src/push_down_filter.rs
+++ b/datafusion/optimizer/src/push_down_filter.rs
@@ -1198,7 +1198,8 @@ impl OptimizerRule for PushDownFilter {
                     return internal_err!(
                         "Vec returned length: {} from supports_filters_pushdown is not the same size as the filters passed, which length is: {}",
                         supported_filters.len(),
-                        non_volatile_filters.len());
+                        non_volatile_filters.len()
+                    );
                 }
 
                 // Compose scan filters from non-volatile filters of `Exact` or `Inexact` pushdown type
@@ -1291,7 +1292,8 @@ impl OptimizerRule for PushDownFilter {
                     return internal_err!(
                         "Vec returned length: {} from supports_filters_pushdown is not the same size as the filters passed, which length is: {}",
                         supported_filters.len(),
-                        pushdown_candidates.len());
+                        pushdown_candidates.len()
+                    );
                 }
 
                 // Compose lateral filters from candidates of `Exact` or `Inexact` pushdown type
diff --git a/datafusion/sql/src/relation/join.rs b/datafusion/sql/src/relation/join.rs
index 11196db603beb..59f70fb7383c7 100644
--- a/datafusion/sql/src/relation/join.rs
+++ b/datafusion/sql/src/relation/join.rs
@@ -16,7 +16,7 @@
 // under the License.
 
 use crate::planner::{ContextProvider, PlannerContext, SqlToRel};
-use datafusion_common::{Column, Result, not_impl_err, plan_datafusion_err};
+use datafusion_common::{Column, Result, not_impl_err, plan_datafusion_err, plan_err};
 use datafusion_expr::{
     ExprSchemable, JoinType, LateralBatchedTableFunction, LogicalPlan, LogicalPlanBuilder,
 };
@@ -54,27 +54,27 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
     ) -> Result<LogicalPlan> {
         let is_lateral = is_lateral_join(&join)?;
 
-        if is_lateral {
-            if let Some(lateral_tf_plan) = self.try_create_lateral_table_function(
+        if is_lateral
+            && let Some(lateral_tf_plan) = self.try_create_lateral_table_function(
                 &left,
                 &join.relation,
                 planner_context,
-            )? {
-                // LateralBatchedTableFunction already combines input + function output
-                match &join.join_operator {
-                    JoinOperator::CrossJoin(_) | JoinOperator::CrossApply => {
-                        return Ok(lateral_tf_plan);
-                    }
-                    _ => {
-                        // For other join types, use lateral function as right side
-                        let right = lateral_tf_plan;
-                        return self.finish_join(
-                            left,
-                            right,
-                            join.join_operator,
-                            planner_context,
-                        );
-                    }
+            )?
+        {
+            // LateralBatchedTableFunction already combines input + function output
+            match &join.join_operator {
+                JoinOperator::CrossJoin(_) | JoinOperator::CrossApply => {
+                    return Ok(lateral_tf_plan);
+                }
+                _ => {
+                    // For other join types, use lateral function as right side
+                    let right = lateral_tf_plan;
+                    return self.finish_join(
+                        left,
+                        right,
+                        join.join_operator,
+                        planner_context,
+                    );
                 }
             }
         }
diff --git a/datafusion/sql/src/relation/mod.rs b/datafusion/sql/src/relation/mod.rs
index 1ddf999db14ae..3228ba8a86cd2 100644
--- a/datafusion/sql/src/relation/mod.rs
+++ b/datafusion/sql/src/relation/mod.rs
@@ -21,16 +21,16 @@ use crate::planner::{ContextProvider, PlannerContext, SqlToRel};
 
 use datafusion_common::tree_node::{Transformed, TreeNode};
 use datafusion_common::{
-    not_impl_err, plan_datafusion_err, plan_err, DFSchema, Diagnostic, Result, Span,
-    Spans, TableReference,
+    DFSchema, Diagnostic, Result, Span, Spans, TableReference, not_impl_err,
+    plan_datafusion_err, plan_err,
 };
 use datafusion_expr::builder::subquery_alias;
 use datafusion_expr::planner::{
     PlannedRelation, RelationPlannerContext, RelationPlanning,
 };
 use datafusion_expr::{
-    expr::Unnest, Expr, ExprSchemable, LogicalPlan, LogicalPlanBuilder,
-    StandaloneBatchedTableFunction,
+    Expr, ExprSchemable, LogicalPlan, LogicalPlanBuilder, StandaloneBatchedTableFunction,
+    expr::Unnest,
 };
 use datafusion_expr::{Subquery, SubqueryAlias};
 use sqlparser::ast::{FunctionArg, FunctionArgExpr, Spanned, TableFactor};

From 3fc8e0de14a909d804c93fb65d28480fe8f5d5e8 Mon Sep 17 00:00:00 2001
From: bubulalabu <bubulalabububu@gmail.com>
Date: Thu, 11 Dec 2025 10:46:26 +0100
Subject: [PATCH 16/18] fmt and clippy

---
 datafusion/optimizer/src/analyzer/type_coercion.rs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/datafusion/optimizer/src/analyzer/type_coercion.rs b/datafusion/optimizer/src/analyzer/type_coercion.rs
index 095b7d257e49d..2d7a554c67a29 100644
--- a/datafusion/optimizer/src/analyzer/type_coercion.rs
+++ b/datafusion/optimizer/src/analyzer/type_coercion.rs
@@ -33,7 +33,6 @@ use datafusion_common::{
     exec_err, internal_datafusion_err, internal_err, not_impl_err, plan_datafusion_err,
     plan_err,
 };
-use datafusion_expr::BatchedTableFunctionSource;
 use datafusion_expr::expr::{
     self, AggregateFunctionParams, Alias, Between, BinaryExpr, Case, Exists, InList,
     InSubquery, Like, ScalarFunction, Sort, WindowFunction,
@@ -50,6 +49,7 @@ use datafusion_expr::type_coercion::other::{
 };
 use datafusion_expr::type_coercion::{is_datetime, is_utf8_or_utf8view_or_large_utf8};
 use datafusion_expr::utils::merge_schema;
+use datafusion_expr::BatchedTableFunctionSource;
 use datafusion_expr::{
     AggregateUDF, Expr, ExprSchemable, Join, Limit, LogicalPlan, Operator, Projection,
     ScalarUDF, Union, WindowFrame, WindowFrameBound, WindowFrameUnits, is_false,

From fde8e280b4ba694efa673f41690971199dd1da15 Mon Sep 17 00:00:00 2001
From: bubulalabu <bubulalabububu@gmail.com>
Date: Tue, 9 Dec 2025 11:42:50 +0100
Subject: [PATCH 17/18] add named parameters support for table functions

---
 .../src/generate_series_batched.rs            |   4 +-
 datafusion/sql/src/relation/join.rs           |  77 +++++++--
 datafusion/sql/src/relation/mod.rs            | 159 +++++++++++++++---
 .../test_files/named_arguments.slt            | 123 ++++++++++++++
 4 files changed, 326 insertions(+), 37 deletions(-)

diff --git a/datafusion/functions-table/src/generate_series_batched.rs b/datafusion/functions-table/src/generate_series_batched.rs
index 47d4c6b48384c..b3681c96a0054 100644
--- a/datafusion/functions-table/src/generate_series_batched.rs
+++ b/datafusion/functions-table/src/generate_series_batched.rs
@@ -58,7 +58,9 @@ impl GenerateSeriesFunction {
             signature: Signature::coercible(
                 vec![integer.clone(), integer.clone()],
                 datafusion_expr::Volatility::Immutable,
-            ),
+            )
+            .with_parameter_names(vec!["start", "stop"])
+            .expect("valid parameter names"),
         }
     }
 }
diff --git a/datafusion/sql/src/relation/join.rs b/datafusion/sql/src/relation/join.rs
index 59f70fb7383c7..27b053dbfd850 100644
--- a/datafusion/sql/src/relation/join.rs
+++ b/datafusion/sql/src/relation/join.rs
@@ -248,25 +248,76 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
 
         let input_schema = input_plan.schema();
 
-        let func_args: Vec<datafusion_expr::Expr> = args
+        // Parse arguments with names preserved
+        let results: Result<Vec<(datafusion_expr::Expr, Option<String>)>> = args
             .iter()
             .map(|arg| match arg {
-                sqlparser::ast::FunctionArg::Unnamed(
-                    sqlparser::ast::FunctionArgExpr::Expr(expr),
-                )
-                | sqlparser::ast::FunctionArg::Named {
+                sqlparser::ast::FunctionArg::Named {
+                    name,
                     arg: sqlparser::ast::FunctionArgExpr::Expr(expr),
                     ..
-                } => self.sql_expr_to_logical_expr(
-                    expr.clone(),
-                    input_schema.as_ref(),
-                    planner_context,
-                ),
+                } => {
+                    let e = self.sql_expr_to_logical_expr(
+                        expr.clone(),
+                        input_schema.as_ref(),
+                        planner_context,
+                    )?;
+                    let arg_name = crate::utils::normalize_ident(name.clone());
+                    Ok((e, Some(arg_name)))
+                }
+                sqlparser::ast::FunctionArg::Unnamed(
+                    sqlparser::ast::FunctionArgExpr::Expr(expr),
+                ) => {
+                    let e = self.sql_expr_to_logical_expr(
+                        expr.clone(),
+                        input_schema.as_ref(),
+                        planner_context,
+                    )?;
+                    Ok((e, None))
+                }
                 _ => plan_err!("Unsupported function argument: {arg:?}"),
             })
-            .collect::<Result<Vec<_>>>()?;
+            .collect();
+        let pairs = results?;
+        let (func_args, arg_names): (Vec<datafusion_expr::Expr>, Vec<Option<String>>) =
+            pairs.into_iter().unzip();
+
+        // Resolve arguments if any are named
+        let resolved_args = if arg_names.iter().any(|name| name.is_some()) {
+            // Get arg types to create a temporary source for signature access
+            let arg_types: Vec<arrow::datatypes::DataType> = func_args
+                .iter()
+                .map(|e| e.get_type(input_schema.as_ref()))
+                .collect::<Result<Vec<_>>>()?;
+
+            let temp_source = self
+                .context_provider
+                .get_batched_table_function_source(func_name, &arg_types)?
+                .ok_or_else(|| {
+                    datafusion_common::plan_datafusion_err!(
+                        "Failed to get source for batched table function '{}'",
+                        func_name
+                    )
+                })?;
+
+            if let Some(param_names) = &temp_source.signature().parameter_names {
+                datafusion_expr::arguments::resolve_function_arguments(
+                    param_names,
+                    func_args,
+                    arg_names,
+                )?
+            } else {
+                return plan_err!(
+                    "Batched table function '{}' does not support named arguments",
+                    func_name
+                );
+            }
+        } else {
+            func_args
+        };
 
-        let arg_types: Vec<arrow::datatypes::DataType> = func_args
+        // Now get arg types from resolved arguments
+        let arg_types: Vec<arrow::datatypes::DataType> = resolved_args
             .iter()
             .map(|e| e.get_type(input_schema.as_ref()))
             .collect::<Result<Vec<_>>>()?;
@@ -303,7 +354,7 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
                 input: Arc::new(input_plan.clone()),
                 function_name: func_name.to_string(),
                 source,
-                args: func_args,
+                args: resolved_args,
                 schema: Arc::new(combined_schema),
                 table_function_schema: Arc::new(qualified_func_schema),
                 projection: None,
diff --git a/datafusion/sql/src/relation/mod.rs b/datafusion/sql/src/relation/mod.rs
index 3228ba8a86cd2..bfdcc9f82d972 100644
--- a/datafusion/sql/src/relation/mod.rs
+++ b/datafusion/sql/src/relation/mod.rs
@@ -164,24 +164,79 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
                             .cloned()
                             .unwrap_or_else(DFSchema::empty);
 
-                        let args = func_args
+                        // Parse arguments with names preserved
+                        let results: Result<Vec<(Expr, Option<String>)>> = func_args
                             .args
                             .into_iter()
                             .map(|arg| match arg {
-                                FunctionArg::Unnamed(FunctionArgExpr::Expr(expr))
-                                | FunctionArg::Named {
+                                FunctionArg::Named {
+                                    name,
                                     arg: FunctionArgExpr::Expr(expr),
                                     ..
-                                } => self.sql_expr_to_logical_expr(
-                                    expr,
-                                    &schema,
-                                    planner_context,
-                                ),
+                                } => {
+                                    let e = self.sql_expr_to_logical_expr(
+                                        expr,
+                                        &schema,
+                                        planner_context,
+                                    )?;
+                                    let arg_name = crate::utils::normalize_ident(name);
+                                    Ok((e, Some(arg_name)))
+                                }
+                                FunctionArg::Unnamed(FunctionArgExpr::Expr(expr)) => {
+                                    let e = self.sql_expr_to_logical_expr(
+                                        expr,
+                                        &schema,
+                                        planner_context,
+                                    )?;
+                                    Ok((e, None))
+                                }
                                 _ => plan_err!("Unsupported function argument: {arg:?}"),
                             })
-                            .collect::<Result<Vec<Expr>>>()?;
+                            .collect();
+                        let pairs = results?;
+                        let (func_args, arg_names): (Vec<Expr>, Vec<Option<String>>) =
+                            pairs.into_iter().unzip();
+
+                        // Resolve arguments if any are named
+                        let has_named = arg_names.iter().any(|name| name.is_some());
+                        let resolved_args = if has_named {
+                            // Get arg types to create a temporary source for signature access
+                            let arg_types: Vec<arrow::datatypes::DataType> = func_args
+                                .iter()
+                                .map(|e| e.get_type(&schema))
+                                .collect::<Result<Vec<_>>>()?;
+
+                            let temp_source = self
+                                .context_provider
+                                .get_batched_table_function_source(&tbl_func_name, &arg_types)?
+                                .ok_or_else(|| {
+                                    plan_datafusion_err!(
+                                        "Failed to get source for batched table function '{}'",
+                                        tbl_func_name
+                                    )
+                                })?;
+
+                            let sig_param_names =
+                                temp_source.signature().parameter_names.clone();
+
+                            if let Some(param_names) = sig_param_names.as_ref() {
+                                datafusion_expr::arguments::resolve_function_arguments(
+                                    param_names,
+                                    func_args,
+                                    arg_names,
+                                )?
+                            } else {
+                                return plan_err!(
+                                    "Batched table function '{}' does not support named arguments",
+                                    tbl_func_name
+                                );
+                            }
+                        } else {
+                            func_args
+                        };
 
-                        let arg_types: Vec<arrow::datatypes::DataType> = args
+                        // Now get arg types from resolved arguments
+                        let arg_types: Vec<arrow::datatypes::DataType> = resolved_args
                             .iter()
                             .map(|e| e.get_type(&schema))
                             .collect::<Result<Vec<_>>>()?;
@@ -215,7 +270,7 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
                             StandaloneBatchedTableFunction {
                                 function_name: tbl_func_name.clone(),
                                 source,
-                                args,
+                                args: resolved_args,
                                 schema: Arc::clone(&qualified_func_schema),
                                 projection: None,
                                 projected_schema: qualified_func_schema,
@@ -347,30 +402,88 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
                 let tbl_func_ref = self.object_name_to_table_reference(name)?;
                 let func_name = tbl_func_ref.table();
 
-                if self.context_provider.is_batched_table_function(func_name) {
+                let is_batched =
+                    self.context_provider.is_batched_table_function(func_name);
+
+                if is_batched {
                     // Standalone batched table function
                     let schema = planner_context
                         .outer_query_schema()
                         .cloned()
                         .unwrap_or_else(DFSchema::empty);
 
-                    let func_args = args
+                    // Parse arguments with names preserved
+                    let results: Result<Vec<(Expr, Option<String>)>> = args
                         .into_iter()
                         .map(|arg| match arg {
-                            FunctionArg::Unnamed(FunctionArgExpr::Expr(expr))
-                            | FunctionArg::Named {
+                            FunctionArg::Named {
+                                name,
                                 arg: FunctionArgExpr::Expr(expr),
                                 ..
-                            } => self.sql_expr_to_logical_expr(
-                                expr,
-                                &schema,
-                                planner_context,
-                            ),
+                            } => {
+                                let e = self.sql_expr_to_logical_expr(
+                                    expr,
+                                    &schema,
+                                    planner_context,
+                                )?;
+                                let arg_name = crate::utils::normalize_ident(name);
+                                Ok((e, Some(arg_name)))
+                            }
+                            FunctionArg::Unnamed(FunctionArgExpr::Expr(expr)) => {
+                                let e = self.sql_expr_to_logical_expr(
+                                    expr,
+                                    &schema,
+                                    planner_context,
+                                )?;
+                                Ok((e, None))
+                            }
                             _ => plan_err!("Unsupported function argument: {arg:?}"),
                         })
-                        .collect::<Result<Vec<Expr>>>()?;
+                        .collect();
+                    let pairs = results?;
+                    let (func_args, arg_names): (Vec<Expr>, Vec<Option<String>>) =
+                        pairs.into_iter().unzip();
+
+                    // Resolve arguments if any are named
+                    let has_named = arg_names.iter().any(|name| name.is_some());
+                    let resolved_args = if has_named {
+                        // Get arg types to create a temporary source for signature access
+                        let arg_types: Vec<arrow::datatypes::DataType> = func_args
+                            .iter()
+                            .map(|e| e.get_type(&schema))
+                            .collect::<Result<Vec<_>>>()?;
+
+                        let temp_source = self
+                            .context_provider
+                            .get_batched_table_function_source(func_name, &arg_types)?
+                            .ok_or_else(|| {
+                                plan_datafusion_err!(
+                                    "Failed to get source for batched table function '{}'",
+                                    func_name
+                                )
+                            })?;
+
+                        let sig_param_names =
+                            temp_source.signature().parameter_names.clone();
+
+                        if let Some(param_names) = sig_param_names.as_ref() {
+                            datafusion_expr::arguments::resolve_function_arguments(
+                                param_names,
+                                func_args,
+                                arg_names,
+                            )?
+                        } else {
+                            return plan_err!(
+                                "Batched table function '{}' does not support named arguments",
+                                func_name
+                            );
+                        }
+                    } else {
+                        func_args
+                    };
 
-                    let arg_types: Vec<arrow::datatypes::DataType> = func_args
+                    // Now get arg types from resolved arguments
+                    let arg_types: Vec<arrow::datatypes::DataType> = resolved_args
                         .iter()
                         .map(|e| e.get_type(&schema))
                         .collect::<Result<Vec<_>>>()?;
@@ -402,7 +515,7 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
                         StandaloneBatchedTableFunction {
                             function_name: func_name.to_string(),
                             source,
-                            args: func_args,
+                            args: resolved_args,
                             schema: Arc::clone(&qualified_func_schema),
                             projection: None,
                             projected_schema: qualified_func_schema,
diff --git a/datafusion/sqllogictest/test_files/named_arguments.slt b/datafusion/sqllogictest/test_files/named_arguments.slt
index a16de826a2d52..878833eb404cf 100644
--- a/datafusion/sqllogictest/test_files/named_arguments.slt
+++ b/datafusion/sqllogictest/test_files/named_arguments.slt
@@ -269,3 +269,126 @@ SELECT row_number(value => 1) OVER (ORDER BY id) FROM window_test;
 # Cleanup
 statement ok
 DROP TABLE window_test;
+
+##########
+## Batched Table Functions with Named Arguments
+##########
+
+# Test basic named arguments (correct order)
+query I
+SELECT * FROM batched_generate_series(start => 1, stop => 5);
+----
+1
+2
+3
+4
+5
+
+# Test named arguments (reversed order)
+query I
+SELECT * FROM batched_generate_series(stop => 5, start => 1);
+----
+1
+2
+3
+4
+5
+
+# Test positional arguments (baseline - should still work)
+query I
+SELECT * FROM batched_generate_series(1, 5);
+----
+1
+2
+3
+4
+5
+
+# Test mixed positional and named (positional first)
+query I
+SELECT * FROM batched_generate_series(1, stop => 5);
+----
+1
+2
+3
+4
+5
+
+# Test error: named before positional
+query error DataFusion error: Error during planning: Positional argument .* follows named argument
+SELECT * FROM batched_generate_series(start => 1, 5);
+
+# Test error: unknown parameter name
+query error DataFusion error: Error during planning: Unknown parameter name 'begin'
+SELECT * FROM batched_generate_series(begin => 1, stop => 5);
+
+# Test error: duplicate parameter
+query error DataFusion error: Error during planning: Parameter 'start' specified multiple times
+SELECT * FROM batched_generate_series(start => 1, start => 2);
+
+# Test error: missing required parameter
+query error DataFusion error: (Error during planning: .*'stop'|Execution error: generate_series expects 2 arguments, got 1)
+SELECT * FROM batched_generate_series(start => 1);
+
+# Test case insensitivity of parameter names
+query I
+SELECT * FROM batched_generate_series(START => 1, StoP => 3);
+----
+1
+2
+3
+
+##########
+## LATERAL join tests with named arguments
+##########
+
+statement ok
+CREATE TABLE lateral_test(col1 BIGINT, col2 BIGINT);
+
+statement ok
+INSERT INTO lateral_test VALUES (1, 3), (5, 7);
+
+query II
+SELECT t.col1, value
+FROM lateral_test t
+CROSS JOIN LATERAL batched_generate_series(start => t.col1, stop => t.col2)
+ORDER BY t.col1, value;
+----
+1 1
+1 2
+1 3
+5 5
+5 6
+5 7
+
+# Test LATERAL with reversed parameter order
+query II
+SELECT t.col1, value
+FROM lateral_test t
+CROSS JOIN LATERAL batched_generate_series(stop => t.col2, start => t.col1)
+ORDER BY t.col1, value;
+----
+1 1
+1 2
+1 3
+5 5
+5 6
+5 7
+
+# Test LATERAL with mixed positional/named
+query II
+SELECT t.col1, value
+FROM lateral_test t
+CROSS JOIN LATERAL batched_generate_series(t.col1, stop => t.col2)
+ORDER BY t.col1, value;
+----
+1 1
+1 2
+1 3
+5 5
+5 6
+5 7
+
+# Cleanup
+statement ok
+DROP TABLE lateral_test;

From 58cd14f934b61acb5f77ea4f99ed323565476b90 Mon Sep 17 00:00:00 2001
From: bubulalabu <bubulalabububu@gmail.com>
Date: Sat, 13 Dec 2025 12:00:48 +0100
Subject: [PATCH 18/18] Fix test error message pattern and import ordering
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Update named_arguments.slt test to match new error message format
- Reorder imports in type_coercion.rs for consistency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
---
 datafusion/optimizer/src/analyzer/type_coercion.rs     | 2 +-
 datafusion/sqllogictest/test_files/named_arguments.slt | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/datafusion/optimizer/src/analyzer/type_coercion.rs b/datafusion/optimizer/src/analyzer/type_coercion.rs
index 2d7a554c67a29..095b7d257e49d 100644
--- a/datafusion/optimizer/src/analyzer/type_coercion.rs
+++ b/datafusion/optimizer/src/analyzer/type_coercion.rs
@@ -33,6 +33,7 @@ use datafusion_common::{
     exec_err, internal_datafusion_err, internal_err, not_impl_err, plan_datafusion_err,
     plan_err,
 };
+use datafusion_expr::BatchedTableFunctionSource;
 use datafusion_expr::expr::{
     self, AggregateFunctionParams, Alias, Between, BinaryExpr, Case, Exists, InList,
     InSubquery, Like, ScalarFunction, Sort, WindowFunction,
@@ -49,7 +50,6 @@ use datafusion_expr::type_coercion::other::{
 };
 use datafusion_expr::type_coercion::{is_datetime, is_utf8_or_utf8view_or_large_utf8};
 use datafusion_expr::utils::merge_schema;
-use datafusion_expr::BatchedTableFunctionSource;
 use datafusion_expr::{
     AggregateUDF, Expr, ExprSchemable, Join, Limit, LogicalPlan, Operator, Projection,
     ScalarUDF, Union, WindowFrame, WindowFrameBound, WindowFrameUnits, is_false,
diff --git a/datafusion/sqllogictest/test_files/named_arguments.slt b/datafusion/sqllogictest/test_files/named_arguments.slt
index 878833eb404cf..578d8035deb70 100644
--- a/datafusion/sqllogictest/test_files/named_arguments.slt
+++ b/datafusion/sqllogictest/test_files/named_arguments.slt
@@ -327,7 +327,7 @@ query error DataFusion error: Error during planning: Parameter 'start' specified
 SELECT * FROM batched_generate_series(start => 1, start => 2);
 
 # Test error: missing required parameter
-query error DataFusion error: (Error during planning: .*'stop'|Execution error: generate_series expects 2 arguments, got 1)
+query error DataFusion error: type_coercion\ncaused by\nError during planning: Function 'batched_generate_series' expects 2 arguments but received 1
 SELECT * FROM batched_generate_series(start => 1);
 
 # Test case insensitivity of parameter names
