This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 1bf775e  Support REST catalog views and SQL functions (#494)
1bf775e is described below

commit 1bf775ead20d6864ede7555624933234cc6bcfcb
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Jul 10 21:57:15 2026 +0800

    Support REST catalog views and SQL functions (#494)
---
 bindings/python/src/context.rs                     |   8 +-
 crates/integrations/datafusion/README.md           |  26 +
 crates/integrations/datafusion/src/catalog.rs      | 422 +++++++++-
 crates/integrations/datafusion/src/lib.rs          |   1 +
 crates/integrations/datafusion/src/sql_context.rs  | 853 ++++++++++++++++++++-
 crates/integrations/datafusion/src/sql_function.rs | 459 +++++++++++
 .../integrations/datafusion/tests/read_tables.rs   |   8 +-
 .../datafusion/tests/sql_context_tests.rs          |   8 +-
 crates/paimon/src/api/api_response.rs              | 118 ++-
 crates/paimon/src/api/mod.rs                       |   5 +-
 crates/paimon/src/api/resource_paths.rs            |  78 ++
 crates/paimon/src/api/rest_api.rs                  | 132 +++-
 crates/paimon/src/catalog/function.rs              | 163 ++++
 crates/paimon/src/catalog/mod.rs                   |  36 +
 crates/paimon/src/catalog/rest/rest_catalog.rs     |  78 ++
 crates/paimon/src/catalog/view.rs                  | 106 +++
 crates/paimon/src/error.rs                         |   4 +
 crates/paimon/tests/mock_server.rs                 | 200 ++++-
 crates/paimon/tests/rest_api_test.rs               | 126 ++-
 crates/paimon/tests/rest_catalog_test.rs           | 184 ++++-
 crates/paimon/tests/rest_object_models_test.rs     | 154 ++++
 docs/src/sql.md                                    |  54 ++
 22 files changed, 3182 insertions(+), 41 deletions(-)

diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index 8cddde0..d92d8ee 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -75,7 +75,13 @@ impl PaimonCatalog {
     #[new]
     fn new(catalog_options: HashMap<String, String>) -> PyResult<Self> {
         let catalog = build_paimon_catalog(catalog_options)?;
-        let provider = 
Arc::new(PaimonCatalogProvider::new(Arc::clone(&catalog)));
+        let provider = Arc::new(PaimonCatalogProvider::new(
+            None,
+            Arc::clone(&catalog),
+            Default::default(),
+            Default::default(),
+            None,
+        ));
         Ok(Self { catalog, provider })
     }
 
diff --git a/crates/integrations/datafusion/README.md 
b/crates/integrations/datafusion/README.md
index 92f8a29..cc7a3a1 100644
--- a/crates/integrations/datafusion/README.md
+++ b/crates/integrations/datafusion/README.md
@@ -24,4 +24,30 @@
 
 This crate contains the integration of [Apache 
DataFusion](https://datafusion.apache.org/) and [Apache 
Paimon](https://paimon.apache.org/).
 
+## REST Catalog views and SQL functions
+
+`SQLContext` can read persistent views and SQL scalar functions that already 
exist in a Paimon
+REST Catalog. No view or function DDL is added.
+
+- A persistent view is resolved lazily like a table. The `datafusion` dialect 
is preferred and the
+  default view query is used when that dialect is absent. Unqualified 
relations inside the view
+  resolve in the view's owning catalog and database.
+- A SQL function can be called as `function(args...)` in the current 
catalog/database or as
+  `catalog.database.function(args...)`. Its `definitions.datafusion` value 
must be a scalar SQL
+  expression, it must be deterministic, and it must declare its input 
parameters and exactly one
+  return parameter.
+- Only reads and execution are supported. Lambda/file functions, named 
arguments, multiple return
+  values, non-deterministic functions, and calls made directly through a raw 
DataFusion
+  `SessionContext` are not supported.
+
+Use `SQLContext::sql` for function expansion:
+
+```rust,ignore
+let mut ctx = paimon_datafusion::SQLContext::new();
+ctx.register_catalog("paimon", rest_catalog).await?;
+
+let view = ctx.sql("SELECT * FROM analytics_view").await?;
+let function = ctx.sql("SELECT normalize_score(score) FROM scores").await?;
+```
+
 See the [documentation](https://paimon.apache.org/docs/rust/datafusion/) for 
getting started guide and more details.
diff --git a/crates/integrations/datafusion/src/catalog.rs 
b/crates/integrations/datafusion/src/catalog.rs
index 7e32bae..5bcdd10 100644
--- a/crates/integrations/datafusion/src/catalog.rs
+++ b/crates/integrations/datafusion/src/catalog.rs
@@ -17,17 +17,23 @@
 
 //! Paimon catalog integration for DataFusion.
 
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet, VecDeque};
 use std::fmt::Debug;
 use std::sync::Arc;
 use std::sync::RwLock;
 
 use async_trait::async_trait;
 use datafusion::catalog::{CatalogProvider, MemorySchemaProvider, 
SchemaProvider};
-use datafusion::common::plan_datafusion_err;
+use datafusion::common::{plan_datafusion_err, Column};
 use datafusion::datasource::TableProvider;
 use datafusion::error::Result as DFResult;
-use paimon::catalog::{Catalog, Identifier};
+use datafusion::execution::SessionState;
+use datafusion::logical_expr::{expr_fn::cast, Expr, LogicalPlan, 
LogicalPlanBuilder};
+use datafusion::sql::planner::IdentNormalizer;
+use datafusion::sql::sqlparser::ast::{Ident, ObjectName, Query, Statement, 
Visit, Visitor};
+use datafusion::sql::sqlparser::dialect::GenericDialect;
+use datafusion::sql::sqlparser::parser::Parser;
+use paimon::catalog::{Catalog, Identifier, View};
 
 use crate::error::to_datafusion_error;
 use crate::runtime::{await_with_runtime, block_on_with_runtime};
@@ -35,12 +41,15 @@ use crate::system_tables;
 use crate::table::PaimonTableProvider;
 use crate::{BlobReaderRegistry, DynamicOptions};
 
+pub(crate) type SessionStateProvider = Arc<dyn Fn() -> Option<SessionState> + 
Send + Sync>;
+
 /// Provides an interface to manage and access multiple schemas (databases)
 /// within a Paimon [`Catalog`].
 ///
 /// This provider uses lazy loading - databases and tables are fetched
 /// on-demand from the catalog, ensuring data is always fresh.
 pub struct PaimonCatalogProvider {
+    catalog_name: Option<String>,
     /// Reference to the Paimon catalog.
     catalog: Arc<dyn Catalog>,
     /// Session-scoped dynamic options shared with the SQL context.
@@ -54,6 +63,7 @@ pub struct PaimonCatalogProvider {
     /// becoming invisible or stale, which is recoverable by re-registering it.
     temp_tables: Arc<RwLock<HashMap<String, Arc<MemorySchemaProvider>>>>,
     blob_reader_registry: BlobReaderRegistry,
+    session_state: Option<SessionStateProvider>,
 }
 
 impl Debug for PaimonCatalogProvider {
@@ -64,29 +74,20 @@ impl Debug for PaimonCatalogProvider {
 
 impl PaimonCatalogProvider {
     /// Creates a new [`PaimonCatalogProvider`].
-    ///
-    /// For standalone use without `SET`/`RESET` support.
-    /// When used via [`SQLContext`], the handler creates the provider
-    /// internally with shared dynamic options.
-    pub fn new(catalog: Arc<dyn Catalog>) -> Self {
-        PaimonCatalogProvider {
-            catalog,
-            dynamic_options: Default::default(),
-            temp_tables: Arc::new(RwLock::new(HashMap::new())),
-            blob_reader_registry: BlobReaderRegistry::default(),
-        }
-    }
-
-    pub(crate) fn with_dynamic_options(
+    pub fn new(
+        catalog_name: Option<String>,
         catalog: Arc<dyn Catalog>,
         dynamic_options: DynamicOptions,
         blob_reader_registry: BlobReaderRegistry,
+        session_state: Option<SessionStateProvider>,
     ) -> Self {
         PaimonCatalogProvider {
+            catalog_name,
             catalog,
             dynamic_options,
             temp_tables: Arc::new(RwLock::new(HashMap::new())),
             blob_reader_registry,
+            session_state,
         }
     }
 }
@@ -109,6 +110,8 @@ impl CatalogProvider for PaimonCatalogProvider {
         let catalog = Arc::clone(&self.catalog);
         let dynamic_options = Arc::clone(&self.dynamic_options);
         let blob_reader_registry = self.blob_reader_registry.clone();
+        let catalog_name = self.catalog_name.clone();
+        let session_state = self.session_state.clone();
         let name = name.to_string();
 
         let temp_provider = {
@@ -120,20 +123,24 @@ impl CatalogProvider for PaimonCatalogProvider {
             async move {
                 match catalog.get_database(&name).await {
                     Ok(_) => Some(Arc::new(PaimonSchemaProvider::new(
+                        catalog_name,
                         Arc::clone(&catalog),
                         name,
                         dynamic_options,
                         temp_provider,
                         blob_reader_registry,
+                        session_state,
                     )) as Arc<dyn SchemaProvider>),
                     Err(paimon::Error::DatabaseNotExist { .. }) => {
                         if temp_provider.is_some() {
                             Some(Arc::new(PaimonSchemaProvider::new(
+                                catalog_name,
                                 Arc::clone(&catalog),
                                 name,
                                 dynamic_options,
                                 temp_provider,
                                 blob_reader_registry,
+                                session_state,
                             )) as Arc<dyn SchemaProvider>)
                         } else {
                             None
@@ -157,6 +164,8 @@ impl CatalogProvider for PaimonCatalogProvider {
         let catalog = Arc::clone(&self.catalog);
         let dynamic_options = Arc::clone(&self.dynamic_options);
         let blob_reader_registry = self.blob_reader_registry.clone();
+        let catalog_name = self.catalog_name.clone();
+        let session_state = self.session_state.clone();
         let name = name.to_string();
         block_on_with_runtime(
             async move {
@@ -165,11 +174,13 @@ impl CatalogProvider for PaimonCatalogProvider {
                     .await
                     .map_err(to_datafusion_error)?;
                 Ok(Some(Arc::new(PaimonSchemaProvider::new(
+                    catalog_name,
                     Arc::clone(&catalog),
                     name,
                     dynamic_options,
                     None,
                     blob_reader_registry,
+                    session_state,
                 )) as Arc<dyn SchemaProvider>))
             },
             "paimon catalog access thread panicked",
@@ -184,6 +195,8 @@ impl CatalogProvider for PaimonCatalogProvider {
         let catalog = Arc::clone(&self.catalog);
         let dynamic_options = Arc::clone(&self.dynamic_options);
         let blob_reader_registry = self.blob_reader_registry.clone();
+        let catalog_name = self.catalog_name.clone();
+        let session_state = self.session_state.clone();
         let name = name.to_string();
         block_on_with_runtime(
             async move {
@@ -192,11 +205,13 @@ impl CatalogProvider for PaimonCatalogProvider {
                     .await
                     .map_err(to_datafusion_error)?;
                 Ok(Some(Arc::new(PaimonSchemaProvider::new(
+                    catalog_name,
                     Arc::clone(&catalog),
                     name,
                     dynamic_options,
                     None,
                     blob_reader_registry,
+                    session_state,
                 )) as Arc<dyn SchemaProvider>))
             },
             "paimon catalog access thread panicked",
@@ -287,6 +302,7 @@ impl PaimonCatalogProvider {
 ///
 /// Tables are loaded lazily when accessed via the `table()` method.
 pub struct PaimonSchemaProvider {
+    catalog_name: Option<String>,
     /// Reference to the Paimon catalog.
     catalog: Arc<dyn Catalog>,
     /// Database name this schema represents.
@@ -296,6 +312,7 @@ pub struct PaimonSchemaProvider {
     /// Optional temporary in-memory provider for temp tables and views.
     temp_provider: Option<Arc<MemorySchemaProvider>>,
     blob_reader_registry: BlobReaderRegistry,
+    session_state: Option<SessionStateProvider>,
 }
 
 impl Debug for PaimonSchemaProvider {
@@ -308,20 +325,24 @@ impl Debug for PaimonSchemaProvider {
 }
 
 impl PaimonSchemaProvider {
-    /// Creates a new [`PaimonSchemaProvider`] with shared dynamic options.
+    /// Creates a new [`PaimonSchemaProvider`].
     pub fn new(
+        catalog_name: Option<String>,
         catalog: Arc<dyn Catalog>,
         database: String,
         dynamic_options: DynamicOptions,
         temp_provider: Option<Arc<MemorySchemaProvider>>,
         blob_reader_registry: BlobReaderRegistry,
+        session_state: Option<SessionStateProvider>,
     ) -> Self {
         PaimonSchemaProvider {
+            catalog_name,
             catalog,
             database,
             dynamic_options,
             temp_provider,
             blob_reader_registry,
+            session_state,
         }
     }
 }
@@ -335,13 +356,21 @@ impl SchemaProvider for PaimonSchemaProvider {
             {
                 let db = database.clone();
                 async move {
-                    match catalog.list_tables(&db).await {
+                    let mut names = match catalog.list_tables(&db).await {
                         Ok(names) => names,
                         Err(e) => {
                             log::error!("failed to list tables in '{}': {e}", 
db);
                             vec![]
                         }
+                    };
+                    match catalog.list_views(&db).await {
+                        Ok(views) => names.extend(views),
+                        Err(paimon::Error::Unsupported { .. }) => {}
+                        Err(error) => {
+                            log::error!("failed to list views in '{}': 
{error}", db);
+                        }
                     }
+                    names
                 }
             },
             "paimon catalog access thread panicked",
@@ -378,6 +407,8 @@ impl SchemaProvider for PaimonSchemaProvider {
         let catalog = Arc::clone(&self.catalog);
         let dynamic_options = Arc::clone(&self.dynamic_options);
         let blob_reader_registry = self.blob_reader_registry.clone();
+        let catalog_name = self.catalog_name.clone();
+        let session_state = self.session_state.clone();
         let identifier = Identifier::new(self.database.clone(), 
object.table().to_string());
         let branch = object.branch().map(str::to_string);
         await_with_runtime(async move {
@@ -412,7 +443,51 @@ impl SchemaProvider for PaimonSchemaProvider {
                     };
                     Ok(Some(Arc::new(provider) as Arc<dyn TableProvider>))
                 }
-                Err(paimon::Error::TableNotExist { .. }) => Ok(None),
+                Err(paimon::Error::TableNotExist { .. }) => {
+                    if branch.is_some() {
+                        return Ok(None);
+                    }
+                    let view = match catalog.get_view(&identifier).await {
+                        Ok(view) => view,
+                        Err(paimon::Error::ViewNotExist { .. })
+                        | Err(paimon::Error::Unsupported { .. }) => return 
Ok(None),
+                        Err(error) => return Err(to_datafusion_error(error)),
+                    };
+                    let catalog_name = catalog_name.ok_or_else(|| {
+                        plan_datafusion_err!(
+                            "REST catalog view '{}' requires a session-aware 
catalog provider",
+                            identifier.full_name()
+                        )
+                    })?;
+                    validate_view_dependencies(&catalog, &catalog_name, 
&view).await?;
+                    let mut state = session_state
+                        .and_then(|provider| provider())
+                        .ok_or_else(|| {
+                            plan_datafusion_err!(
+                                "DataFusion session is unavailable while 
planning REST catalog view '{}'",
+                                identifier.full_name()
+                            )
+                        })?;
+                    state.config_mut().options_mut().catalog.default_catalog =
+                        catalog_name.clone();
+                    state.config_mut().options_mut().catalog.default_schema =
+                        identifier.database().to_string();
+                    let catalogs =
+                        HashMap::from([(catalog_name.clone(), 
Arc::clone(&catalog))]);
+                    let query = crate::sql_function::expand_sql(
+                        view.query_for("datafusion"),
+                        &catalogs,
+                        &catalog_name,
+                        identifier.database(),
+                    )
+                    .await?;
+                    let plan = state.create_logical_plan(&query).await?;
+                    let plan = enforce_view_schema(plan, &view)?;
+                    Ok(Some(Arc::new(datafusion::datasource::ViewTable::new(
+                        plan,
+                        Some(query),
+                    )) as Arc<dyn TableProvider>))
+                }
                 Err(e) => Err(to_datafusion_error(e)),
             }
         })
@@ -458,7 +533,20 @@ impl SchemaProvider for PaimonSchemaProvider {
                             true
                         }
                     }
-                    Err(paimon::Error::TableNotExist { .. }) => false,
+                    Err(paimon::Error::TableNotExist { .. }) => {
+                        if branch.is_some() {
+                            return false;
+                        }
+                        match catalog.get_view(&identifier).await {
+                            Ok(_) => true,
+                            Err(paimon::Error::ViewNotExist { .. })
+                            | Err(paimon::Error::Unsupported { .. }) => false,
+                            Err(error) => {
+                                log::error!("failed to check view '{}': 
{error}", identifier);
+                                false
+                            }
+                        }
+                    }
                     Err(e) => {
                         log::error!("failed to check table '{}': {e}", 
identifier);
                         false
@@ -501,3 +589,295 @@ impl SchemaProvider for PaimonSchemaProvider {
         )
     }
 }
+
+fn enforce_view_schema(plan: LogicalPlan, view: &View) -> 
DFResult<LogicalPlan> {
+    let declared_fields = view.schema().fields();
+    let actual_fields = plan.schema().fields();
+    if actual_fields.len() != declared_fields.len() {
+        return Err(plan_datafusion_err!(
+            "REST catalog view '{}' declares {} fields but its query returns 
{}",
+            view.full_name(),
+            declared_fields.len(),
+            actual_fields.len()
+        ));
+    }
+
+    let expressions = declared_fields
+        .iter()
+        .enumerate()
+        .map(|(index, declared)| {
+            let (qualifier, actual) = plan.schema().qualified_field(index);
+            let column = match qualifier {
+                Some(qualifier) => Column::new(Some(qualifier.clone()), 
actual.name()),
+                None => Column::new_unqualified(actual.name()),
+            };
+            let target_type = 
paimon::arrow::paimon_type_to_arrow(declared.data_type())
+                .map_err(to_datafusion_error)?;
+            Ok(cast(Expr::Column(column), target_type).alias(declared.name()))
+        })
+        .collect::<DFResult<Vec<_>>>()?;
+
+    LogicalPlanBuilder::from(plan).project(expressions)?.build()
+}
+
+const MAX_VIEW_DEPENDENCIES: usize = 64;
+
+async fn validate_view_dependencies(
+    catalog: &Arc<dyn Catalog>,
+    catalog_name: &str,
+    root: &View,
+) -> DFResult<()> {
+    let mut queue = VecDeque::from([root.clone()]);
+    let mut loaded = HashSet::from([root.identifier().clone()]);
+    let mut dependencies = HashMap::<Identifier, Vec<Identifier>>::new();
+
+    while let Some(view) = queue.pop_front() {
+        let candidates = view_relation_identifiers(&view, catalog_name)?;
+        let mut view_dependencies = Vec::new();
+        for identifier in candidates {
+            match catalog.get_table(&identifier).await {
+                Ok(_) => continue,
+                Err(paimon::Error::TableNotExist { .. })
+                | Err(paimon::Error::Unsupported { .. }) => {}
+                Err(error) => return Err(to_datafusion_error(error)),
+            }
+
+            let dependency = match catalog.get_view(&identifier).await {
+                Ok(view) => view,
+                Err(paimon::Error::ViewNotExist { .. })
+                | Err(paimon::Error::Unsupported { .. }) => continue,
+                Err(error) => return Err(to_datafusion_error(error)),
+            };
+            view_dependencies.push(identifier.clone());
+            if loaded.insert(identifier) {
+                if loaded.len() > MAX_VIEW_DEPENDENCIES {
+                    return Err(plan_datafusion_err!(
+                        "REST catalog view '{}' exceeds the dependency limit 
of {}",
+                        root.full_name(),
+                        MAX_VIEW_DEPENDENCIES
+                    ));
+                }
+                queue.push_back(dependency);
+            }
+        }
+        dependencies.insert(view.identifier().clone(), view_dependencies);
+
+        if let Some(cycle) = find_view_dependency_cycle(&dependencies) {
+            let path = cycle
+                .iter()
+                .map(Identifier::full_name)
+                .collect::<Vec<_>>()
+                .join(" -> ");
+            return Err(plan_datafusion_err!(
+                "recursive REST catalog view dependency detected: {path}"
+            ));
+        }
+    }
+    Ok(())
+}
+
+fn view_relation_identifiers(view: &View, catalog_name: &str) -> 
DFResult<Vec<Identifier>> {
+    let statements =
+        Parser::parse_sql(&GenericDialect {}, 
view.query_for("datafusion")).map_err(|error| {
+            plan_datafusion_err!(
+                "Invalid SQL for REST catalog view '{}': {error}",
+                view.full_name()
+            )
+        })?;
+    if statements.len() != 1 {
+        return Err(plan_datafusion_err!(
+            "REST catalog view '{}' must contain exactly one SQL statement",
+            view.full_name()
+        ));
+    }
+    if !matches!(statements.first(), Some(Statement::Query(_))) {
+        return Err(plan_datafusion_err!(
+            "REST catalog view '{}' must contain a single read-only query",
+            view.full_name()
+        ));
+    }
+
+    let mut visitor = ViewRelationVisitor::new(catalog_name, 
view.identifier().database());
+    let _: std::ops::ControlFlow<()> = statements.visit(&mut visitor);
+    Ok(visitor.identifiers)
+}
+
+type SqlIdentifierKey = String;
+
+struct QueryCteScope {
+    visible: HashSet<SqlIdentifierKey>,
+    cte_query_visibility: HashMap<usize, HashSet<SqlIdentifierKey>>,
+}
+
+struct ViewRelationVisitor<'a> {
+    catalog_name: &'a str,
+    current_database: &'a str,
+    scopes: Vec<QueryCteScope>,
+    identifiers: Vec<Identifier>,
+}
+
+impl<'a> ViewRelationVisitor<'a> {
+    fn new(catalog_name: &'a str, current_database: &'a str) -> Self {
+        Self {
+            catalog_name,
+            current_database,
+            scopes: Vec::new(),
+            identifiers: Vec::new(),
+        }
+    }
+}
+
+impl Visitor for ViewRelationVisitor<'_> {
+    type Break = ();
+
+    fn pre_visit_query(&mut self, query: &Query) -> 
std::ops::ControlFlow<Self::Break> {
+        let query_address = query as *const Query as usize;
+        let inherited = self
+            .scopes
+            .last()
+            .map(|scope| {
+                scope
+                    .cte_query_visibility
+                    .get(&query_address)
+                    .unwrap_or(&scope.visible)
+                    .clone()
+            })
+            .unwrap_or_default();
+        let mut visible = inherited.clone();
+        let mut cte_query_visibility = HashMap::new();
+
+        if let Some(with) = &query.with {
+            let local_ctes = with
+                .cte_tables
+                .iter()
+                .map(|cte| sql_identifier_key(&cte.alias.name))
+                .collect::<Vec<_>>();
+            if with.recursive {
+                visible.extend(local_ctes);
+                for cte in &with.cte_tables {
+                    cte_query_visibility
+                        .insert(cte.query.as_ref() as *const Query as usize, 
visible.clone());
+                }
+            } else {
+                for (cte, alias) in with.cte_tables.iter().zip(local_ctes) {
+                    cte_query_visibility
+                        .insert(cte.query.as_ref() as *const Query as usize, 
visible.clone());
+                    visible.insert(alias);
+                }
+            }
+        }
+
+        self.scopes.push(QueryCteScope {
+            visible,
+            cte_query_visibility,
+        });
+        std::ops::ControlFlow::Continue(())
+    }
+
+    fn post_visit_query(&mut self, _query: &Query) -> 
std::ops::ControlFlow<Self::Break> {
+        self.scopes.pop();
+        std::ops::ControlFlow::Continue(())
+    }
+
+    fn pre_visit_relation(&mut self, relation: &ObjectName) -> 
std::ops::ControlFlow<Self::Break> {
+        let is_cte = match relation.0.as_slice() {
+            [part] => part.as_ident().is_some_and(|identifier| {
+                self.scopes
+                    .last()
+                    .is_some_and(|scope| 
scope.visible.contains(&sql_identifier_key(identifier)))
+            }),
+            _ => false,
+        };
+        if !is_cte {
+            if let Some(identifier) =
+                relation_identifier(relation, self.catalog_name, 
self.current_database)
+            {
+                self.identifiers.push(identifier);
+            }
+        }
+        std::ops::ControlFlow::Continue(())
+    }
+}
+
+fn sql_identifier_key(identifier: &Ident) -> SqlIdentifierKey {
+    IdentNormalizer::default().normalize(identifier.clone())
+}
+
+fn relation_identifier(
+    relation: &ObjectName,
+    catalog_name: &str,
+    current_database: &str,
+) -> Option<Identifier> {
+    let parts = relation
+        .0
+        .iter()
+        .map(|part| part.as_ident().map(sql_identifier_key))
+        .collect::<Option<Vec<_>>>()?;
+    match parts.as_slice() {
+        [object] => Some(Identifier::new(current_database, object.as_str())),
+        [database, object] => Some(Identifier::new(database.as_str(), 
object.as_str())),
+        [catalog, database, object] if catalog == catalog_name => {
+            Some(Identifier::new(database.as_str(), object.as_str()))
+        }
+        _ => None,
+    }
+}
+
+fn find_view_dependency_cycle(
+    dependencies: &HashMap<Identifier, Vec<Identifier>>,
+) -> Option<Vec<Identifier>> {
+    fn visit(
+        identifier: &Identifier,
+        dependencies: &HashMap<Identifier, Vec<Identifier>>,
+        finished: &mut HashSet<Identifier>,
+        path: &mut Vec<Identifier>,
+    ) -> Option<Vec<Identifier>> {
+        if let Some(start) = path.iter().position(|entry| entry == identifier) 
{
+            let mut cycle = path[start..].to_vec();
+            cycle.push(identifier.clone());
+            return Some(cycle);
+        }
+        if finished.contains(identifier) {
+            return None;
+        }
+
+        path.push(identifier.clone());
+        if let Some(next_identifiers) = dependencies.get(identifier) {
+            for next in next_identifiers {
+                if let Some(cycle) = visit(next, dependencies, finished, path) 
{
+                    return Some(cycle);
+                }
+            }
+        }
+        path.pop();
+        finished.insert(identifier.clone());
+        None
+    }
+
+    let mut finished = HashSet::new();
+    for identifier in dependencies.keys() {
+        if let Some(cycle) = visit(identifier, dependencies, &mut finished, 
&mut Vec::new()) {
+            return Some(cycle);
+        }
+    }
+    None
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn relation_identifiers_follow_datafusion_normalization() {
+        let relation = ObjectName(vec![
+            
datafusion::sql::sqlparser::ast::ObjectNamePart::Identifier(Ident::new("PAIMON")),
+            
datafusion::sql::sqlparser::ast::ObjectNamePart::Identifier(Ident::new("DEFAULT")),
+            
datafusion::sql::sqlparser::ast::ObjectNamePart::Identifier(Ident::new("ANSWER_VIEW")),
+        ]);
+
+        assert_eq!(
+            relation_identifier(&relation, "paimon", "unused"),
+            Some(Identifier::new("default", "answer_view"))
+        );
+    }
+}
diff --git a/crates/integrations/datafusion/src/lib.rs 
b/crates/integrations/datafusion/src/lib.rs
index 9c1d914..a60d52b 100644
--- a/crates/integrations/datafusion/src/lib.rs
+++ b/crates/integrations/datafusion/src/lib.rs
@@ -52,6 +52,7 @@ mod procedures;
 mod relation_planner;
 pub mod runtime;
 mod sql_context;
+mod sql_function;
 mod system_tables;
 mod table;
 mod table_function_args;
diff --git a/crates/integrations/datafusion/src/sql_context.rs 
b/crates/integrations/datafusion/src/sql_context.rs
index 1513ea9..d136492 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -175,12 +175,17 @@ impl SQLContext {
                 Err(e) => return Err(DataFusionError::External(Box::new(e))),
             }
         }
+        let weak_state = self.ctx.state_weak_ref();
+        let session_state: crate::catalog::SessionStateProvider =
+            Arc::new(move || weak_state.upgrade().map(|state| 
state.read().clone()));
         self.ctx.register_catalog(
             &catalog_name,
-            
Arc::new(crate::catalog::PaimonCatalogProvider::with_dynamic_options(
+            Arc::new(crate::catalog::PaimonCatalogProvider::new(
+                Some(catalog_name.clone()),
                 catalog.clone(),
                 self.dynamic_options.clone(),
                 self.blob_reader_registry.clone(),
+                Some(session_state),
             )),
         );
         register_table_functions(&self.ctx, &catalog, 
default_db.unwrap_or("default"));
@@ -475,6 +480,24 @@ impl SQLContext {
                 )
                 .await
             }
+            Statement::Query(_) | Statement::Explain { .. } => {
+                let current_catalog = self.current_catalog_name();
+                let current_database = self
+                    .ctx
+                    .state()
+                    .config_options()
+                    .catalog
+                    .default_schema
+                    .clone();
+                let expanded = crate::sql_function::expand_statement(
+                    statements[0].clone(),
+                    &self.catalogs,
+                    &current_catalog,
+                    &current_database,
+                )
+                .await?;
+                self.ctx.sql(&expanded.to_string()).await
+            }
             _ => self.ctx.sql(sql).await,
         }
     }
@@ -602,8 +625,23 @@ impl SQLContext {
             );
         }
 
-        // Execute the rewritten SQL; tracker auto-deregisters on drop
-        self.ctx.sql(&rewritten_sql).await
+        // Execute the rewritten SQL; tracker auto-deregisters on drop.
+        let current_catalog = self.current_catalog_name();
+        let current_database = self
+            .ctx
+            .state()
+            .config_options()
+            .catalog
+            .default_schema
+            .clone();
+        let expanded = crate::sql_function::expand_sql(
+            &rewritten_sql,
+            &self.catalogs,
+            &current_catalog,
+            &current_database,
+        )
+        .await?;
+        self.ctx.sql(&expanded).await
     }
 
     /// Parse a timestamp string to milliseconds since epoch (using local 
timezone).
@@ -2653,7 +2691,9 @@ mod tests {
 
     use async_trait::async_trait;
     use paimon::catalog::Database;
-    use paimon::spec::{DataType as PaimonDataType, IntType, Schema as 
PaimonSchema};
+    use paimon::spec::{
+        DataField as PaimonDataField, DataType as PaimonDataType, IntType, 
Schema as PaimonSchema,
+    };
     use paimon::table::Table;
 
     // ==================== Mock Catalog ====================
@@ -2681,6 +2721,8 @@ mod tests {
     struct MockCatalog {
         calls: Mutex<Vec<CatalogCall>>,
         existing_table: Mutex<Option<Table>>,
+        functions: Mutex<HashMap<Identifier, paimon::catalog::Function>>,
+        views: Mutex<HashMap<Identifier, paimon::catalog::View>>,
     }
 
     impl MockCatalog {
@@ -2688,12 +2730,28 @@ mod tests {
             Self {
                 calls: Mutex::new(Vec::new()),
                 existing_table: Mutex::new(None),
+                functions: Mutex::new(HashMap::new()),
+                views: Mutex::new(HashMap::new()),
             }
         }
 
         fn take_calls(&self) -> Vec<CatalogCall> {
             std::mem::take(&mut *self.calls.lock().unwrap())
         }
+
+        fn add_function(&self, function: paimon::catalog::Function) {
+            self.functions
+                .lock()
+                .unwrap()
+                .insert(function.identifier().clone(), function);
+        }
+
+        fn add_view(&self, view: paimon::catalog::View) {
+            self.views
+                .lock()
+                .unwrap()
+                .insert(view.identifier().clone(), view);
+        }
     }
 
     #[async_trait]
@@ -2710,9 +2768,7 @@ mod tests {
             Ok(())
         }
         async fn get_database(&self, _name: &str) -> paimon::Result<Database> {
-            Err(paimon::Error::DatabaseNotExist {
-                database: _name.to_string(),
-            })
+            Ok(Database::new(_name.to_string(), HashMap::new(), None))
         }
         async fn drop_database(
             &self,
@@ -2779,6 +2835,53 @@ mod tests {
             });
             Ok(())
         }
+
+        async fn list_functions(&self, database_name: &str) -> 
paimon::Result<Vec<String>> {
+            Ok(self
+                .functions
+                .lock()
+                .unwrap()
+                .keys()
+                .filter(|identifier| identifier.database() == database_name)
+                .map(|identifier| identifier.object().to_string())
+                .collect())
+        }
+
+        async fn get_function(
+            &self,
+            identifier: &Identifier,
+        ) -> paimon::Result<paimon::catalog::Function> {
+            self.functions
+                .lock()
+                .unwrap()
+                .get(identifier)
+                .cloned()
+                .ok_or_else(|| paimon::Error::FunctionNotExist {
+                    full_name: identifier.full_name(),
+                })
+        }
+
+        async fn list_views(&self, database_name: &str) -> 
paimon::Result<Vec<String>> {
+            Ok(self
+                .views
+                .lock()
+                .unwrap()
+                .keys()
+                .filter(|identifier| identifier.database() == database_name)
+                .map(|identifier| identifier.object().to_string())
+                .collect())
+        }
+
+        async fn get_view(&self, identifier: &Identifier) -> 
paimon::Result<paimon::catalog::View> {
+            self.views
+                .lock()
+                .unwrap()
+                .get(identifier)
+                .cloned()
+                .ok_or_else(|| paimon::Error::ViewNotExist {
+                    full_name: identifier.full_name(),
+                })
+        }
     }
 
     async fn make_sql_context(catalog: Arc<MockCatalog>) -> SQLContext {
@@ -2787,6 +2890,742 @@ mod tests {
         ctx
     }
 
+    fn add_unary_sql_function(
+        catalog: &MockCatalog,
+        name: &str,
+        definition: &str,
+        deterministic: bool,
+    ) {
+        add_unary_sql_function_in_database(catalog, "default", name, 
definition, deterministic);
+    }
+
+    fn add_unary_sql_function_in_database(
+        catalog: &MockCatalog,
+        database: &str,
+        name: &str,
+        definition: &str,
+        deterministic: bool,
+    ) {
+        let input_params: Vec<PaimonDataField> = 
serde_json::from_value(serde_json::json!([
+            {"id": 0, "name": "x", "type": "BIGINT"}
+        ]))
+        .unwrap();
+        let return_params: Vec<PaimonDataField> = 
serde_json::from_value(serde_json::json!([
+            {"id": 0, "name": "result", "type": "BIGINT"}
+        ]))
+        .unwrap();
+        catalog.add_function(paimon::catalog::Function::new(
+            Identifier::new(database, name),
+            Some(input_params),
+            Some(return_params),
+            deterministic,
+            HashMap::from([(
+                "datafusion".to_string(),
+                paimon::catalog::FunctionDefinition::Sql {
+                    definition: definition.to_string(),
+                },
+            )]),
+            None,
+            HashMap::new(),
+        ));
+    }
+
+    fn add_plus_one_function(catalog: &MockCatalog) {
+        add_unary_sql_function(catalog, "plus_one", "x + 1", true);
+    }
+
+    fn add_constant_view(catalog: &MockCatalog) {
+        let schema = serde_json::from_value(serde_json::json!({
+            "fields": [
+                {"id": 0, "name": "answer", "type": "BIGINT"}
+            ],
+            "query": "SELECT CAST(0 AS BIGINT) AS answer",
+            "dialects": {
+                "datafusion": "SELECT CAST(42 AS INT) AS source_answer"
+            },
+            "comment": null,
+            "options": {}
+        }))
+        .unwrap();
+        catalog.add_view(paimon::catalog::View::new(
+            Identifier::new("default", "answer_view"),
+            schema,
+        ));
+    }
+
+    fn add_bigint_view(catalog: &MockCatalog, database: &str, name: &str, 
query: &str) {
+        let schema = serde_json::from_value(serde_json::json!({
+            "fields": [
+                {"id": 0, "name": "answer", "type": "BIGINT"}
+            ],
+            "query": query,
+            "dialects": {},
+            "comment": null,
+            "options": {}
+        }))
+        .unwrap();
+        catalog.add_view(paimon::catalog::View::new(
+            Identifier::new(database, name),
+            schema,
+        ));
+    }
+
+    #[tokio::test]
+    async fn rest_catalog_view_is_planned_lazily() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_constant_view(&catalog);
+        let ctx = make_sql_context(catalog).await;
+
+        let batches = ctx
+            .sql("SELECT * FROM answer_view")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        let answers = batches[0]
+            .column_by_name("answer")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(answers.value(0), 42);
+    }
+
+    #[tokio::test]
+    async fn rest_catalog_view_can_call_bare_sql_function() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_plus_one_function(&catalog);
+        let schema = serde_json::from_value(serde_json::json!({
+            "fields": [
+                {"id": 0, "name": "answer", "type": "BIGINT"}
+            ],
+            "query": "SELECT plus_one(41) AS answer",
+            "dialects": {},
+            "comment": null,
+            "options": {}
+        }))
+        .unwrap();
+        catalog.add_view(paimon::catalog::View::new(
+            Identifier::new("default", "function_view"),
+            schema,
+        ));
+        let ctx = make_sql_context(catalog).await;
+
+        let batches = ctx
+            .sql("SELECT * FROM function_view")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        let answers = batches[0]
+            .column_by_name("answer")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(answers.value(0), 42);
+    }
+
+    #[tokio::test]
+    async fn rest_catalog_view_is_discoverable_from_schema_provider() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_constant_view(&catalog);
+        let ctx = make_sql_context(catalog).await;
+
+        let schema = ctx
+            .ctx
+            .catalog("paimon")
+            .unwrap()
+            .schema("default")
+            .unwrap();
+
+        assert!(schema.table_names().contains(&"answer_view".to_string()));
+        assert!(schema.table_exist("answer_view"));
+    }
+
+    #[tokio::test]
+    async fn nested_rest_catalog_view_binds_bare_names_to_owning_database() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_bigint_view(
+            &catalog,
+            "default",
+            "base_view",
+            "SELECT CAST(42 AS BIGINT) AS answer",
+        );
+        add_bigint_view(
+            &catalog,
+            "other",
+            "base_view",
+            "SELECT CAST(7 AS BIGINT) AS answer",
+        );
+        add_bigint_view(&catalog, "default", "outer_view", "SELECT * FROM 
base_view");
+        let ctx = make_sql_context(catalog).await;
+        ctx.set_current_database("other").await.unwrap();
+
+        let batches = ctx
+            .sql("SELECT * FROM paimon.default.outer_view")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        let answers = batches[0]
+            .column_by_name("answer")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(answers.value(0), 42);
+    }
+
+    #[tokio::test]
+    async fn recursive_rest_catalog_views_are_rejected() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_bigint_view(
+            &catalog,
+            "default",
+            "first_view",
+            "SELECT * FROM second_view",
+        );
+        add_bigint_view(
+            &catalog,
+            "default",
+            "second_view",
+            "SELECT * FROM first_view",
+        );
+        let ctx = make_sql_context(catalog).await;
+
+        let error = tokio::time::timeout(
+            std::time::Duration::from_secs(2),
+            ctx.sql("SELECT * FROM first_view"),
+        )
+        .await
+        .expect("recursive view planning should terminate")
+        .unwrap_err()
+        .to_string();
+
+        assert!(
+            error.contains("recursive REST catalog view"),
+            "unexpected error: {error}"
+        );
+    }
+
+    #[tokio::test]
+    async fn rest_catalog_view_allows_cte_with_same_name() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_bigint_view(
+            &catalog,
+            "default",
+            "cte_view",
+            "WITH wrapper AS (\
+                 WITH cte_view AS (SELECT CAST(42 AS BIGINT) AS answer) \
+                 SELECT * FROM cte_view\
+             ) SELECT * FROM wrapper",
+        );
+        let ctx = make_sql_context(catalog).await;
+
+        let batches = ctx
+            .sql("SELECT * FROM cte_view")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        let answers = batches[0]
+            .column_by_name("answer")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(answers.value(0), 42);
+    }
+
+    #[tokio::test]
+    async fn rest_catalog_view_normalizes_cte_identifiers() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_bigint_view(
+            &catalog,
+            "default",
+            "cte_view",
+            "WITH cte_view AS (SELECT CAST(42 AS BIGINT) AS answer) \
+             SELECT * FROM \"cte_view\"",
+        );
+        let ctx = make_sql_context(catalog).await;
+
+        let batches = ctx
+            .sql("SELECT * FROM cte_view")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        let answers = batches[0]
+            .column_by_name("answer")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(answers.value(0), 42);
+    }
+
+    #[tokio::test]
+    async fn rest_catalog_view_rejects_non_query_sql() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_bigint_view(
+            &catalog,
+            "default",
+            "unsafe_view",
+            "DELETE FROM missing_table",
+        );
+        let ctx = make_sql_context(catalog).await;
+
+        let error = ctx
+            .sql("SELECT * FROM unsafe_view")
+            .await
+            .unwrap_err()
+            .to_string();
+
+        assert!(
+            error.contains("read-only query"),
+            "unexpected error: {error}"
+        );
+    }
+
+    #[tokio::test]
+    async fn bare_rest_sql_function_is_expanded() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_plus_one_function(&catalog);
+        let ctx = make_sql_context(catalog).await;
+
+        let batches = ctx
+            .sql("SELECT plus_one(41) AS answer")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        let answers = batches[0]
+            .column_by_name("answer")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(answers.value(0), 42);
+    }
+
+    #[tokio::test]
+    async fn rest_sql_function_normalizes_call_identifiers() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_plus_one_function(&catalog);
+        let ctx = make_sql_context(catalog).await;
+
+        let batches = ctx
+            .sql("SELECT PAIMON.DEFAULT.PLUS_ONE(41) AS answer")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        let answers = batches[0]
+            .column_by_name("answer")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(answers.value(0), 42);
+    }
+
+    #[tokio::test]
+    async fn rest_sql_function_accepts_outer_column_argument() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_plus_one_function(&catalog);
+        let ctx = make_sql_context(catalog).await;
+
+        let batches = ctx
+            .sql("SELECT plus_one(x) AS answer FROM (VALUES (41)) AS t(x)")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        let answers = batches[0]
+            .column_by_name("answer")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(answers.value(0), 42);
+    }
+
+    #[tokio::test]
+    async fn fully_qualified_rest_sql_function_is_expanded() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_plus_one_function(&catalog);
+        let ctx = make_sql_context(catalog).await;
+
+        let batches = ctx
+            .sql("SELECT paimon.default.plus_one(41) AS answer")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        let answers = batches[0]
+            .column_by_name("answer")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(answers.value(0), 42);
+    }
+
+    #[tokio::test]
+    async fn rest_sql_function_result_is_cast_to_declared_type() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_unary_sql_function(&catalog, "narrow_body", "CAST(x AS INT)", 
true);
+        let ctx = make_sql_context(catalog).await;
+
+        let batches = ctx
+            .sql("SELECT narrow_body(41) AS answer")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        let answers = batches[0]
+            .column_by_name("answer")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(answers.value(0), 41);
+    }
+
+    #[tokio::test]
+    async fn rest_sql_function_normalizes_definition_parameters() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_unary_sql_function(&catalog, "uppercase_parameter", "X + 1", true);
+        let ctx = make_sql_context(catalog).await;
+
+        let batches = ctx
+            .sql("SELECT uppercase_parameter(41) AS answer")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        let answers = batches[0]
+            .column_by_name("answer")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(answers.value(0), 42);
+    }
+
+    #[tokio::test]
+    async fn rest_sql_function_preserves_quoted_metadata_parameter() {
+        let catalog = Arc::new(MockCatalog::new());
+        let input_params: Vec<PaimonDataField> = 
serde_json::from_value(serde_json::json!([
+            {"id": 0, "name": "X", "type": "BIGINT"}
+        ]))
+        .unwrap();
+        let return_params: Vec<PaimonDataField> = 
serde_json::from_value(serde_json::json!([
+            {"id": 0, "name": "result", "type": "BIGINT"}
+        ]))
+        .unwrap();
+        catalog.add_function(paimon::catalog::Function::new(
+            Identifier::new("default", "quoted_parameter"),
+            Some(input_params),
+            Some(return_params),
+            true,
+            HashMap::from([(
+                "datafusion".to_string(),
+                paimon::catalog::FunctionDefinition::Sql {
+                    definition: "\"X\" + 1".to_string(),
+                },
+            )]),
+            None,
+            HashMap::new(),
+        ));
+        let ctx = make_sql_context(catalog).await;
+
+        let batches = ctx
+            .sql("SELECT quoted_parameter(41) AS answer")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        let answers = batches[0]
+            .column_by_name("answer")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(answers.value(0), 42);
+    }
+
+    #[tokio::test]
+    async fn rest_sql_function_is_expanded_in_explain() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_plus_one_function(&catalog);
+        let ctx = make_sql_context(catalog).await;
+
+        ctx.sql("EXPLAIN SELECT plus_one(1)")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+    }
+
+    #[tokio::test]
+    async fn rest_sql_function_is_expanded_in_time_travel_query() {
+        let temp_dir = tempfile::tempdir().unwrap();
+        let mut options = paimon::Options::new();
+        options.set(
+            paimon::CatalogOptions::WAREHOUSE,
+            temp_dir.path().to_string_lossy(),
+        );
+        let storage_catalog = 
Arc::new(paimon::FileSystemCatalog::new(options).unwrap());
+        let mut setup = SQLContext::new();
+        setup
+            .register_catalog("paimon", storage_catalog.clone())
+            .await
+            .unwrap();
+        setup
+            .sql("CREATE TABLE paimon.default.time_travel_source (id INT)")
+            .await
+            .unwrap();
+        setup
+            .sql("INSERT INTO paimon.default.time_travel_source VALUES (41)")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+
+        let catalog = Arc::new(MockCatalog::new());
+        *catalog.existing_table.lock().unwrap() = Some(
+            storage_catalog
+                .get_table(&Identifier::new("default", "time_travel_source"))
+                .await
+                .unwrap(),
+        );
+        add_plus_one_function(&catalog);
+        let ctx = make_sql_context(catalog).await;
+
+        let batches = ctx
+            .sql(
+                "SELECT plus_one(id) AS answer \
+                 FROM time_travel_source VERSION AS OF 1",
+            )
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        let answers = batches[0]
+            .column_by_name("answer")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(answers.value(0), 42);
+    }
+
+    #[tokio::test]
+    async fn rest_sql_function_rejects_undeclared_identifiers() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_unary_sql_function(&catalog, "captures_column", "x + y", true);
+        let ctx = make_sql_context(catalog).await;
+
+        let error = ctx
+            .sql("SELECT captures_column(41)")
+            .await
+            .unwrap_err()
+            .to_string();
+
+        assert!(
+            error.contains("undeclared identifier 'y'"),
+            "unexpected error: {error}"
+        );
+    }
+
+    #[tokio::test]
+    async fn non_deterministic_rest_sql_function_is_rejected() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_unary_sql_function(&catalog, "unsafe_plus_one", "x + 1", false);
+        let ctx = make_sql_context(catalog).await;
+
+        let error = ctx
+            .sql("SELECT unsafe_plus_one(41)")
+            .await
+            .unwrap_err()
+            .to_string();
+
+        assert!(
+            error.contains("non-deterministic"),
+            "unexpected error: {error}"
+        );
+    }
+
+    #[tokio::test]
+    async fn rest_sql_function_without_single_return_is_rejected() {
+        let catalog = Arc::new(MockCatalog::new());
+        let input_params: Vec<PaimonDataField> = 
serde_json::from_value(serde_json::json!([
+            {"id": 0, "name": "x", "type": "BIGINT"}
+        ]))
+        .unwrap();
+        catalog.add_function(paimon::catalog::Function::new(
+            Identifier::new("default", "missing_return"),
+            Some(input_params),
+            None,
+            true,
+            HashMap::from([(
+                "datafusion".to_string(),
+                paimon::catalog::FunctionDefinition::Sql {
+                    definition: "x + 1".to_string(),
+                },
+            )]),
+            None,
+            HashMap::new(),
+        ));
+        let ctx = make_sql_context(catalog).await;
+
+        let error = ctx
+            .sql("SELECT missing_return(41)")
+            .await
+            .unwrap_err()
+            .to_string();
+
+        assert!(
+            error.contains("exactly one return parameter"),
+            "unexpected error: {error}"
+        );
+    }
+
+    #[tokio::test]
+    async fn nested_rest_sql_functions_are_expanded() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_plus_one_function(&catalog);
+        add_unary_sql_function(&catalog, "plus_two", "plus_one(x) + 1", true);
+        let ctx = make_sql_context(catalog).await;
+
+        let batches = ctx
+            .sql("SELECT plus_two(40) AS answer")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        let answers = batches[0]
+            .column_by_name("answer")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(answers.value(0), 42);
+    }
+
+    #[tokio::test]
+    async fn nested_rest_sql_function_binds_to_owning_database() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_unary_sql_function(&catalog, "plus_one", "x + 100", true);
+        add_unary_sql_function_in_database(&catalog, "other", "plus_one", "x + 
1", true);
+        add_unary_sql_function_in_database(&catalog, "other", "plus_two", 
"plus_one(x) + 1", true);
+        let ctx = make_sql_context(catalog).await;
+
+        let batches = ctx
+            .sql("SELECT paimon.other.plus_two(40) AS answer")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        let answers = batches[0]
+            .column_by_name("answer")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(answers.value(0), 42);
+    }
+
+    #[tokio::test]
+    async fn nested_rest_sql_function_normalizes_owning_database_reference() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_unary_sql_function(&catalog, "plus_one", "x + 100", true);
+        add_unary_sql_function_in_database(&catalog, "other", "plus_one", "x + 
1", true);
+        add_unary_sql_function_in_database(&catalog, "other", "plus_two", 
"PLUS_ONE(x) + 1", true);
+        let ctx = make_sql_context(catalog).await;
+
+        let batches = ctx
+            .sql("SELECT paimon.other.plus_two(40) AS answer")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        let answers = batches[0]
+            .column_by_name("answer")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(answers.value(0), 42);
+    }
+
+    #[tokio::test]
+    async fn recursive_rest_sql_functions_are_rejected() {
+        let catalog = Arc::new(MockCatalog::new());
+        add_unary_sql_function(&catalog, "first", "second(x)", true);
+        add_unary_sql_function(&catalog, "second", "first(x)", true);
+        let ctx = make_sql_context(catalog).await;
+
+        let error = ctx.sql("SELECT first(1)").await.unwrap_err().to_string();
+
+        assert!(error.contains("recursive"), "unexpected error: {error}");
+    }
+
+    #[tokio::test]
+    async fn branching_rest_sql_function_expansion_is_bounded() {
+        let catalog = Arc::new(MockCatalog::new());
+        for index in 0..3 {
+            let next = index + 1;
+            add_unary_sql_function(
+                &catalog,
+                &format!("f{index}"),
+                &format!("f{next}(x) + f{next}(x)"),
+                true,
+            );
+        }
+        add_unary_sql_function(&catalog, "f3", "x", true);
+        let ctx = make_sql_context(catalog).await;
+        let statement = Parser::parse_sql(&GenericDialect {}, "SELECT f0(1)")
+            .unwrap()
+            .remove(0);
+
+        let error = crate::sql_function::expand_statement_with_budget(
+            statement,
+            &ctx.catalogs,
+            &ctx.current_catalog_name(),
+            "default",
+            4,
+        )
+        .await
+        .unwrap_err()
+        .to_string();
+
+        assert!(
+            error.contains("expansion budget"),
+            "unexpected error: {error}"
+        );
+    }
+
     // ==================== register_catalog_with_default_db tests 
====================
 
     /// Counts get/create_database calls so tests can assert whether the 
default-db
diff --git a/crates/integrations/datafusion/src/sql_function.rs 
b/crates/integrations/datafusion/src/sql_function.rs
new file mode 100644
index 0000000..88d52be
--- /dev/null
+++ b/crates/integrations/datafusion/src/sql_function.rs
@@ -0,0 +1,459 @@
+// 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.
+
+use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
+use std::ops::ControlFlow;
+use std::sync::Arc;
+
+use datafusion::error::{DataFusionError, Result as DFResult};
+use datafusion::sql::planner::IdentNormalizer;
+use datafusion::sql::sqlparser::ast::{
+    visit_expressions, visit_expressions_mut, Expr as SqlExpr, FunctionArg, 
FunctionArgExpr,
+    FunctionArguments, Ident, ObjectName, Statement,
+};
+use datafusion::sql::sqlparser::dialect::GenericDialect;
+use datafusion::sql::sqlparser::parser::Parser;
+use paimon::catalog::{Catalog, Function, Identifier};
+
+const MAX_EXPANSION_DEPTH: usize = 32;
+const MAX_FUNCTION_REFERENCES: usize = 1024;
+const MAX_EXPANDED_CALLS: usize = 1024;
+type FunctionReference = (String, Identifier);
+
+pub(crate) async fn expand_sql(
+    sql: &str,
+    catalogs: &HashMap<String, Arc<dyn Catalog>>,
+    current_catalog: &str,
+    current_database: &str,
+) -> DFResult<String> {
+    let mut statements = Parser::parse_sql(&GenericDialect {}, sql)
+        .map_err(|error| DataFusionError::Plan(format!("Invalid REST SQL: 
{error}")))?;
+    if statements.len() != 1 {
+        return Err(DataFusionError::Plan(format!(
+            "REST SQL must contain exactly one statement, found {}",
+            statements.len()
+        )));
+    }
+    let statement = expand_statement(
+        statements.remove(0),
+        catalogs,
+        current_catalog,
+        current_database,
+    )
+    .await?;
+    Ok(statement.to_string())
+}
+
+pub(crate) async fn expand_statement(
+    statement: Statement,
+    catalogs: &HashMap<String, Arc<dyn Catalog>>,
+    current_catalog: &str,
+    current_database: &str,
+) -> DFResult<Statement> {
+    expand_statement_with_budget(
+        statement,
+        catalogs,
+        current_catalog,
+        current_database,
+        MAX_EXPANDED_CALLS,
+    )
+    .await
+}
+
+pub(crate) async fn expand_statement_with_budget(
+    mut statement: Statement,
+    catalogs: &HashMap<String, Arc<dyn Catalog>>,
+    current_catalog: &str,
+    current_database: &str,
+    max_expanded_calls: usize,
+) -> DFResult<Statement> {
+    let mut functions = HashMap::new();
+    let mut dependencies = HashMap::new();
+    let mut total_expanded_calls = 0;
+    for _ in 0..MAX_EXPANSION_DEPTH {
+        let mut references = BTreeMap::new();
+        let _: ControlFlow<()> = visit_expressions(&statement, |expr| {
+            if let SqlExpr::Function(function) = expr {
+                if let Some(reference) =
+                    function_reference(function, current_catalog, 
current_database)
+                {
+                    references.insert(reference, ());
+                }
+            }
+            ControlFlow::Continue(())
+        });
+
+        let mut pending = references.into_keys().collect::<VecDeque<_>>();
+        while let Some(reference) = pending.pop_front() {
+            if functions.contains_key(&reference) {
+                continue;
+            }
+            if functions.len() >= MAX_FUNCTION_REFERENCES {
+                return Err(DataFusionError::Plan(format!(
+                    "REST SQL function expansion exceeded the reference limit 
of {MAX_FUNCTION_REFERENCES}"
+                )));
+            }
+            let Some(catalog) = catalogs.get(&reference.0) else {
+                functions.insert(reference, None);
+                continue;
+            };
+            let function = match catalog.get_function(&reference.1).await {
+                Ok(function) => Some(function),
+                Err(paimon::Error::FunctionNotExist { .. })
+                | Err(paimon::Error::Unsupported { .. }) => None,
+                Err(error) => return Err(crate::to_datafusion_error(error)),
+            };
+            if let Some(function) = &function {
+                let nested = function_dependencies(function, &reference.0, 
reference.1.database());
+                pending.extend(nested.iter().cloned());
+                dependencies.insert(reference.clone(), nested);
+            }
+            functions.insert(reference, function);
+        }
+
+        if let Some(cycle) = find_dependency_cycle(&dependencies) {
+            let path = cycle
+                .iter()
+                .map(|(catalog, identifier)| format!("{catalog}.{}", 
identifier.full_name()))
+                .collect::<Vec<_>>()
+                .join(" -> ");
+            return Err(DataFusionError::Plan(format!(
+                "recursive REST SQL function dependency detected: {path}"
+            )));
+        }
+
+        let mut expanded_count = 0;
+        let flow = visit_expressions_mut(&mut statement, |expr| {
+            let SqlExpr::Function(call) = expr else {
+                return ControlFlow::Continue(());
+            };
+            let Some(reference) = function_reference(call, current_catalog, 
current_database)
+            else {
+                return ControlFlow::Continue(());
+            };
+            let Some(Some(function)) = functions.get(&reference) else {
+                return ControlFlow::Continue(());
+            };
+            if total_expanded_calls >= max_expanded_calls {
+                return ControlFlow::Break(DataFusionError::Plan(format!(
+                    "REST SQL function expansion budget of 
{max_expanded_calls} calls exceeded"
+                )));
+            }
+            total_expanded_calls += 1;
+
+            match expand_call(function, call, &reference.0, &functions) {
+                Ok(expanded) => {
+                    *expr = expanded;
+                    expanded_count += 1;
+                    ControlFlow::Continue(())
+                }
+                Err(error) => ControlFlow::Break(error),
+            }
+        });
+        if let ControlFlow::Break(error) = flow {
+            return Err(error);
+        }
+        if expanded_count == 0 {
+            return Ok(statement);
+        }
+    }
+
+    Err(DataFusionError::Plan(format!(
+        "REST SQL function expansion exceeded maximum depth of 
{MAX_EXPANSION_DEPTH}"
+    )))
+}
+
+fn function_dependencies(
+    function: &Function,
+    current_catalog: &str,
+    current_database: &str,
+) -> Vec<FunctionReference> {
+    let Some(definition) = function
+        .definition("datafusion")
+        .and_then(paimon::catalog::FunctionDefinition::sql)
+    else {
+        return Vec::new();
+    };
+    let Ok(mut parser) = Parser::new(&GenericDialect 
{}).try_with_sql(definition) else {
+        return Vec::new();
+    };
+    let Ok(body) = parser.parse_expr() else {
+        return Vec::new();
+    };
+    let mut dependencies = Vec::new();
+    let _: ControlFlow<()> = visit_expressions(&body, |expr| {
+        if let SqlExpr::Function(call) = expr {
+            if let Some(reference) = function_reference(call, current_catalog, 
current_database) {
+                dependencies.push(reference);
+            }
+        }
+        ControlFlow::Continue(())
+    });
+    dependencies
+}
+
+fn find_dependency_cycle(
+    dependencies: &HashMap<FunctionReference, Vec<FunctionReference>>,
+) -> Option<Vec<FunctionReference>> {
+    fn visit(
+        reference: &FunctionReference,
+        dependencies: &HashMap<FunctionReference, Vec<FunctionReference>>,
+        finished: &mut HashSet<FunctionReference>,
+        path: &mut Vec<FunctionReference>,
+    ) -> Option<Vec<FunctionReference>> {
+        if let Some(start) = path.iter().position(|entry| entry == reference) {
+            let mut cycle = path[start..].to_vec();
+            cycle.push(reference.clone());
+            return Some(cycle);
+        }
+        if finished.contains(reference) {
+            return None;
+        }
+
+        path.push(reference.clone());
+        if let Some(next_references) = dependencies.get(reference) {
+            for next in next_references {
+                if let Some(cycle) = visit(next, dependencies, finished, path) 
{
+                    return Some(cycle);
+                }
+            }
+        }
+        path.pop();
+        finished.insert(reference.clone());
+        None
+    }
+
+    let mut finished = HashSet::new();
+    for reference in dependencies.keys() {
+        if let Some(cycle) = visit(reference, dependencies, &mut finished, 
&mut Vec::new()) {
+            return Some(cycle);
+        }
+    }
+    None
+}
+
+fn function_reference(
+    function: &datafusion::sql::sqlparser::ast::Function,
+    current_catalog: &str,
+    current_database: &str,
+) -> Option<(String, Identifier)> {
+    let identifiers = function
+        .name
+        .0
+        .iter()
+        .map(|part| part.as_ident().map(normalize_identifier))
+        .collect::<Option<Vec<_>>>()?;
+    match identifiers.as_slice() {
+        [function] => Some((
+            current_catalog.to_string(),
+            Identifier::new(current_database, function),
+        )),
+        [catalog, database, function] => {
+            Some((catalog.clone(), Identifier::new(database, function)))
+        }
+        _ => None,
+    }
+}
+
+fn normalize_identifier(identifier: &Ident) -> String {
+    IdentNormalizer::default().normalize(identifier.clone())
+}
+
+fn expand_call(
+    function: &Function,
+    call: &datafusion::sql::sqlparser::ast::Function,
+    owner_catalog: &str,
+    functions: &HashMap<FunctionReference, Option<Function>>,
+) -> DFResult<SqlExpr> {
+    if !function.is_deterministic() {
+        return Err(DataFusionError::Plan(format!(
+            "REST SQL function '{}' is non-deterministic and is not supported",
+            function.full_name()
+        )));
+    }
+    if function
+        .return_params()
+        .map(<[paimon::spec::DataField]>::len)
+        != Some(1)
+    {
+        return Err(DataFusionError::Plan(format!(
+            "REST SQL function '{}' must declare exactly one return parameter",
+            function.full_name()
+        )));
+    }
+    let input_params = function.input_params().ok_or_else(|| {
+        DataFusionError::Plan(format!(
+            "REST SQL function '{}' has no input parameters",
+            function.full_name()
+        ))
+    })?;
+    let definition = function
+        .definition("datafusion")
+        .and_then(paimon::catalog::FunctionDefinition::sql)
+        .ok_or_else(|| {
+            DataFusionError::Plan(format!(
+                "REST SQL function '{}' has no datafusion SQL definition",
+                function.full_name()
+            ))
+        })?;
+
+    let FunctionArguments::List(args) = &call.args else {
+        return Err(DataFusionError::Plan(format!(
+            "REST SQL function '{}' requires positional arguments",
+            function.full_name()
+        )));
+    };
+    let values = args
+        .args
+        .iter()
+        .map(|arg| match arg {
+            FunctionArg::Unnamed(FunctionArgExpr::Expr(expr)) => 
Ok(expr.clone()),
+            _ => Err(DataFusionError::Plan(format!(
+                "REST SQL function '{}' requires positional expression 
arguments",
+                function.full_name()
+            ))),
+        })
+        .collect::<DFResult<Vec<_>>>()?;
+    if values.len() != input_params.len() {
+        return Err(DataFusionError::Plan(format!(
+            "REST SQL function '{}' expects {} arguments but received {}",
+            function.full_name(),
+            input_params.len(),
+            values.len()
+        )));
+    }
+
+    let mut body = Parser::new(&GenericDialect {})
+        .try_with_sql(definition)
+        .map_err(|error| {
+            DataFusionError::Plan(format!("Invalid SQL function definition: 
{error}"))
+        })?
+        .parse_expr()
+        .map_err(|error| {
+            DataFusionError::Plan(format!("Invalid SQL function definition: 
{error}"))
+        })?;
+    let replacements: HashMap<String, SqlExpr> = input_params
+        .iter()
+        .zip(values)
+        .map(|(field, value)| (field.name().to_string(), value))
+        .collect();
+    let validation = visit_expressions(&body, |expr| match expr {
+        SqlExpr::Identifier(identifier)
+            if !replacements.contains_key(&normalize_identifier(identifier)) =>
+        {
+            ControlFlow::Break(identifier.value.clone())
+        }
+        SqlExpr::CompoundIdentifier(identifiers) => ControlFlow::Break(
+            identifiers
+                .iter()
+                .map(|identifier| identifier.value.as_str())
+                .collect::<Vec<_>>()
+                .join("."),
+        ),
+        _ => ControlFlow::Continue(()),
+    });
+    if let ControlFlow::Break(identifier) = validation {
+        return Err(DataFusionError::Plan(format!(
+            "REST SQL function '{}' references undeclared identifier 
'{identifier}'",
+            function.full_name()
+        )));
+    }
+    let _: ControlFlow<()> = visit_expressions_mut(&mut body, |expr| {
+        let SqlExpr::Function(call) = expr else {
+            return ControlFlow::Continue(());
+        };
+        let Some(function_name) = bare_function_name(call) else {
+            return ControlFlow::Continue(());
+        };
+        let function_name = normalize_identifier(&function_name);
+        let reference = (
+            owner_catalog.to_string(),
+            Identifier::new(function.identifier().database(), &function_name),
+        );
+        if matches!(functions.get(&reference), Some(Some(_))) {
+            call.name = ObjectName::from(vec![
+                Ident::with_quote('"', owner_catalog),
+                Ident::with_quote('"', function.identifier().database()),
+                Ident::with_quote('"', function_name),
+            ]);
+        }
+        ControlFlow::Continue(())
+    });
+    let _: ControlFlow<()> = visit_expressions_mut(&mut body, |expr| {
+        if let SqlExpr::Identifier(identifier) = expr {
+            if let Some(replacement) = 
replacements.get(&normalize_identifier(identifier)) {
+                *expr = replacement.clone();
+            }
+        }
+        ControlFlow::Continue(())
+    });
+
+    let return_type = function.return_params().expect("validated 
above")[0].data_type();
+    let serialized_type = serde_json::to_value(return_type).map_err(|error| {
+        DataFusionError::Plan(format!(
+            "Invalid return type for REST SQL function '{}': {error}",
+            function.full_name()
+        ))
+    })?;
+    let sql_type = serialized_type.as_str().ok_or_else(|| {
+        DataFusionError::Plan(format!(
+            "REST SQL function '{}' has a return type that cannot be 
represented in SQL",
+            function.full_name()
+        ))
+    })?;
+    let sql_type = sql_type.strip_suffix(" NOT NULL").unwrap_or(sql_type);
+    Parser::new(&GenericDialect {})
+        .try_with_sql(&format!("CAST(({body}) AS {sql_type})"))
+        .map_err(|error| {
+            DataFusionError::Plan(format!(
+                "Invalid return type for REST SQL function '{}': {error}",
+                function.full_name()
+            ))
+        })?
+        .parse_expr()
+        .map_err(|error| {
+            DataFusionError::Plan(format!(
+                "Invalid return type for REST SQL function '{}': {error}",
+                function.full_name()
+            ))
+        })
+}
+
+fn bare_function_name(function: &datafusion::sql::sqlparser::ast::Function) -> 
Option<Ident> {
+    match function.name.0.as_slice() {
+        [part] => part.as_ident().cloned(),
+        _ => None,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::collections::HashMap;
+
+    use super::expand_sql;
+
+    #[tokio::test]
+    async fn leaves_bare_functions_for_datafusion_without_a_paimon_catalog() {
+        let sql = "SELECT vector_from_json('[1, 2.5]')";
+
+        let expanded = expand_sql(sql, &HashMap::new(), "datafusion", "public")
+            .await
+            .unwrap();
+
+        assert_eq!(expanded, sql);
+    }
+}
diff --git a/crates/integrations/datafusion/tests/read_tables.rs 
b/crates/integrations/datafusion/tests/read_tables.rs
index 55db55b..c0a5e6d 100644
--- a/crates/integrations/datafusion/tests/read_tables.rs
+++ b/crates/integrations/datafusion/tests/read_tables.rs
@@ -745,7 +745,13 @@ async fn test_query_via_catalog_provider() {
 #[tokio::test]
 async fn test_missing_database_returns_no_schema() {
     let catalog = create_catalog();
-    let provider = PaimonCatalogProvider::new(Arc::new(catalog));
+    let provider = PaimonCatalogProvider::new(
+        None,
+        Arc::new(catalog),
+        Default::default(),
+        Default::default(),
+        None,
+    );
 
     assert!(
         provider.schema("definitely_missing_database").is_none(),
diff --git a/crates/integrations/datafusion/tests/sql_context_tests.rs 
b/crates/integrations/datafusion/tests/sql_context_tests.rs
index d8934ee..f04f184 100644
--- a/crates/integrations/datafusion/tests/sql_context_tests.rs
+++ b/crates/integrations/datafusion/tests/sql_context_tests.rs
@@ -622,7 +622,13 @@ async fn test_drop_schema() {
 #[tokio::test]
 async fn test_schema_names_via_catalog_provider() {
     let (_tmp, catalog) = create_test_env();
-    let provider = PaimonCatalogProvider::new(catalog.clone());
+    let provider = PaimonCatalogProvider::new(
+        None,
+        catalog.clone(),
+        Default::default(),
+        Default::default(),
+        None,
+    );
 
     catalog
         .create_database("db_a", false, Default::default())
diff --git a/crates/paimon/src/api/api_response.rs 
b/crates/paimon/src/api/api_response.rs
index 092008a..9586c7f 100644
--- a/crates/paimon/src/api/api_response.rs
+++ b/crates/paimon/src/api/api_response.rs
@@ -22,7 +22,8 @@
 use serde::{Deserialize, Serialize};
 use std::collections::HashMap;
 
-use crate::spec::Schema;
+use crate::catalog::{Function, FunctionDefinition, ViewSchema};
+use crate::spec::{DataField, Schema};
 
 /// Error response from REST API calls.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -130,6 +131,81 @@ pub struct GetTableResponse {
     pub schema: Option<Schema>,
 }
 
+/// Response for getting a persistent view.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GetViewResponse {
+    /// Audit information.
+    #[serde(flatten)]
+    pub audit: AuditRESTResponse,
+    /// The unique identifier of the view.
+    pub id: Option<String>,
+    /// The name of the view.
+    pub name: Option<String>,
+    /// Stored view schema and SQL representations.
+    pub schema: ViewSchema,
+}
+
+/// Response for getting a persistent function.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GetFunctionResponse {
+    /// Audit information.
+    #[serde(flatten)]
+    pub audit: AuditRESTResponse,
+    /// The unique identifier of the function.
+    pub uuid: Option<String>,
+    /// The name of the function.
+    pub name: Option<String>,
+    /// Declared input parameters.
+    pub input_params: Option<Vec<DataField>>,
+    /// Declared return parameters.
+    pub return_params: Option<Vec<DataField>>,
+    /// Whether the function is deterministic.
+    pub deterministic: bool,
+    /// Engine-specific function definitions.
+    pub definitions: HashMap<String, FunctionDefinition>,
+    /// Optional function comment.
+    pub comment: Option<String>,
+    /// Function options.
+    #[serde(default)]
+    pub options: HashMap<String, String>,
+}
+
+impl GetFunctionResponse {
+    /// Create a response from a catalog function.
+    pub fn from_function(function: &Function, audit: AuditRESTResponse) -> 
Self {
+        Self {
+            audit,
+            uuid: None,
+            name: Some(function.name().to_string()),
+            input_params: function.input_params().map(<[DataField]>::to_vec),
+            return_params: function.return_params().map(<[DataField]>::to_vec),
+            deterministic: function.is_deterministic(),
+            definitions: function.definitions().clone(),
+            comment: function.comment().map(ToString::to_string),
+            options: function.options().clone(),
+        }
+    }
+}
+
+impl GetViewResponse {
+    /// Create a new get-view response.
+    pub fn new(
+        id: Option<String>,
+        name: Option<String>,
+        schema: ViewSchema,
+        audit: AuditRESTResponse,
+    ) -> Self {
+        Self {
+            audit,
+            id,
+            name,
+            schema,
+        }
+    }
+}
+
 impl GetTableResponse {
     /// Create a new GetTableResponse.
     #[allow(clippy::too_many_arguments)]
@@ -248,6 +324,46 @@ pub struct ListTablesResponse {
     pub next_page_token: Option<String>,
 }
 
+/// Response for listing persistent views.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ListViewsResponse {
+    /// View names.
+    pub views: Option<Vec<String>>,
+    /// Token for the next page.
+    pub next_page_token: Option<String>,
+}
+
+/// Response for listing persistent functions.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ListFunctionsResponse {
+    /// Function names.
+    pub functions: Option<Vec<String>>,
+    /// Token for the next page.
+    pub next_page_token: Option<String>,
+}
+
+impl ListFunctionsResponse {
+    /// Create a list-functions response.
+    pub fn new(functions: Vec<String>, next_page_token: Option<String>) -> 
Self {
+        Self {
+            functions: Some(functions),
+            next_page_token,
+        }
+    }
+}
+
+impl ListViewsResponse {
+    /// Create a list-views response.
+    pub fn new(views: Vec<String>, next_page_token: Option<String>) -> Self {
+        Self {
+            views: Some(views),
+            next_page_token,
+        }
+    }
+}
+
 impl ListTablesResponse {
     /// Create a new ListTablesResponse.
     pub fn new(tables: Option<Vec<String>>, next_page_token: Option<String>) 
-> Self {
diff --git a/crates/paimon/src/api/mod.rs b/crates/paimon/src/api/mod.rs
index 7672b9a..a81067b 100644
--- a/crates/paimon/src/api/mod.rs
+++ b/crates/paimon/src/api/mod.rs
@@ -37,8 +37,9 @@ pub use api_request::{
 
 // Re-export response types
 pub use api_response::{
-    AuditRESTResponse, ConfigResponse, ErrorResponse, GetDatabaseResponse, 
GetTableResponse,
-    GetTableTokenResponse, ListDatabasesResponse, ListPartitionsResponse, 
ListTablesResponse,
+    AuditRESTResponse, ConfigResponse, ErrorResponse, GetDatabaseResponse, 
GetFunctionResponse,
+    GetTableResponse, GetTableTokenResponse, GetViewResponse, 
ListDatabasesResponse,
+    ListFunctionsResponse, ListPartitionsResponse, ListTablesResponse, 
ListViewsResponse,
     PagedList,
 };
 
diff --git a/crates/paimon/src/api/resource_paths.rs 
b/crates/paimon/src/api/resource_paths.rs
index 45505da..4983ae7 100644
--- a/crates/paimon/src/api/resource_paths.rs
+++ b/crates/paimon/src/api/resource_paths.rs
@@ -33,6 +33,8 @@ impl ResourcePaths {
     const TABLES: &'static str = "tables";
     const TABLE_DETAILS: &'static str = "table-details";
     const PARTITIONS: &'static str = "partitions";
+    const VIEWS: &'static str = "views";
+    const FUNCTIONS: &'static str = "functions";
 
     /// Create a new ResourcePaths with the given prefix.
     pub fn new(prefix: &str) -> Self {
@@ -105,6 +107,46 @@ impl ResourcePaths {
         )
     }
 
+    /// Get the views endpoint path for a database.
+    pub fn views(&self, database_name: &str) -> String {
+        format!(
+            "{}/{}/{}/{}",
+            self.base_path,
+            Self::DATABASES,
+            RESTUtil::encode_string(database_name),
+            Self::VIEWS
+        )
+    }
+
+    /// Get a specific view endpoint path.
+    pub fn view(&self, database_name: &str, view_name: &str) -> String {
+        format!(
+            "{}/{}",
+            self.views(database_name),
+            RESTUtil::encode_string(view_name)
+        )
+    }
+
+    /// Get the functions endpoint path for a database.
+    pub fn functions(&self, database_name: &str) -> String {
+        format!(
+            "{}/{}/{}/{}",
+            self.base_path,
+            Self::DATABASES,
+            RESTUtil::encode_string(database_name),
+            Self::FUNCTIONS
+        )
+    }
+
+    /// Get a specific function endpoint path.
+    pub fn function(&self, database_name: &str, function_name: &str) -> String 
{
+        format!(
+            "{}/{}",
+            self.functions(database_name),
+            RESTUtil::encode_string(function_name)
+        )
+    }
+
     /// Get the table details endpoint path.
     pub fn table_details(&self, database_name: &str) -> String {
         format!(
@@ -204,4 +246,40 @@ mod tests {
     fn test_config_path() {
         assert_eq!(ResourcePaths::config(), "/v1/config");
     }
+
+    #[test]
+    fn test_views_path_encodes_database() {
+        let paths = ResourcePaths::new("catalog");
+        assert_eq!(
+            paths.views("analytics db"),
+            "/v1/catalog/databases/analytics+db/views"
+        );
+    }
+
+    #[test]
+    fn test_view_path_encodes_object_name() {
+        let paths = ResourcePaths::new("catalog");
+        assert_eq!(
+            paths.view("analytics", "active ids"),
+            "/v1/catalog/databases/analytics/views/active+ids"
+        );
+    }
+
+    #[test]
+    fn test_functions_path_encodes_database() {
+        let paths = ResourcePaths::new("catalog");
+        assert_eq!(
+            paths.functions("analytics db"),
+            "/v1/catalog/databases/analytics+db/functions"
+        );
+    }
+
+    #[test]
+    fn test_function_path_encodes_object_name() {
+        let paths = ResourcePaths::new("catalog");
+        assert_eq!(
+            paths.function("analytics", "rectangle area"),
+            "/v1/catalog/databases/analytics/functions/rectangle+area"
+        );
+    }
 }
diff --git a/crates/paimon/src/api/rest_api.rs 
b/crates/paimon/src/api/rest_api.rs
index 8721f48..421a23d 100644
--- a/crates/paimon/src/api/rest_api.rs
+++ b/crates/paimon/src/api/rest_api.rs
@@ -33,8 +33,9 @@ use super::api_request::{
     RenameTableRequest,
 };
 use super::api_response::{
-    ConfigResponse, GetDatabaseResponse, GetTableResponse, 
ListDatabasesResponse,
-    ListPartitionsResponse, ListTablesResponse, PagedList,
+    ConfigResponse, GetDatabaseResponse, GetFunctionResponse, 
GetTableResponse, GetViewResponse,
+    ListDatabasesResponse, ListFunctionsResponse, ListPartitionsResponse, 
ListTablesResponse,
+    ListViewsResponse, PagedList,
 };
 use super::auth::{AuthProviderFactory, RESTAuthFunction};
 use super::resource_paths::ResourcePaths;
@@ -88,6 +89,8 @@ impl RESTApi {
     pub const PAGE_TOKEN: &'static str = "pageToken";
     pub const DATABASE_NAME_PATTERN: &'static str = "databaseNamePattern";
     pub const TABLE_NAME_PATTERN: &'static str = "tableNamePattern";
+    pub const VIEW_NAME_PATTERN: &'static str = "viewNamePattern";
+    pub const FUNCTION_NAME_PATTERN: &'static str = "functionNamePattern";
     pub const TABLE_TYPE: &'static str = "tableType";
 
     /// Create a new RESTApi from options.
@@ -392,6 +395,131 @@ impl RESTApi {
         Ok(())
     }
 
+    // ==================== View Operations ====================
+
+    /// List persistent views in a database.
+    pub async fn list_views(&self, database: &str) -> Result<Vec<String>> {
+        validate_non_empty(database, "database name")?;
+        let mut results = Vec::new();
+        let mut page_token = None;
+        loop {
+            let page = self
+                .list_views_paged(database, None, page_token.as_deref(), None)
+                .await?;
+            let is_empty = page.elements.is_empty();
+            results.extend(page.elements);
+            page_token = page.next_page_token;
+            if page_token.is_none() || is_empty {
+                break;
+            }
+        }
+        Ok(results)
+    }
+
+    /// List persistent views in a database with pagination.
+    pub async fn list_views_paged(
+        &self,
+        database: &str,
+        max_results: Option<u32>,
+        page_token: Option<&str>,
+        view_name_pattern: Option<&str>,
+    ) -> Result<PagedList<String>> {
+        validate_non_empty(database, "database name")?;
+        let path = self.resource_paths.views(database);
+        let mut params = Vec::new();
+        if let Some(max_results) = max_results {
+            params.push((Self::MAX_RESULTS, max_results.to_string()));
+        }
+        if let Some(page_token) = page_token {
+            params.push((Self::PAGE_TOKEN, page_token.to_string()));
+        }
+        if let Some(view_name_pattern) = view_name_pattern {
+            params.push((Self::VIEW_NAME_PATTERN, 
view_name_pattern.to_string()));
+        }
+        let response: ListViewsResponse = if params.is_empty() {
+            self.client.get(&path, None::<&[(&str, &str)]>).await?
+        } else {
+            self.client.get(&path, Some(&params)).await?
+        };
+        Ok(PagedList::new(
+            response.views.unwrap_or_default(),
+            response.next_page_token,
+        ))
+    }
+
+    /// Get persistent view information.
+    pub async fn get_view(&self, identifier: &Identifier) -> 
Result<GetViewResponse> {
+        let database = identifier.database();
+        let view = identifier.object();
+        validate_non_empty_multi(&[(database, "database name"), (view, "view 
name")])?;
+        let path = self.resource_paths.view(database, view);
+        self.client.get(&path, None::<&[(&str, &str)]>).await
+    }
+
+    // ==================== Function Operations ====================
+
+    /// List persistent functions in a database.
+    pub async fn list_functions(&self, database: &str) -> Result<Vec<String>> {
+        validate_non_empty(database, "database name")?;
+        let mut results = Vec::new();
+        let mut page_token = None;
+        loop {
+            let page = self
+                .list_functions_paged(database, None, page_token.as_deref(), 
None)
+                .await?;
+            let is_empty = page.elements.is_empty();
+            results.extend(page.elements);
+            page_token = page.next_page_token;
+            if page_token.is_none() || is_empty {
+                break;
+            }
+        }
+        Ok(results)
+    }
+
+    /// List persistent functions in a database with pagination.
+    pub async fn list_functions_paged(
+        &self,
+        database: &str,
+        max_results: Option<u32>,
+        page_token: Option<&str>,
+        function_name_pattern: Option<&str>,
+    ) -> Result<PagedList<String>> {
+        validate_non_empty(database, "database name")?;
+        let path = self.resource_paths.functions(database);
+        let mut params = Vec::new();
+        if let Some(max_results) = max_results {
+            params.push((Self::MAX_RESULTS, max_results.to_string()));
+        }
+        if let Some(page_token) = page_token {
+            params.push((Self::PAGE_TOKEN, page_token.to_string()));
+        }
+        if let Some(function_name_pattern) = function_name_pattern {
+            params.push((
+                Self::FUNCTION_NAME_PATTERN,
+                function_name_pattern.to_string(),
+            ));
+        }
+        let response: ListFunctionsResponse = if params.is_empty() {
+            self.client.get(&path, None::<&[(&str, &str)]>).await?
+        } else {
+            self.client.get(&path, Some(&params)).await?
+        };
+        Ok(PagedList::new(
+            response.functions.unwrap_or_default(),
+            response.next_page_token,
+        ))
+    }
+
+    /// Get persistent function information.
+    pub async fn get_function(&self, identifier: &Identifier) -> 
Result<GetFunctionResponse> {
+        let database = identifier.database();
+        let function = identifier.object();
+        validate_non_empty_multi(&[(database, "database name"), (function, 
"function name")])?;
+        let path = self.resource_paths.function(database, function);
+        self.client.get(&path, None::<&[(&str, &str)]>).await
+    }
+
     // ==================== Partition Operations ====================
 
     /// List all partitions of a table, paging internally.
diff --git a/crates/paimon/src/catalog/function.rs 
b/crates/paimon/src/catalog/function.rs
new file mode 100644
index 0000000..37003de
--- /dev/null
+++ b/crates/paimon/src/catalog/function.rs
@@ -0,0 +1,163 @@
+// 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.
+
+use std::collections::HashMap;
+
+use serde::{Deserialize, Serialize};
+
+use crate::spec::DataField;
+
+use super::Identifier;
+
+/// A persistent catalog function.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Function {
+    identifier: Identifier,
+    input_params: Option<Vec<DataField>>,
+    return_params: Option<Vec<DataField>>,
+    deterministic: bool,
+    definitions: HashMap<String, FunctionDefinition>,
+    comment: Option<String>,
+    options: HashMap<String, String>,
+}
+
+impl Function {
+    /// Create a catalog function from REST metadata.
+    #[allow(clippy::too_many_arguments)]
+    pub fn new(
+        identifier: Identifier,
+        input_params: Option<Vec<DataField>>,
+        return_params: Option<Vec<DataField>>,
+        deterministic: bool,
+        definitions: HashMap<String, FunctionDefinition>,
+        comment: Option<String>,
+        options: HashMap<String, String>,
+    ) -> Self {
+        Self {
+            identifier,
+            input_params,
+            return_params,
+            deterministic,
+            definitions,
+            comment,
+            options,
+        }
+    }
+
+    /// Function identifier.
+    pub fn identifier(&self) -> &Identifier {
+        &self.identifier
+    }
+
+    /// Unqualified function name.
+    pub fn name(&self) -> &str {
+        self.identifier.object()
+    }
+
+    /// Database-qualified function name.
+    pub fn full_name(&self) -> String {
+        self.identifier.full_name()
+    }
+
+    /// Declared input parameters, when present.
+    pub fn input_params(&self) -> Option<&[DataField]> {
+        self.input_params.as_deref()
+    }
+
+    /// Declared return parameters, when present.
+    pub fn return_params(&self) -> Option<&[DataField]> {
+        self.return_params.as_deref()
+    }
+
+    /// Whether the function is deterministic.
+    pub fn is_deterministic(&self) -> bool {
+        self.deterministic
+    }
+
+    /// Definition for an execution engine.
+    pub fn definition(&self, dialect: &str) -> Option<&FunctionDefinition> {
+        self.definitions.get(dialect)
+    }
+
+    /// All engine definitions.
+    pub fn definitions(&self) -> &HashMap<String, FunctionDefinition> {
+        &self.definitions
+    }
+
+    /// Optional function comment.
+    pub fn comment(&self) -> Option<&str> {
+        self.comment.as_deref()
+    }
+
+    /// Function options.
+    pub fn options(&self) -> &HashMap<String, String> {
+        &self.options
+    }
+}
+
+/// Engine-specific implementation of a catalog function.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "lowercase")]
+pub enum FunctionDefinition {
+    /// A function loaded from external resources.
+    File {
+        #[serde(rename = "fileResources")]
+        file_resources: Vec<FunctionFileResource>,
+        language: String,
+        #[serde(rename = "className")]
+        class_name: String,
+        #[serde(rename = "functionName")]
+        function_name: String,
+    },
+    /// A scalar SQL expression.
+    Sql { definition: String },
+    /// A language-specific lambda expression.
+    Lambda {
+        definition: String,
+        language: String,
+    },
+}
+
+/// External resource referenced by a file function definition.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct FunctionFileResource {
+    resource_type: String,
+    uri: String,
+}
+
+impl FunctionFileResource {
+    /// Resource type such as `jar`.
+    pub fn resource_type(&self) -> &str {
+        &self.resource_type
+    }
+
+    /// Resource URI.
+    pub fn uri(&self) -> &str {
+        &self.uri
+    }
+}
+
+impl FunctionDefinition {
+    /// Return the SQL expression when this is an SQL definition.
+    pub fn sql(&self) -> Option<&str> {
+        match self {
+            Self::Sql { definition } => Some(definition),
+            _ => None,
+        }
+    }
+}
diff --git a/crates/paimon/src/catalog/mod.rs b/crates/paimon/src/catalog/mod.rs
index 6d5db12..fa653a1 100644
--- a/crates/paimon/src/catalog/mod.rs
+++ b/crates/paimon/src/catalog/mod.rs
@@ -23,8 +23,10 @@
 mod database;
 mod factory;
 mod filesystem;
+mod function;
 mod partition_listing;
 mod rest;
+mod view;
 
 use std::collections::HashMap;
 use std::fmt;
@@ -33,9 +35,11 @@ use crate::{Error, Result};
 pub use database::*;
 pub use factory::*;
 pub use filesystem::*;
+pub use function::*;
 pub use partition_listing::list_partitions_from_file_system;
 pub use rest::*;
 use serde::{Deserialize, Serialize};
+pub use view::*;
 
 /// Splitter for system table names (e.g. `table$snapshots`).
 pub const SYSTEM_TABLE_SPLITTER: &str = "$";
@@ -371,6 +375,38 @@ pub trait Catalog: Send + Sync {
         ignore_if_not_exists: bool,
     ) -> Result<()>;
 
+    // ======================= view methods ===============================
+
+    /// List persistent view names in a database.
+    async fn list_views(&self, _database_name: &str) -> Result<Vec<String>> {
+        Err(Error::Unsupported {
+            message: "Catalog does not support views".to_string(),
+        })
+    }
+
+    /// Get a persistent view by identifier.
+    async fn get_view(&self, _identifier: &Identifier) -> Result<View> {
+        Err(Error::Unsupported {
+            message: "Catalog does not support views".to_string(),
+        })
+    }
+
+    // ======================= function methods ===============================
+
+    /// List persistent function names in a database.
+    async fn list_functions(&self, _database_name: &str) -> 
Result<Vec<String>> {
+        Err(Error::Unsupported {
+            message: "Catalog does not support functions".to_string(),
+        })
+    }
+
+    /// Get a persistent function by identifier.
+    async fn get_function(&self, _identifier: &Identifier) -> Result<Function> 
{
+        Err(Error::Unsupported {
+            message: "Catalog does not support functions".to_string(),
+        })
+    }
+
     /// List partitions for a table.
     ///
     /// Default impl scans the table's manifest entries via
diff --git a/crates/paimon/src/catalog/rest/rest_catalog.rs 
b/crates/paimon/src/catalog/rest/rest_catalog.rs
index 4d87008..6901312 100644
--- a/crates/paimon/src/catalog/rest/rest_catalog.rs
+++ b/crates/paimon/src/catalog/rest/rest_catalog.rs
@@ -278,6 +278,49 @@ impl Catalog for RESTCatalog {
         })
     }
 
+    async fn list_views(&self, database_name: &str) -> Result<Vec<String>> {
+        self.api
+            .list_views(database_name)
+            .await
+            .map_err(|e| map_unsupported_endpoint(e, "view"))
+    }
+
+    async fn get_view(&self, identifier: &Identifier) -> 
Result<crate::catalog::View> {
+        let response = self
+            .api
+            .get_view(identifier)
+            .await
+            .map_err(|e| map_rest_error_for_view(e, identifier))?;
+        Ok(crate::catalog::View::new(
+            identifier.clone(),
+            response.schema,
+        ))
+    }
+
+    async fn list_functions(&self, database_name: &str) -> Result<Vec<String>> 
{
+        self.api
+            .list_functions(database_name)
+            .await
+            .map_err(|e| map_unsupported_endpoint(e, "function"))
+    }
+
+    async fn get_function(&self, identifier: &Identifier) -> 
Result<crate::catalog::Function> {
+        let response = self
+            .api
+            .get_function(identifier)
+            .await
+            .map_err(|e| map_rest_error_for_function(e, identifier))?;
+        Ok(crate::catalog::Function::new(
+            identifier.clone(),
+            response.input_params,
+            response.return_params,
+            response.deterministic,
+            response.definitions,
+            response.comment,
+            response.options,
+        ))
+    }
+
     async fn list_partitions(&self, identifier: &Identifier) -> 
Result<Vec<Partition>> {
         match self.api.list_partitions(identifier).await {
             Ok(parts) => Ok(parts),
@@ -360,6 +403,41 @@ fn map_rest_error_for_table(err: Error, identifier: 
&Identifier) -> Error {
     }
 }
 
+/// Map a REST API error to a catalog-level view error.
+fn map_rest_error_for_view(err: Error, identifier: &Identifier) -> Error {
+    match err {
+        Error::RestApi {
+            source: RestError::NoSuchResource { .. },
+        } => Error::ViewNotExist {
+            full_name: identifier.full_name(),
+        },
+        other => map_unsupported_endpoint(other, "view"),
+    }
+}
+
+/// Map a REST API error to a catalog-level function error.
+fn map_rest_error_for_function(err: Error, identifier: &Identifier) -> Error {
+    match err {
+        Error::RestApi {
+            source: RestError::NoSuchResource { .. },
+        } => Error::FunctionNotExist {
+            full_name: identifier.full_name(),
+        },
+        other => map_unsupported_endpoint(other, "function"),
+    }
+}
+
+fn map_unsupported_endpoint(err: Error, object_type: &str) -> Error {
+    match err {
+        Error::RestApi {
+            source: RestError::NotImplemented { message },
+        } => Error::Unsupported {
+            message: format!("REST catalog {object_type} endpoint is not 
supported: {message}"),
+        },
+        other => other,
+    }
+}
+
 /// Execute a fallible operation and ignore a specific error variant.
 ///
 /// If the operation succeeds, returns `Ok(())`.
diff --git a/crates/paimon/src/catalog/view.rs 
b/crates/paimon/src/catalog/view.rs
new file mode 100644
index 0000000..d99f819
--- /dev/null
+++ b/crates/paimon/src/catalog/view.rs
@@ -0,0 +1,106 @@
+// 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.
+
+use std::collections::HashMap;
+
+use serde::{Deserialize, Serialize};
+
+use crate::spec::DataField;
+
+use super::Identifier;
+
+/// A persistent catalog view.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct View {
+    identifier: Identifier,
+    schema: ViewSchema,
+}
+
+impl View {
+    /// Create a catalog view from its identifier and stored schema.
+    pub fn new(identifier: Identifier, schema: ViewSchema) -> Self {
+        Self { identifier, schema }
+    }
+
+    /// View identifier.
+    pub fn identifier(&self) -> &Identifier {
+        &self.identifier
+    }
+
+    /// Unqualified view name.
+    pub fn name(&self) -> &str {
+        self.identifier.object()
+    }
+
+    /// Database-qualified view name.
+    pub fn full_name(&self) -> String {
+        self.identifier.full_name()
+    }
+
+    /// Stored schema and SQL representations.
+    pub fn schema(&self) -> &ViewSchema {
+        &self.schema
+    }
+
+    /// SQL representation for a dialect, falling back to the default query.
+    pub fn query_for(&self, dialect: &str) -> &str {
+        self.schema.query_for(dialect)
+    }
+}
+
+/// Schema and SQL representations stored for a catalog view.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ViewSchema {
+    fields: Vec<DataField>,
+    query: String,
+    #[serde(default)]
+    dialects: HashMap<String, String>,
+    comment: Option<String>,
+    #[serde(default)]
+    options: HashMap<String, String>,
+}
+
+impl ViewSchema {
+    /// Declared output fields of the view.
+    pub fn fields(&self) -> &[DataField] {
+        &self.fields
+    }
+
+    /// Default SQL representation of the view.
+    pub fn query(&self) -> &str {
+        &self.query
+    }
+
+    /// SQL representation for a dialect, falling back to the default query.
+    pub fn query_for(&self, dialect: &str) -> &str {
+        self.dialects
+            .get(dialect)
+            .map(String::as_str)
+            .unwrap_or(&self.query)
+    }
+
+    /// Optional view comment.
+    pub fn comment(&self) -> Option<&str> {
+        self.comment.as_deref()
+    }
+
+    /// View options.
+    pub fn options(&self) -> &HashMap<String, String> {
+        &self.options
+    }
+}
diff --git a/crates/paimon/src/error.rs b/crates/paimon/src/error.rs
index 56a4d3c..ddac874 100644
--- a/crates/paimon/src/error.rs
+++ b/crates/paimon/src/error.rs
@@ -102,6 +102,10 @@ pub enum Error {
     TableAlreadyExist { full_name: String },
     #[snafu(display("Table {} does not exist.", full_name))]
     TableNotExist { full_name: String },
+    #[snafu(display("View {} does not exist.", full_name))]
+    ViewNotExist { full_name: String },
+    #[snafu(display("Function {} does not exist.", full_name))]
+    FunctionNotExist { full_name: String },
     #[snafu(display("Column {} already exists in table {}.", column, 
full_name))]
     ColumnAlreadyExist { full_name: String, column: String },
     #[snafu(display("Column {} does not exist in table {}.", column, 
full_name))]
diff --git a/crates/paimon/tests/mock_server.rs 
b/crates/paimon/tests/mock_server.rs
index 6a9f38d..99e11e6 100644
--- a/crates/paimon/tests/mock_server.rs
+++ b/crates/paimon/tests/mock_server.rs
@@ -35,14 +35,20 @@ use tokio::task::JoinHandle;
 
 use paimon::api::{
     AlterDatabaseRequest, AlterTableRequest, AuditRESTResponse, 
ConfigResponse, ErrorResponse,
-    GetDatabaseResponse, GetTableResponse, ListDatabasesResponse, 
ListTablesResponse,
+    GetDatabaseResponse, GetFunctionResponse, GetTableResponse, 
GetViewResponse,
+    ListDatabasesResponse, ListFunctionsResponse, ListTablesResponse, 
ListViewsResponse,
     RenameTableRequest, ResourcePaths,
 };
+use paimon::catalog::Function;
 
 #[derive(Clone, Debug, Default)]
 struct MockState {
     databases: HashMap<String, GetDatabaseResponse>,
     tables: HashMap<String, GetTableResponse>,
+    views: HashMap<String, GetViewResponse>,
+    functions: HashMap<String, GetFunctionResponse>,
+    view_function_endpoints_unsupported: bool,
+    list_page_size: Option<usize>,
     no_permission_databases: HashSet<String>,
     no_permission_tables: HashSet<String>,
     /// ECS metadata role name (for token loader testing)
@@ -51,6 +57,24 @@ struct MockState {
     ecs_token: Option<serde_json::Value>,
 }
 
+fn paginate_names(
+    names: Vec<String>,
+    params: &HashMap<String, String>,
+    page_size: Option<usize>,
+) -> (Vec<String>, Option<String>) {
+    let Some(page_size) = page_size else {
+        return (names, None);
+    };
+    let offset = params
+        .get("pageToken")
+        .and_then(|token| token.parse::<usize>().ok())
+        .unwrap_or(0)
+        .min(names.len());
+    let end = (offset + page_size).min(names.len());
+    let next_page_token = (end < names.len()).then(|| end.to_string());
+    (names[offset..end].to_vec(), next_page_token)
+}
+
 #[derive(Clone)]
 pub struct RESTServer {
     warehouse: String,
@@ -315,6 +339,126 @@ impl RESTServer {
         (StatusCode::OK, Json(response)).into_response()
     }
 
+    /// Handle GET /databases/:db/views/:view - get a persistent view.
+    pub async fn get_view(
+        Path((db, view)): Path<(String, String)>,
+        Extension(state): Extension<Arc<RESTServer>>,
+    ) -> impl IntoResponse {
+        let s = state.inner.lock().unwrap();
+        if s.view_function_endpoints_unsupported {
+            let err = ErrorResponse::new(
+                Some("view".to_string()),
+                Some(view),
+                Some("Not Implemented".to_string()),
+                Some(501),
+            );
+            return (StatusCode::NOT_IMPLEMENTED, Json(err)).into_response();
+        }
+        let key = format!("{db}.{view}");
+        if let Some(response) = s.views.get(&key) {
+            (StatusCode::OK, Json(response.clone())).into_response()
+        } else {
+            let err = ErrorResponse::new(
+                Some("view".to_string()),
+                Some(view),
+                Some("Not Found".to_string()),
+                Some(404),
+            );
+            (StatusCode::NOT_FOUND, Json(err)).into_response()
+        }
+    }
+
+    /// Handle GET /databases/:db/views - list persistent views.
+    pub async fn list_views(
+        Path(db): Path<String>,
+        Query(params): Query<HashMap<String, String>>,
+        Extension(state): Extension<Arc<RESTServer>>,
+    ) -> impl IntoResponse {
+        let s = state.inner.lock().unwrap();
+        if s.view_function_endpoints_unsupported {
+            let err = ErrorResponse::new(
+                Some("view".to_string()),
+                None,
+                Some("Not Implemented".to_string()),
+                Some(501),
+            );
+            return (StatusCode::NOT_IMPLEMENTED, Json(err)).into_response();
+        }
+        let prefix = format!("{db}.");
+        let mut views: Vec<String> = s
+            .views
+            .keys()
+            .filter_map(|key| 
key.strip_prefix(&prefix).map(ToString::to_string))
+            .collect();
+        views.sort();
+        let (views, next_page_token) = paginate_names(views, &params, 
s.list_page_size);
+        (
+            StatusCode::OK,
+            Json(ListViewsResponse::new(views, next_page_token)),
+        )
+            .into_response()
+    }
+
+    /// Handle GET /databases/:db/functions/:function - get a persistent 
function.
+    pub async fn get_function(
+        Path((db, function)): Path<(String, String)>,
+        Extension(state): Extension<Arc<RESTServer>>,
+    ) -> impl IntoResponse {
+        let s = state.inner.lock().unwrap();
+        if s.view_function_endpoints_unsupported {
+            let err = ErrorResponse::new(
+                Some("function".to_string()),
+                Some(function),
+                Some("Not Implemented".to_string()),
+                Some(501),
+            );
+            return (StatusCode::NOT_IMPLEMENTED, Json(err)).into_response();
+        }
+        let key = format!("{db}.{function}");
+        if let Some(response) = s.functions.get(&key) {
+            (StatusCode::OK, Json(response.clone())).into_response()
+        } else {
+            let err = ErrorResponse::new(
+                Some("function".to_string()),
+                Some(function),
+                Some("Not Found".to_string()),
+                Some(404),
+            );
+            (StatusCode::NOT_FOUND, Json(err)).into_response()
+        }
+    }
+
+    /// Handle GET /databases/:db/functions - list persistent functions.
+    pub async fn list_functions(
+        Path(db): Path<String>,
+        Query(params): Query<HashMap<String, String>>,
+        Extension(state): Extension<Arc<RESTServer>>,
+    ) -> impl IntoResponse {
+        let s = state.inner.lock().unwrap();
+        if s.view_function_endpoints_unsupported {
+            let err = ErrorResponse::new(
+                Some("function".to_string()),
+                None,
+                Some("Not Implemented".to_string()),
+                Some(501),
+            );
+            return (StatusCode::NOT_IMPLEMENTED, Json(err)).into_response();
+        }
+        let prefix = format!("{db}.");
+        let mut functions: Vec<String> = s
+            .functions
+            .keys()
+            .filter_map(|key| 
key.strip_prefix(&prefix).map(ToString::to_string))
+            .collect();
+        functions.sort();
+        let (functions, next_page_token) = paginate_names(functions, &params, 
s.list_page_size);
+        (
+            StatusCode::OK,
+            Json(ListFunctionsResponse::new(functions, next_page_token)),
+        )
+            .into_response()
+    }
+
     /// Handle POST /databases/:db/tables - create a new table.
     pub async fn create_table(
         Path(db): Path<String>,
@@ -602,6 +746,44 @@ impl RESTServer {
         });
     }
 
+    /// Add a persistent view to the server state.
+    pub fn add_view(&self, database: &str, view: &str, schema: 
paimon::catalog::ViewSchema) {
+        let mut s = self.inner.lock().unwrap();
+        let key = format!("{database}.{view}");
+        s.views.insert(
+            key,
+            GetViewResponse::new(
+                Some(view.to_string()),
+                Some(view.to_string()),
+                schema,
+                AuditRESTResponse::new(None, None, None, None, None),
+            ),
+        );
+    }
+
+    /// Add a persistent function to the server state.
+    pub fn add_function(&self, function: Function) {
+        let key = function.full_name();
+        let response = GetFunctionResponse::from_function(
+            &function,
+            AuditRESTResponse::new(None, None, None, None, None),
+        );
+        self.inner.lock().unwrap().functions.insert(key, response);
+    }
+
+    /// Force list-view and list-function handlers to paginate at this size.
+    pub fn set_list_page_size(&self, page_size: usize) {
+        self.inner.lock().unwrap().list_page_size = Some(page_size.max(1));
+    }
+
+    /// Make persistent view and function endpoints return HTTP 501.
+    pub fn set_view_function_endpoints_unsupported(&self) {
+        self.inner
+            .lock()
+            .unwrap()
+            .view_function_endpoints_unsupported = true;
+    }
+
     /// Add a table with schema and path to the server state.
     ///
     /// This is needed for `RESTCatalog::get_table` which requires
@@ -761,6 +943,22 @@ pub async fn start_mock_server(
                 .post(RESTServer::alter_table)
                 .delete(RESTServer::drop_table),
         )
+        .route(
+            &format!("{prefix}/databases/:db/views"),
+            get(RESTServer::list_views),
+        )
+        .route(
+            &format!("{prefix}/databases/:db/views/:view"),
+            get(RESTServer::get_view),
+        )
+        .route(
+            &format!("{prefix}/databases/:db/functions"),
+            get(RESTServer::list_functions),
+        )
+        .route(
+            &format!("{prefix}/databases/:db/functions/:function"),
+            get(RESTServer::get_function),
+        )
         .route(
             &format!("{prefix}/tables/rename"),
             post(RESTServer::rename_table),
diff --git a/crates/paimon/tests/rest_api_test.rs 
b/crates/paimon/tests/rest_api_test.rs
index 74784b3..07593cd 100644
--- a/crates/paimon/tests/rest_api_test.rs
+++ b/crates/paimon/tests/rest_api_test.rs
@@ -25,8 +25,9 @@ use std::collections::HashMap;
 use paimon::api::auth::{DLFECSTokenLoader, DLFToken, DLFTokenLoader};
 use paimon::api::rest_api::RESTApi;
 use paimon::api::ConfigResponse;
-use paimon::catalog::Identifier;
+use paimon::catalog::{Function, FunctionDefinition, Identifier, ViewSchema};
 use paimon::common::Options;
+use paimon::spec::DataField;
 use serde_json::json;
 
 mod mock_server;
@@ -82,6 +83,129 @@ async fn test_list_databases() {
     assert!(dbs.contains(&"prod_db".to_string()));
 }
 
+#[tokio::test]
+async fn test_get_view() {
+    let ctx = setup_test_server(vec!["default"]).await;
+    let schema: ViewSchema = serde_json::from_value(json!({
+        "fields": [{"id": 0, "name": "id", "type": "INT"}],
+        "query": "SELECT id FROM source",
+        "dialects": {"datafusion": "SELECT id FROM source WHERE id > 0"},
+        "comment": null,
+        "options": {}
+    }))
+    .unwrap();
+    ctx.server.add_view("default", "active_ids", schema);
+
+    let response = ctx
+        .api
+        .get_view(&Identifier::new("default", "active_ids"))
+        .await
+        .unwrap();
+
+    assert_eq!(response.name.as_deref(), Some("active_ids"));
+    assert_eq!(response.id.as_deref(), Some("active_ids"));
+    assert_eq!(
+        response.schema.query_for("datafusion"),
+        "SELECT id FROM source WHERE id > 0"
+    );
+}
+
+#[tokio::test]
+async fn test_list_views() {
+    let ctx = setup_test_server(vec!["default"]).await;
+    ctx.server.set_list_page_size(1);
+    let schema: ViewSchema = serde_json::from_value(json!({
+        "fields": [{"id": 0, "name": "id", "type": "INT"}],
+        "query": "SELECT 1 AS id",
+        "dialects": {},
+        "comment": null,
+        "options": {}
+    }))
+    .unwrap();
+    ctx.server.add_view("default", "v2", schema.clone());
+    ctx.server.add_view("default", "v1", schema);
+
+    assert_eq!(
+        ctx.api.list_views("default").await.unwrap(),
+        vec!["v1", "v2"]
+    );
+}
+
+#[tokio::test]
+async fn test_get_function() {
+    let ctx = setup_test_server(vec!["default"]).await;
+    let input_params: Vec<DataField> = serde_json::from_value(json!([
+        {"id": 0, "name": "length", "type": "DOUBLE"},
+        {"id": 1, "name": "width", "type": "DOUBLE"}
+    ]))
+    .unwrap();
+    let return_params: Vec<DataField> = serde_json::from_value(json!([
+        {"id": 0, "name": "area", "type": "DOUBLE"}
+    ]))
+    .unwrap();
+    ctx.server.add_function(Function::new(
+        Identifier::new("default", "area"),
+        Some(input_params),
+        Some(return_params),
+        true,
+        HashMap::from([(
+            "datafusion".to_string(),
+            FunctionDefinition::Sql {
+                definition: "length * width".to_string(),
+            },
+        )]),
+        None,
+        HashMap::new(),
+    ));
+
+    let response = ctx
+        .api
+        .get_function(&Identifier::new("default", "area"))
+        .await
+        .unwrap();
+
+    assert_eq!(response.name.as_deref(), Some("area"));
+    assert_eq!(
+        response
+            .definitions
+            .get("datafusion")
+            .and_then(FunctionDefinition::sql),
+        Some("length * width")
+    );
+}
+
+#[tokio::test]
+async fn test_list_functions() {
+    let ctx = setup_test_server(vec!["default"]).await;
+    ctx.server.set_list_page_size(1);
+    for name in ["zeta", "alpha"] {
+        ctx.server.add_function(Function::new(
+            Identifier::new("default", name),
+            Some(Vec::new()),
+            Some(
+                serde_json::from_value(json!([
+                    {"id": 0, "name": "result", "type": "INT"}
+                ]))
+                .unwrap(),
+            ),
+            true,
+            HashMap::from([(
+                "datafusion".to_string(),
+                FunctionDefinition::Sql {
+                    definition: "1".to_string(),
+                },
+            )]),
+            None,
+            HashMap::new(),
+        ));
+    }
+
+    assert_eq!(
+        ctx.api.list_functions("default").await.unwrap(),
+        vec!["alpha", "zeta"]
+    );
+}
+
 #[tokio::test]
 async fn test_create_database() {
     let ctx = setup_test_server(vec!["default"]).await;
diff --git a/crates/paimon/tests/rest_catalog_test.rs 
b/crates/paimon/tests/rest_catalog_test.rs
index b8801b8..778337c 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -27,11 +27,11 @@ use arrow_array::{Array, BinaryArray, Int32Array, 
Int64Array, RecordBatch, Strin
 use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema as 
ArrowSchema};
 use futures::TryStreamExt;
 use paimon::api::ConfigResponse;
-use paimon::catalog::{Catalog, Identifier, RESTCatalog};
+use paimon::catalog::{Catalog, Function, FunctionDefinition, Identifier, 
RESTCatalog, ViewSchema};
 use paimon::common::Options;
 use paimon::spec::{
-    BigIntType, BlobType, BlobViewStruct, DataType, Datum, IntType, 
PredicateBuilder, Schema,
-    SchemaChange, VarCharType,
+    BigIntType, BlobType, BlobViewStruct, DataField, DataType, Datum, IntType, 
PredicateBuilder,
+    Schema, SchemaChange, VarCharType,
 };
 use paimon::{CatalogOptions, FileSystemCatalog, Table};
 
@@ -998,3 +998,181 @@ async fn test_catalog_multiple_databases_with_tables() {
     assert_eq!(tables_db2.len(), 1);
     assert!(tables_db2.contains(&"table1_db2".to_string()));
 }
+
+#[tokio::test]
+async fn test_catalog_get_view() {
+    let ctx = setup_catalog(vec!["default"]).await;
+    let schema: ViewSchema = serde_json::from_value(serde_json::json!({
+        "fields": [{"id": 0, "name": "id", "type": "INT"}],
+        "query": "SELECT id FROM source",
+        "dialects": {"datafusion": "SELECT id FROM source WHERE id > 0"},
+        "comment": null,
+        "options": {}
+    }))
+    .unwrap();
+    ctx.server.add_view("default", "active_ids", schema);
+
+    let view = ctx
+        .catalog
+        .get_view(&Identifier::new("default", "active_ids"))
+        .await
+        .unwrap();
+
+    assert_eq!(view.full_name(), "default.active_ids");
+    assert_eq!(
+        view.query_for("datafusion"),
+        "SELECT id FROM source WHERE id > 0"
+    );
+}
+
+#[tokio::test]
+async fn test_catalog_list_views() {
+    let ctx = setup_catalog(vec!["default"]).await;
+    let schema: ViewSchema = serde_json::from_value(serde_json::json!({
+        "fields": [{"id": 0, "name": "id", "type": "INT"}],
+        "query": "SELECT 1 AS id",
+        "dialects": {},
+        "comment": null,
+        "options": {}
+    }))
+    .unwrap();
+    ctx.server.add_view("default", "v2", schema.clone());
+    ctx.server.add_view("default", "v1", schema);
+
+    assert_eq!(
+        ctx.catalog.list_views("default").await.unwrap(),
+        vec!["v1", "v2"]
+    );
+}
+
+#[tokio::test]
+async fn test_catalog_get_function() {
+    let ctx = setup_catalog(vec!["default"]).await;
+    let input_params: Vec<DataField> = 
serde_json::from_value(serde_json::json!([
+        {"id": 0, "name": "x", "type": "INT"}
+    ]))
+    .unwrap();
+    let return_params: Vec<DataField> = 
serde_json::from_value(serde_json::json!([
+        {"id": 0, "name": "result", "type": "INT"}
+    ]))
+    .unwrap();
+    ctx.server.add_function(Function::new(
+        Identifier::new("default", "plus_one"),
+        Some(input_params),
+        Some(return_params),
+        true,
+        HashMap::from([(
+            "datafusion".to_string(),
+            FunctionDefinition::Sql {
+                definition: "x + 1".to_string(),
+            },
+        )]),
+        None,
+        HashMap::new(),
+    ));
+
+    let function = ctx
+        .catalog
+        .get_function(&Identifier::new("default", "plus_one"))
+        .await
+        .unwrap();
+
+    assert_eq!(function.full_name(), "default.plus_one");
+    assert_eq!(
+        function
+            .definition("datafusion")
+            .and_then(FunctionDefinition::sql),
+        Some("x + 1")
+    );
+}
+
+#[tokio::test]
+async fn test_catalog_list_functions() {
+    let ctx = setup_catalog(vec!["default"]).await;
+    let return_params: Vec<DataField> = 
serde_json::from_value(serde_json::json!([
+        {"id": 0, "name": "result", "type": "INT"}
+    ]))
+    .unwrap();
+    for name in ["zeta", "alpha"] {
+        ctx.server.add_function(Function::new(
+            Identifier::new("default", name),
+            Some(Vec::new()),
+            Some(return_params.clone()),
+            true,
+            HashMap::from([(
+                "datafusion".to_string(),
+                FunctionDefinition::Sql {
+                    definition: "1".to_string(),
+                },
+            )]),
+            None,
+            HashMap::new(),
+        ));
+    }
+
+    assert_eq!(
+        ctx.catalog.list_functions("default").await.unwrap(),
+        vec!["alpha", "zeta"]
+    );
+}
+
+#[tokio::test]
+async fn test_catalog_get_missing_view_maps_error() {
+    let ctx = setup_catalog(vec!["default"]).await;
+
+    let error = ctx
+        .catalog
+        .get_view(&Identifier::new("default", "missing"))
+        .await
+        .unwrap_err();
+
+    assert!(matches!(
+        error,
+        paimon::Error::ViewNotExist { full_name } if full_name == 
"default.missing"
+    ));
+}
+
+#[tokio::test]
+async fn test_catalog_get_missing_function_maps_error() {
+    let ctx = setup_catalog(vec!["default"]).await;
+
+    let error = ctx
+        .catalog
+        .get_function(&Identifier::new("default", "missing"))
+        .await
+        .unwrap_err();
+
+    assert!(matches!(
+        error,
+        paimon::Error::FunctionNotExist { full_name } if full_name == 
"default.missing"
+    ));
+}
+
+#[tokio::test]
+async fn test_catalog_maps_unsupported_view_and_function_endpoints() {
+    let ctx = setup_catalog(vec!["default"]).await;
+    ctx.server.set_view_function_endpoints_unsupported();
+
+    assert!(matches!(
+        ctx.catalog.list_views("default").await.unwrap_err(),
+        paimon::Error::Unsupported { .. }
+    ));
+    assert!(matches!(
+        ctx.catalog
+            .get_view(&Identifier::new("default", "view"))
+            .await
+            .unwrap_err(),
+        paimon::Error::Unsupported { .. }
+    ));
+    assert!(matches!(
+        ctx.catalog.list_functions("default").await.unwrap_err(),
+        paimon::Error::Unsupported { .. }
+    ));
+    assert!(matches!(
+        ctx.catalog
+            .get_function(&Identifier::new("default", "function"))
+            .await
+            .unwrap_err(),
+        paimon::Error::Unsupported { .. }
+    ));
+}
diff --git a/crates/paimon/tests/rest_object_models_test.rs 
b/crates/paimon/tests/rest_object_models_test.rs
new file mode 100644
index 0000000..3c16bbd
--- /dev/null
+++ b/crates/paimon/tests/rest_object_models_test.rs
@@ -0,0 +1,154 @@
+// 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.
+
+use std::collections::HashMap;
+
+use paimon::catalog::{Function, FunctionDefinition, Identifier, View, 
ViewSchema};
+use paimon::spec::DataField;
+use serde_json::json;
+
+#[test]
+fn deserialize_view_schema_contract() {
+    let schema: ViewSchema = serde_json::from_value(json!({
+        "fields": [{"id": 0, "name": "id", "type": "INT"}],
+        "query": "SELECT id FROM source",
+        "dialects": {"datafusion": "SELECT id FROM source"},
+        "comment": "active ids",
+        "options": {"owner": "analytics"}
+    }))
+    .unwrap();
+
+    assert_eq!(schema.fields()[0].name(), "id");
+    assert_eq!(schema.query_for("datafusion"), "SELECT id FROM source");
+    assert_eq!(schema.comment(), Some("active ids"));
+    assert_eq!(
+        schema.options().get("owner").map(String::as_str),
+        Some("analytics")
+    );
+}
+
+#[test]
+fn view_binds_identifier_to_schema() {
+    let schema: ViewSchema = serde_json::from_value(json!({
+        "fields": [{"id": 0, "name": "id", "type": "INT"}],
+        "query": "SELECT id FROM source",
+        "dialects": {"datafusion": "SELECT id FROM source WHERE id > 0"},
+        "comment": null,
+        "options": {}
+    }))
+    .unwrap();
+    let view = View::new(Identifier::new("analytics", "active_ids"), schema);
+
+    assert_eq!(view.name(), "active_ids");
+    assert_eq!(view.full_name(), "analytics.active_ids");
+    assert_eq!(
+        view.query_for("datafusion"),
+        "SELECT id FROM source WHERE id > 0"
+    );
+}
+
+#[test]
+fn deserialize_sql_function_definition_contract() {
+    let definition: FunctionDefinition = serde_json::from_value(json!({
+        "type": "sql",
+        "definition": "length * width"
+    }))
+    .unwrap();
+
+    assert_eq!(definition.sql(), Some("length * width"));
+}
+
+#[test]
+fn deserialize_lambda_function_definition_contract() {
+    let definition: FunctionDefinition = serde_json::from_value(json!({
+        "type": "lambda",
+        "definition": "(x) -> x + 1",
+        "language": "java"
+    }))
+    .unwrap();
+
+    assert!(matches!(
+        definition,
+        FunctionDefinition::Lambda { definition, language }
+            if definition == "(x) -> x + 1" && language == "java"
+    ));
+}
+
+#[test]
+fn deserialize_file_function_definition_contract() {
+    let definition: FunctionDefinition = serde_json::from_value(json!({
+        "type": "file",
+        "fileResources": [{"resourceType": "jar", "uri": 
"file:///functions.jar"}],
+        "language": "java",
+        "className": "com.example.Area",
+        "functionName": "eval"
+    }))
+    .unwrap();
+
+    assert!(matches!(
+        definition,
+        FunctionDefinition::File {
+            file_resources,
+            language,
+            class_name,
+            function_name,
+        } if file_resources.len() == 1
+            && file_resources[0].resource_type() == "jar"
+            && file_resources[0].uri() == "file:///functions.jar"
+            && language == "java"
+            && class_name == "com.example.Area"
+            && function_name == "eval"
+    ));
+}
+
+#[test]
+fn function_binds_identifier_signature_and_definition() {
+    let input_params: Vec<DataField> = serde_json::from_value(json!([
+        {"id": 0, "name": "length", "type": "DOUBLE"},
+        {"id": 1, "name": "width", "type": "DOUBLE"}
+    ]))
+    .unwrap();
+    let return_params: Vec<DataField> = serde_json::from_value(json!([
+        {"id": 0, "name": "area", "type": "DOUBLE"}
+    ]))
+    .unwrap();
+    let definitions = HashMap::from([(
+        "datafusion".to_string(),
+        FunctionDefinition::Sql {
+            definition: "length * width".to_string(),
+        },
+    )]);
+    let function = Function::new(
+        Identifier::new("analytics", "area"),
+        Some(input_params),
+        Some(return_params),
+        true,
+        definitions,
+        Some("rectangle area".to_string()),
+        HashMap::new(),
+    );
+
+    assert_eq!(function.full_name(), "analytics.area");
+    assert_eq!(function.input_params().unwrap()[0].name(), "length");
+    assert_eq!(function.return_params().unwrap()[0].name(), "area");
+    assert_eq!(
+        function
+            .definition("datafusion")
+            .and_then(FunctionDefinition::sql),
+        Some("length * width")
+    );
+}
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 6651344..2d58ba6 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -76,6 +76,60 @@ catalogs; registering a catalog also registers the built-in 
scalar function
 feature is enabled) against it. It also manages session-scoped dynamic options
 internally for `SET`/`RESET` support.
 
+### Existing REST Catalog Views and SQL Functions
+
+When the registered catalog is a Paimon REST Catalog, `SQLContext` can read and
+execute persistent views and SQL scalar functions that already exist in the
+catalog. This integration is read-only: it does not add `CREATE`, `ALTER`, or
+`DROP` support for persistent views or functions.
+
+Persistent views resolve through the normal DataFusion catalog path, so they
+can be queried wherever a table can be used:
+
+```sql
+SELECT * FROM analytics_view;
+SELECT * FROM paimon.reporting.daily_orders;
+```
+
+For a view, the `datafusion` entry in `schema.dialects` is preferred. If that
+entry is absent, DataFusion uses the view's default `schema.query`. Unqualified
+relations inside the stored query resolve against the view's owning catalog and
+database, not the caller's current database. The REST-declared output fields
+are authoritative: query results are matched by position, renamed to the
+declared field names, and cast to the declared types. Recursive view
+dependencies are rejected during planning.
+
+REST SQL scalar functions support bare names in the current catalog/database
+and fully qualified three-part names:
+
+```sql
+SELECT normalize_score(score) FROM scores;
+SELECT paimon.reporting.normalize_score(score) FROM scores;
+```
+
+A function is executable only when all of the following are true:
+
+- `definitions.datafusion` exists and has `type: "sql"`;
+- input parameters are declared and the call supplies the exact number of
+  positional expression arguments;
+- exactly one return parameter is declared;
+- the function is deterministic;
+- the SQL definition is a scalar expression that references inputs by their
+  declared parameter names.
+
+Nested REST SQL functions are supported. Bare function names inside a stored
+definition resolve in that function's owning catalog/database. Recursive
+function dependencies, missing DataFusion SQL definitions, undeclared
+identifiers, named arguments, and incompatible return types fail during
+planning. If no REST function exists for a bare name, normal DataFusion
+built-in or registered-function resolution continues.
+
+Function expansion is implemented by `SQLContext::sql`. Queries executed
+directly through a raw DataFusion `SessionContext` do not expand REST SQL
+functions. Two-part function names such as `database.function(...)`, lambda or
+file definitions, aggregate/table/multi-return functions, and non-deterministic
+functions are not supported by this read-only integration.
+
 ## Data Types
 
 The following SQL data types are supported in CREATE TABLE and mapped to their 
corresponding Paimon types:

Reply via email to