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

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


The following commit(s) were added to refs/heads/main by this push:
     new 936f534c [elixir] feat: Add get_table_info admin call (#640)
936f534c is described below

commit 936f534c3e65a0f9434c801fbf9485f97543d78a
Author: Nicoleta Lazar <[email protected]>
AuthorDate: Fri Jun 26 13:00:23 2026 +0300

    [elixir] feat: Add get_table_info admin call (#640)
    
    * [elixir] feat: Add get_table_info admin call
    
    * Include schema in table_info and add get_table_schema admin call
---
 bindings/elixir/lib/fluss/admin.ex              |  32 +++++
 bindings/elixir/lib/fluss/native.ex             |   6 +
 bindings/elixir/lib/fluss/schema_info.ex        |  33 +++++
 bindings/elixir/lib/fluss/table_info.ex         |  96 ++++++++++++++
 bindings/elixir/native/fluss_nif/src/admin.rs   | 104 ++++++++++++++-
 bindings/elixir/native/fluss_nif/src/schema.rs  |  48 +++++++
 bindings/elixir/test/integration/admin_test.exs | 165 ++++++++++++++++++++++++
 7 files changed, 482 insertions(+), 2 deletions(-)

diff --git a/bindings/elixir/lib/fluss/admin.ex 
b/bindings/elixir/lib/fluss/admin.ex
index b9d26f70..11bc2ce8 100644
--- a/bindings/elixir/lib/fluss/admin.ex
+++ b/bindings/elixir/lib/fluss/admin.ex
@@ -176,4 +176,36 @@ defmodule Fluss.Admin do
       {:error, %Fluss.Error{} = err} -> raise err
     end
   end
+
+  @spec get_table_info(t(), String.t(), String.t()) ::
+          {:ok, Fluss.TableInfo.t()} | {:error, Fluss.Error.t()}
+  def get_table_info(admin, database_name, table_name) do
+    admin
+    |> Native.admin_get_table_info(database_name, table_name)
+    |> Native.await_nif()
+  end
+
+  @spec get_table_info!(t(), String.t(), String.t()) :: Fluss.TableInfo.t()
+  def get_table_info!(admin, database_name, table_name) do
+    case get_table_info(admin, database_name, table_name) do
+      {:ok, table_info} -> table_info
+      {:error, %Fluss.Error{} = err} -> raise err
+    end
+  end
+
+  @spec get_table_schema(t(), String.t(), String.t(), integer() | nil) ::
+          {:ok, Fluss.SchemaInfo.t()} | {:error, Fluss.Error.t()}
+  def get_table_schema(admin, database_name, table_name, schema_id \\ nil) do
+    admin
+    |> Native.admin_get_table_schema(database_name, table_name, schema_id)
+    |> Native.await_nif()
+  end
+
+  @spec get_table_schema!(t(), String.t(), String.t(), integer() | nil) :: 
Fluss.SchemaInfo.t()
+  def get_table_schema!(admin, database_name, table_name, schema_id \\ nil) do
+    case get_table_schema(admin, database_name, table_name, schema_id) do
+      {:ok, schema_info} -> schema_info
+      {:error, %Fluss.Error{} = err} -> raise err
+    end
+  end
 end
diff --git a/bindings/elixir/lib/fluss/native.ex 
b/bindings/elixir/lib/fluss/native.ex
index 91b4fbf1..6de93683 100644
--- a/bindings/elixir/lib/fluss/native.ex
+++ b/bindings/elixir/lib/fluss/native.ex
@@ -54,6 +54,12 @@ defmodule Fluss.Native do
   def admin_table_exists(_admin, _database_name, _table_name),
     do: :erlang.nif_error(:nif_not_loaded)
 
+  def admin_get_table_info(_admin, _database_name, _table_name),
+    do: :erlang.nif_error(:nif_not_loaded)
+
+  def admin_get_table_schema(_admin, _database_name, _table_name, _schema_id),
+    do: :erlang.nif_error(:nif_not_loaded)
+
   # Schema / TableDescriptor
   def table_descriptor_new(_schema, _bucket_count, _properties),
     do: :erlang.nif_error(:nif_not_loaded)
diff --git a/bindings/elixir/lib/fluss/schema_info.ex 
b/bindings/elixir/lib/fluss/schema_info.ex
new file mode 100644
index 00000000..2687ad9f
--- /dev/null
+++ b/bindings/elixir/lib/fluss/schema_info.ex
@@ -0,0 +1,33 @@
+# 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.
+
+defmodule Fluss.SchemaInfo do
+  @moduledoc """
+  A versioned schema: a table's `Fluss.Schema` together with its schema id.
+
+  Returned by `Fluss.Admin.get_table_schema/4`. Fluss versions schemas, so the
+  `:schema_id` identifies which version this is.
+  """
+
+  @enforce_keys [:schema, :schema_id]
+  defstruct [:schema, :schema_id]
+
+  @type t :: %__MODULE__{
+          schema: Fluss.Schema.t(),
+          schema_id: integer()
+        }
+end
diff --git a/bindings/elixir/lib/fluss/table_info.ex 
b/bindings/elixir/lib/fluss/table_info.ex
new file mode 100644
index 00000000..31455c1b
--- /dev/null
+++ b/bindings/elixir/lib/fluss/table_info.ex
@@ -0,0 +1,96 @@
+# 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.
+
+defmodule Fluss.TableInfo do
+  @moduledoc """
+  Metadata about a Fluss table: identity, schema id, key/partition layout,
+  bucketing, properties, and creation/modification timestamps as tracked by the
+  cluster.
+
+  Returned by `Fluss.Admin.get_table_info/3`. This carries metadata only — for 
a
+  handle to read from or write to the table, use `Fluss.Table.get/3`.
+  """
+
+  @enforce_keys [
+    :database_name,
+    :table_name,
+    :table_id,
+    :schema_id,
+    :schema,
+    :num_buckets,
+    :has_primary_key,
+    :primary_keys,
+    :physical_primary_keys,
+    :bucket_keys,
+    :has_bucket_key,
+    :is_default_bucket_key,
+    :is_partitioned,
+    :is_auto_partitioned,
+    :partition_keys,
+    :comment,
+    :properties,
+    :custom_properties,
+    :created_time,
+    :modified_time
+  ]
+
+  defstruct [
+    :database_name,
+    :table_name,
+    :table_id,
+    :schema_id,
+    :schema,
+    :num_buckets,
+    :has_primary_key,
+    :primary_keys,
+    :physical_primary_keys,
+    :bucket_keys,
+    :has_bucket_key,
+    :is_default_bucket_key,
+    :is_partitioned,
+    :is_auto_partitioned,
+    :partition_keys,
+    :comment,
+    :properties,
+    :custom_properties,
+    :created_time,
+    :modified_time
+  ]
+
+  @type t :: %__MODULE__{
+          database_name: String.t(),
+          table_name: String.t(),
+          table_id: integer(),
+          schema_id: integer(),
+          schema: Fluss.Schema.t(),
+          num_buckets: integer(),
+          has_primary_key: boolean(),
+          primary_keys: [String.t()],
+          physical_primary_keys: [String.t()],
+          bucket_keys: [String.t()],
+          has_bucket_key: boolean(),
+          is_default_bucket_key: boolean(),
+          is_partitioned: boolean(),
+          is_auto_partitioned: boolean(),
+          partition_keys: [String.t()],
+          comment: String.t() | nil,
+          properties: %{String.t() => String.t()},
+          custom_properties: %{String.t() => String.t()},
+          created_time: integer(),
+          modified_time: integer()
+        }
+end
diff --git a/bindings/elixir/native/fluss_nif/src/admin.rs 
b/bindings/elixir/native/fluss_nif/src/admin.rs
index 6f79a97f..4d348fc9 100644
--- a/bindings/elixir/native/fluss_nif/src/admin.rs
+++ b/bindings/elixir/native/fluss_nif/src/admin.rs
@@ -18,9 +18,10 @@
 use crate::async_nif;
 use crate::atoms::to_nif_err;
 use crate::connection::ConnectionResource;
-use crate::schema::TableDescriptorResource;
+use crate::schema::{NifSchema, TableDescriptorResource};
 use fluss::client::FlussAdmin;
-use fluss::metadata::{DatabaseDescriptor, DatabaseInfo, TablePath};
+use fluss::error::Error;
+use fluss::metadata::{DatabaseDescriptor, DatabaseInfo, SchemaInfo, TableInfo, 
TablePath};
 use fluss::{ServerNode, ServerType};
 use rustler::{Env, NifStruct, NifUnitEnum, ResourceArc, Term};
 use std::collections::HashMap;
@@ -105,6 +106,60 @@ impl NifDatabaseInfo {
     }
 }
 
+#[derive(NifStruct)]
+#[module = "Fluss.TableInfo"]
+pub struct NifTableInfo {
+    pub database_name: String,
+    pub table_name: String,
+    pub table_id: i64,
+    pub schema_id: i32,
+    pub schema: NifSchema,
+    pub num_buckets: i32,
+    pub has_primary_key: bool,
+    pub primary_keys: Vec<String>,
+    pub physical_primary_keys: Vec<String>,
+    pub bucket_keys: Vec<String>,
+    pub has_bucket_key: bool,
+    pub is_default_bucket_key: bool,
+    pub is_partitioned: bool,
+    pub is_auto_partitioned: bool,
+    pub partition_keys: Vec<String>,
+    pub comment: Option<String>,
+    pub properties: HashMap<String, String>,
+    pub custom_properties: HashMap<String, String>,
+    pub created_time: i64,
+    pub modified_time: i64,
+}
+
+impl NifTableInfo {
+    pub fn from_core(info: &TableInfo) -> Result<Self, Error> {
+        let table_path: &TablePath = info.get_table_path();
+
+        Ok(Self {
+            database_name: table_path.database().to_string(),
+            table_name: table_path.table().to_string(),
+            table_id: info.get_table_id(),
+            schema_id: info.get_schema_id(),
+            schema: NifSchema::from_core(info.get_schema())?,
+            num_buckets: info.get_num_buckets(),
+            has_primary_key: info.has_primary_key(),
+            primary_keys: info.get_primary_keys().to_vec(),
+            physical_primary_keys: info.get_physical_primary_keys().to_vec(),
+            bucket_keys: info.get_bucket_keys().to_vec(),
+            has_bucket_key: info.has_bucket_key(),
+            is_default_bucket_key: info.is_default_bucket_key(),
+            is_partitioned: info.is_partitioned(),
+            is_auto_partitioned: info.is_auto_partitioned(),
+            partition_keys: info.get_partition_keys().to_vec(),
+            comment: info.get_comment().map(String::from),
+            properties: info.get_properties().clone(),
+            custom_properties: info.get_custom_properties().clone(),
+            created_time: info.get_created_time(),
+            modified_time: info.get_modified_time(),
+        })
+    }
+}
+
 pub struct AdminResource {
     pub inner: Arc<FlussAdmin>,
 }
@@ -247,3 +302,48 @@ fn admin_table_exists<'a>(
         admin.inner.table_exists(&table_path).await
     })
 }
+
+#[rustler::nif]
+fn admin_get_table_info<'a>(
+    env: Env<'a>,
+    admin: ResourceArc<AdminResource>,
+    database_name: String,
+    table_name: String,
+) -> Term<'a> {
+    async_nif::spawn_task_with_result(env, async move {
+        let table_path = TablePath::new(database_name, table_name);
+        let table_info = admin.inner.get_table_info(&table_path).await?;
+        NifTableInfo::from_core(&table_info)
+    })
+}
+
+#[derive(NifStruct)]
+#[module = "Fluss.SchemaInfo"]
+pub struct NifSchemaInfo {
+    pub schema: NifSchema,
+    pub schema_id: i32,
+}
+
+impl NifSchemaInfo {
+    pub fn from_core(info: &SchemaInfo) -> Result<Self, Error> {
+        Ok(Self {
+            schema: NifSchema::from_core(info.schema())?,
+            schema_id: info.schema_id(),
+        })
+    }
+}
+
+#[rustler::nif]
+fn admin_get_table_schema<'a>(
+    env: Env<'a>,
+    admin: ResourceArc<AdminResource>,
+    database_name: String,
+    table_name: String,
+    schema_id: Option<i32>,
+) -> Term<'a> {
+    async_nif::spawn_task_with_result(env, async move {
+        let table_path = TablePath::new(database_name, table_name);
+        let schema_info = admin.inner.get_table_schema(&table_path, 
schema_id).await?;
+        NifSchemaInfo::from_core(&schema_info)
+    })
+}
diff --git a/bindings/elixir/native/fluss_nif/src/schema.rs 
b/bindings/elixir/native/fluss_nif/src/schema.rs
index 5d61d29d..410334fc 100644
--- a/bindings/elixir/native/fluss_nif/src/schema.rs
+++ b/bindings/elixir/native/fluss_nif/src/schema.rs
@@ -16,6 +16,7 @@
 // under the License.
 
 use crate::atoms::to_nif_err;
+use fluss::error::Error;
 use fluss::metadata::{self, DataTypes, Schema, TableDescriptor};
 use rustler::{NifStruct, NifTaggedEnum, ResourceArc};
 
@@ -73,6 +74,32 @@ fn to_fluss_type(dt: &DataType) -> metadata::DataType {
     }
 }
 
+fn from_fluss_type(dt: &metadata::DataType) -> Result<DataType, Error> {
+    match dt {
+        metadata::DataType::Boolean(_) => Ok(DataType::Boolean),
+        metadata::DataType::TinyInt(_) => Ok(DataType::Tinyint),
+        metadata::DataType::SmallInt(_) => Ok(DataType::Smallint),
+        metadata::DataType::Int(_) => Ok(DataType::Int),
+        metadata::DataType::BigInt(_) => Ok(DataType::Bigint),
+        metadata::DataType::Float(_) => Ok(DataType::Float),
+        metadata::DataType::Double(_) => Ok(DataType::Double),
+        metadata::DataType::String(_) => Ok(DataType::String),
+        metadata::DataType::Bytes(_) => Ok(DataType::Bytes),
+        metadata::DataType::Date(_) => Ok(DataType::Date),
+        metadata::DataType::Time(_) => Ok(DataType::Time),
+        metadata::DataType::Timestamp(_) => Ok(DataType::Timestamp),
+        metadata::DataType::TimestampLTz(_) => Ok(DataType::TimestampLtz),
+        metadata::DataType::Decimal(d) => Ok(DataType::Decimal(d.precision(), 
d.scale())),
+        metadata::DataType::Char(c) => Ok(DataType::Char(c.length())),
+        metadata::DataType::Binary(b) => Ok(DataType::Binary(b.length())),
+        metadata::DataType::Array(_) | metadata::DataType::Map(_) | 
metadata::DataType::Row(_) => {
+            Err(Error::UnsupportedOperation {
+                message: format!("data type {dt:?} is not supported by the 
Elixir bindings"),
+            })
+        }
+    }
+}
+
 /// Decoded from `%Fluss.Schema{}` Elixir struct.
 #[derive(NifStruct)]
 #[module = "Fluss.Schema"]
@@ -81,6 +108,27 @@ pub struct NifSchema {
     pub primary_key: Vec<String>,
 }
 
+impl NifSchema {
+    pub fn from_core(schema: &Schema) -> Result<NifSchema, Error> {
+        let mut columns: Vec<(String, DataType)> = Vec::new();
+
+        for col in schema.columns() {
+            columns.push((col.name().to_string(), 
from_fluss_type(col.data_type())?));
+        }
+
+        let primary_key: Vec<String> = schema
+            .primary_key_column_names()
+            .iter()
+            .map(|s| s.to_string())
+            .collect();
+
+        Ok(Self {
+            columns,
+            primary_key,
+        })
+    }
+}
+
 #[rustler::nif]
 fn table_descriptor_new(
     schema: NifSchema,
diff --git a/bindings/elixir/test/integration/admin_test.exs 
b/bindings/elixir/test/integration/admin_test.exs
index e6a3ff73..8c34187a 100644
--- a/bindings/elixir/test/integration/admin_test.exs
+++ b/bindings/elixir/test/integration/admin_test.exs
@@ -235,4 +235,169 @@ defmodule Fluss.Integration.AdminTest do
       refute Fluss.Admin.table_exists!(admin, @database, table)
     end
   end
+
+  describe "get_table_info/3" do
+    test "returns a %Fluss.TableInfo{} with the table's metadata", %{admin: 
admin} do
+      table = "fluss_table_#{:rand.uniform(100_000)}"
+
+      schema =
+        Fluss.Schema.new()
+        |> Fluss.Schema.column("id", :int)
+        |> Fluss.Schema.column("name", :string)
+        |> Fluss.Schema.primary_key(["id"])
+
+      descriptor = Fluss.TableDescriptor.new!(schema, bucket_count: 4)
+      :ok = Fluss.Admin.create_table(admin, @database, table, descriptor, true)
+      on_exit(fn -> Fluss.Admin.drop_table(admin, @database, table, true) end)
+
+      assert {:ok, info} = Fluss.Admin.get_table_info(admin, @database, table)
+      assert %Fluss.TableInfo{} = info
+
+      # Fields we set explicitly — exact assertions.
+      assert info.database_name == @database
+      assert info.table_name == table
+      assert info.num_buckets == 4
+      assert info.has_primary_key == true
+      assert info.primary_keys == ["id"]
+
+      # Schema round-trips: columns in definition order, PK preserved.
+      assert info.schema == %Fluss.Schema{
+               columns: [{"id", :int}, {"name", :string}],
+               primary_key: ["id"]
+             }
+
+      assert info.is_partitioned == false
+      assert info.is_auto_partitioned == false
+      assert info.partition_keys == []
+      assert info.comment == nil
+
+      assert info.physical_primary_keys == ["id"]
+      assert info.bucket_keys == ["id"]
+      assert info.has_bucket_key == true
+      assert info.is_default_bucket_key == true
+
+      # Server-assigned — sanity only, values aren't predictable.
+      assert is_integer(info.table_id) and info.table_id > 0
+      assert is_integer(info.schema_id) and info.schema_id >= 1
+      assert is_integer(info.created_time) and info.created_time > 0
+      assert is_integer(info.modified_time) and info.modified_time > 0
+
+      assert is_map(info.properties)
+      assert is_map(info.custom_properties)
+    end
+
+    test "exposes a composite primary key", %{admin: admin} do
+      table = "fluss_table_#{:rand.uniform(100_000)}"
+
+      schema =
+        Fluss.Schema.new()
+        |> Fluss.Schema.column("id", :int)
+        |> Fluss.Schema.column("region", :string)
+        |> Fluss.Schema.column("name", :string)
+        |> Fluss.Schema.primary_key(["id", "region"])
+
+      descriptor = Fluss.TableDescriptor.new!(schema, bucket_count: 4)
+      :ok = Fluss.Admin.create_table(admin, @database, table, descriptor, true)
+      on_exit(fn -> Fluss.Admin.drop_table(admin, @database, table, true) end)
+
+      assert {:ok, info} = Fluss.Admin.get_table_info(admin, @database, table)
+
+      assert info.has_primary_key == true
+      assert info.primary_keys == ["id", "region"]
+      assert info.physical_primary_keys == ["id", "region"]
+
+      assert info.schema == %Fluss.Schema{
+               columns: [{"id", :int}, {"region", :string}, {"name", :string}],
+               primary_key: ["id", "region"]
+             }
+    end
+
+    test "returns {:error, %Fluss.Error{}} for a non-existent table", %{admin: 
admin} do
+      table = "fluss_table_#{:rand.uniform(100_000)}"
+      assert {:error, %Fluss.Error{}} = Fluss.Admin.get_table_info(admin, 
@database, table)
+    end
+  end
+
+  describe "get_table_info!/3" do
+    test "returns the %Fluss.TableInfo{} directly without an :ok tuple", 
%{admin: admin} do
+      table = "fluss_table_#{:rand.uniform(100_000)}"
+
+      schema =
+        Fluss.Schema.new()
+        |> Fluss.Schema.column("id", :int)
+        |> Fluss.Schema.column("name", :string)
+
+      descriptor = Fluss.TableDescriptor.new!(schema)
+      :ok = Fluss.Admin.create_table(admin, @database, table, descriptor, true)
+      on_exit(fn -> Fluss.Admin.drop_table(admin, @database, table, true) end)
+
+      info = Fluss.Admin.get_table_info!(admin, @database, table)
+      assert %Fluss.TableInfo{} = info
+      assert info.table_name == table
+    end
+
+    test "raises Fluss.Error for a non-existent table", %{admin: admin} do
+      table = "fluss_table_#{:rand.uniform(100_000)}"
+
+      assert_raise Fluss.Error, fn ->
+        Fluss.Admin.get_table_info!(admin, @database, table)
+      end
+    end
+  end
+
+  describe "get_table_schema/4" do
+    test "returns the current schema as %Fluss.SchemaInfo{}", %{admin: admin} 
do
+      table = "fluss_table_#{:rand.uniform(100_000)}"
+
+      schema =
+        Fluss.Schema.new()
+        |> Fluss.Schema.column("id", :int)
+        |> Fluss.Schema.column("name", :string)
+        |> Fluss.Schema.primary_key(["id"])
+
+      descriptor = Fluss.TableDescriptor.new!(schema)
+      :ok = Fluss.Admin.create_table(admin, @database, table, descriptor, true)
+      on_exit(fn -> Fluss.Admin.drop_table(admin, @database, table, true) end)
+
+      assert {:ok, %Fluss.SchemaInfo{} = info} =
+               Fluss.Admin.get_table_schema(admin, @database, table)
+
+      assert info.schema == %Fluss.Schema{
+               columns: [{"id", :int}, {"name", :string}],
+               primary_key: ["id"]
+             }
+
+      assert is_integer(info.schema_id) and info.schema_id >= 1
+    end
+
+    test "fetches a specific schema version by id", %{admin: admin} do
+      table = "fluss_table_#{:rand.uniform(100_000)}"
+
+      schema =
+        Fluss.Schema.new()
+        |> Fluss.Schema.column("id", :int)
+        |> Fluss.Schema.column("name", :string)
+        |> Fluss.Schema.primary_key(["id"])
+
+      descriptor = Fluss.TableDescriptor.new!(schema)
+      :ok = Fluss.Admin.create_table(admin, @database, table, descriptor, true)
+      on_exit(fn -> Fluss.Admin.drop_table(admin, @database, table, true) end)
+
+      # Without alter_table there's only one version, so requesting its id
+      # explicitly must return the same SchemaInfo as the latest (nil) lookup.
+      {:ok, current} = Fluss.Admin.get_table_schema(admin, @database, table)
+
+      assert {:ok, by_id} =
+               Fluss.Admin.get_table_schema(admin, @database, table, 
current.schema_id)
+
+      assert by_id == current
+    end
+
+    test "returns {:error, %Fluss.Error{}} for a non-existent table", %{admin: 
admin} do
+      table = "fluss_table_#{:rand.uniform(100_000)}"
+
+      assert {:error, %Fluss.Error{}} =
+               Fluss.Admin.get_table_schema(admin, @database, table)
+    end
+  end
 end

Reply via email to