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 75ffe70 Support Java-style blob field directives (#490)
75ffe70 is described below
commit 75ffe70deb37760a979ceb081df536a6961c28aa
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Jul 9 15:33:26 2026 +0800
Support Java-style blob field directives (#490)
---
crates/integrations/datafusion/src/sql_context.rs | 99 +++++-
crates/paimon/src/spec/core_options.rs | 27 +-
crates/paimon/src/spec/schema.rs | 377 ++++++++++++++++++++--
docs/src/sql.md | 44 ++-
4 files changed, 519 insertions(+), 28 deletions(-)
diff --git a/crates/integrations/datafusion/src/sql_context.rs
b/crates/integrations/datafusion/src/sql_context.rs
index e7b0ba2..da74a06 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -49,10 +49,10 @@ use datafusion::error::{DataFusionError, Result as
DFResult};
use datafusion::execution::SessionStateBuilder;
use datafusion::prelude::{DataFrame, SessionContext};
use datafusion::sql::sqlparser::ast::{
- AlterTableOperation, BinaryLength, CharacterLength, ColumnDef,
CreateTable, CreateTableOptions,
- CreateView, Delete, Expr as SqlExpr, FromTable, Insert, Merge, ObjectName,
ObjectType,
- RenameTableNameKind, Reset, ResetStatement, Set, ShowCreateObject,
SqlOption, Statement,
- TableFactor, TableObject, Truncate, Update, Value as SqlValue,
+ AlterTableOperation, BinaryLength, CharacterLength, ColumnDef,
ColumnOption, CreateTable,
+ CreateTableOptions, CreateView, Delete, Expr as SqlExpr, FromTable,
Insert, Merge, ObjectName,
+ ObjectType, RenameTableNameKind, Reset, ResetStatement, Set,
ShowCreateObject, SqlOption,
+ Statement, TableFactor, TableObject, Truncate, Update, Value as SqlValue,
};
use datafusion::sql::sqlparser::dialect::GenericDialect;
use datafusion::sql::sqlparser::parser::Parser;
@@ -1723,7 +1723,7 @@ fn column_def_to_paimon_type(col: &ColumnDef) ->
DFResult<PaimonDataType> {
fn column_def_comment(col: &ColumnDef) -> Option<String> {
col.options.iter().find_map(|opt| match &opt.option {
- datafusion::sql::sqlparser::ast::ColumnOption::Comment(comment) =>
Some(comment.clone()),
+ ColumnOption::Comment(comment) => Some(comment.clone()),
_ => None,
})
}
@@ -1841,6 +1841,10 @@ fn sql_data_type_to_paimon_type(
VarBinaryType::try_new(nullable, VarBinaryType::MAX_LENGTH)
.map_err(to_datafusion_error)?,
)),
+ other if other.to_string().eq_ignore_ascii_case("BYTES") =>
Ok(PaimonDataType::VarBinary(
+ VarBinaryType::try_new(nullable, VarBinaryType::MAX_LENGTH)
+ .map_err(to_datafusion_error)?,
+ )),
SqlType::Blob(_) =>
Ok(PaimonDataType::Blob(BlobType::with_nullable(nullable))),
SqlType::Custom(name, modifiers)
if name.to_string().eq_ignore_ascii_case("VARIANT") &&
modifiers.is_empty() =>
@@ -3401,6 +3405,61 @@ mod tests {
}
}
+ #[tokio::test]
+ async fn test_create_table_blob_comment_directives() {
+ let catalog = Arc::new(MockCatalog::new());
+ let sql_context = make_sql_context(catalog.clone()).await;
+
+ sql_context
+ .sql(
+ "CREATE TABLE mydb.t1 (\
+ id INT, \
+ photo BYTES COMMENT '__BLOB_FIELD; raw photo', \
+ thumb BINARY COMMENT '__BLOB_DESCRIPTOR_FIELD', \
+ preview VARBINARY COMMENT '__BLOB_VIEW_FIELD; preview ref'\
+ ) WITH ('data-evolution.enabled' = 'true')",
+ )
+ .await
+ .unwrap();
+
+ let calls = catalog.take_calls();
+ assert_eq!(calls.len(), 1);
+ if let CatalogCall::CreateTable { schema, .. } = &calls[0] {
+ assert!(matches!(
+ schema.fields()[1].data_type(),
+ PaimonDataType::Blob(_)
+ ));
+ assert!(matches!(
+ schema.fields()[2].data_type(),
+ PaimonDataType::Blob(_)
+ ));
+ assert!(matches!(
+ schema.fields()[3].data_type(),
+ PaimonDataType::Blob(_)
+ ));
+ assert_eq!(schema.fields()[1].description(), Some("raw photo"));
+ assert_eq!(schema.fields()[2].description(), None);
+ assert_eq!(schema.fields()[3].description(), Some("preview ref"));
+ assert_eq!(
+ schema.options().get("blob-field").map(String::as_str),
+ Some("photo")
+ );
+ assert_eq!(
+ schema
+ .options()
+ .get("blob-descriptor-field")
+ .map(String::as_str),
+ Some("thumb")
+ );
+ assert_eq!(
+ schema.options().get("blob-view-field").map(String::as_str),
+ Some("preview")
+ );
+ } else {
+ panic!("expected CreateTable call");
+ }
+ }
+
#[tokio::test]
async fn test_alter_table_add_column() {
let catalog = Arc::new(MockCatalog::new());
@@ -3457,6 +3516,36 @@ mod tests {
}
}
+ #[tokio::test]
+ async fn test_alter_table_add_blob_comment_directive_passes_core_input() {
+ let catalog = Arc::new(MockCatalog::new());
+ let sql_context = make_sql_context(catalog.clone()).await;
+
+ sql_context
+ .sql("ALTER TABLE mydb.t1 ADD COLUMN preview BYTES COMMENT
'__BLOB_DESCRIPTOR_FIELD; preview descriptor'")
+ .await
+ .unwrap();
+
+ let calls = catalog.take_calls();
+ assert_eq!(calls.len(), 1);
+ if let CatalogCall::AlterTable { changes, .. } = &calls[0] {
+ assert_eq!(changes.len(), 1);
+ assert!(matches!(
+ &changes[0],
+ SchemaChange::AddColumn {
+ field_names,
+ data_type,
+ comment,
+ ..
+ } if field_names.first().map(String::as_str) == Some("preview")
+ && matches!(data_type, PaimonDataType::VarBinary(_))
+ && comment.as_deref() == Some("__BLOB_DESCRIPTOR_FIELD;
preview descriptor")
+ ));
+ } else {
+ panic!("expected AlterTable call");
+ }
+ }
+
#[tokio::test]
async fn test_alter_table_drop_column() {
let catalog = Arc::new(MockCatalog::new());
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index a28b0fa..fd685ee 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -93,8 +93,9 @@ const DEFAULT_DYNAMIC_BUCKET_TARGET_ROW_NUM: i64 = 200_000;
const DEFAULT_GLOBAL_INDEX_ROW_COUNT_PER_SHARD: i64 = 100_000;
const DEFAULT_GLOBAL_INDEX_FALLBACK_SCAN_MAX_SIZE: i64 = 256 * 1024 * 1024;
const BLOB_AS_DESCRIPTOR_OPTION: &str = "blob-as-descriptor";
-const BLOB_DESCRIPTOR_FIELD_OPTION: &str = "blob-descriptor-field";
-const BLOB_VIEW_FIELD_OPTION: &str = "blob-view-field";
+pub(crate) const BLOB_FIELD_OPTION: &str = "blob-field";
+pub(crate) const BLOB_DESCRIPTOR_FIELD_OPTION: &str = "blob-descriptor-field";
+pub(crate) const BLOB_VIEW_FIELD_OPTION: &str = "blob-view-field";
pub const BLOB_VIEW_RESOLVE_ENABLED_OPTION: &str = "blob-view.resolve.enabled";
/// Merge engine for primary-key tables.
@@ -833,6 +834,17 @@ impl<'a> CoreOptions<'a> {
.unwrap_or(false)
}
+ /// Comma-separated BLOB field names stored in dedicated `.blob` files.
+ ///
+ /// Fields listed in `blob-descriptor-field` or `blob-view-field` are also
+ /// treated as BLOB fields, matching Java Paimon's `blob-field` semantics.
+ pub fn blob_fields(&self) -> HashSet<String> {
+ let mut fields = self.parse_csv_set(BLOB_FIELD_OPTION);
+ fields.extend(self.blob_descriptor_fields());
+ fields.extend(self.blob_view_fields());
+ fields
+ }
+
/// Comma-separated BLOB field names stored as serialized BlobDescriptor
/// bytes inline in normal data files (no .blob files for these fields).
pub fn blob_descriptor_fields(&self) -> HashSet<String> {
@@ -1448,6 +1460,7 @@ mod tests {
#[test]
fn test_blob_view_options() {
let options = HashMap::from([
+ (BLOB_FIELD_OPTION.to_string(), "raw".to_string()),
(
BLOB_DESCRIPTOR_FIELD_OPTION.to_string(),
"thumb, payload".to_string(),
@@ -1459,6 +1472,16 @@ mod tests {
]);
let core = CoreOptions::new(&options);
+ assert_eq!(
+ core.blob_fields(),
+ HashSet::from([
+ "raw".to_string(),
+ "thumb".to_string(),
+ "payload".to_string(),
+ "image".to_string(),
+ "video".to_string()
+ ])
+ );
assert_eq!(
core.blob_descriptor_fields(),
HashSet::from(["thumb".to_string(), "payload".to_string()])
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index cc3f17a..626889e 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -16,18 +16,23 @@
// under the License.
use crate::spec::core_options::{
- first_row_supports_changelog_producer, CoreOptions, BUCKET_KEY_OPTION,
- QUERY_AUTH_ENABLED_OPTION, SEQUENCE_FIELD_OPTION,
+ first_row_supports_changelog_producer, CoreOptions,
BLOB_DESCRIPTOR_FIELD_OPTION,
+ BLOB_FIELD_OPTION, BLOB_VIEW_FIELD_OPTION, BUCKET_KEY_OPTION,
QUERY_AUTH_ENABLED_OPTION,
+ SEQUENCE_FIELD_OPTION,
};
use crate::spec::types::{ArrayType, DataType, MapType, MultisetType, RowType};
use crate::spec::{
- remove_field_scoped_options, rename_field_scoped_options,
AggregationConfig, ColumnMove,
- ColumnMoveType, PartialUpdateConfig,
+ remove_field_scoped_options, rename_field_scoped_options,
AggregationConfig, BlobType,
+ ColumnMove, ColumnMoveType, PartialUpdateConfig,
};
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use std::collections::{HashMap, HashSet};
+const BLOB_FIELD_DIRECTIVE: &str = "__BLOB_FIELD";
+const BLOB_DESCRIPTOR_FIELD_DIRECTIVE: &str = "__BLOB_DESCRIPTOR_FIELD";
+const BLOB_VIEW_FIELD_DIRECTIVE: &str = "__BLOB_VIEW_FIELD";
+
/// The table schema for paimon table.
///
/// Impl References:
<https://github.com/apache/paimon/blob/release-0.8.2/paimon-core/src/main/java/org/apache/paimon/schema/TableSchema.java#L47>
@@ -203,8 +208,8 @@ impl TableSchema {
}
SchemaChange::AddColumn {
field_names,
- data_type,
- comment,
+ mut data_type,
+ mut comment,
column_move,
} => {
let name = top_level_field(&field_names)?;
@@ -214,6 +219,11 @@ impl TableSchema {
column: name.to_string(),
});
}
+ if let Some(directive) =
parse_blob_comment_directive(comment.as_deref())? {
+ append_csv_option(&mut new_schema.options,
directive.option_key, name);
+ comment = directive.comment;
+ data_type = normalize_blob_field_type(name,
data_type)?;
+ }
// Mirrors Java: an added column has no value for existing
// rows, so it must be nullable.
if !data_type.is_nullable() {
@@ -618,6 +628,98 @@ fn reassign_field_ids(data_type: DataType, next_id: &mut
i32) -> DataType {
}
}
+struct BlobCommentDirective {
+ option_key: &'static str,
+ comment: Option<String>,
+}
+
+fn parse_blob_comment_directive(
+ comment: Option<&str>,
+) -> crate::Result<Option<BlobCommentDirective>> {
+ let Some(comment) = comment else {
+ return Ok(None);
+ };
+ let comment = comment.trim();
+ if !comment.starts_with("__BLOB") {
+ return Ok(None);
+ }
+ let Some((option_key, marker)) = match_blob_comment_directive(comment)
else {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "Unsupported BLOB comment directive '{comment}'. Supported
directives are \
+ '{BLOB_FIELD_DIRECTIVE}',
'{BLOB_DESCRIPTOR_FIELD_DIRECTIVE}', and \
+ '{BLOB_VIEW_FIELD_DIRECTIVE}'."
+ ),
+ });
+ };
+ let real_comment = if comment.len() == marker.len() {
+ None
+ } else {
+ let real_comment = comment[marker.len() + 1..].trim();
+ (!real_comment.is_empty()).then(|| real_comment.to_string())
+ };
+ Ok(Some(BlobCommentDirective {
+ option_key,
+ comment: real_comment,
+ }))
+}
+
+fn match_blob_comment_directive(comment: &str) -> Option<(&'static str,
&'static str)> {
+ [
+ (BLOB_VIEW_FIELD_DIRECTIVE, BLOB_VIEW_FIELD_OPTION),
+ (
+ BLOB_DESCRIPTOR_FIELD_DIRECTIVE,
+ BLOB_DESCRIPTOR_FIELD_OPTION,
+ ),
+ (BLOB_FIELD_DIRECTIVE, BLOB_FIELD_OPTION),
+ ]
+ .into_iter()
+ .find_map(|(marker, option_key)| {
+ if !comment.starts_with(marker) {
+ return None;
+ }
+ if comment.len() == marker.len() ||
comment.as_bytes().get(marker.len()) == Some(&b';') {
+ Some((option_key, marker))
+ } else {
+ None
+ }
+ })
+}
+
+fn append_csv_option(options: &mut HashMap<String, String>, key: &'static str,
field_name: &str) {
+ let value = append_csv_field(options.get(key).map(String::as_str),
field_name);
+ options.insert(key.to_string(), value);
+}
+
+fn append_csv_field(existing: Option<&str>, field_name: &str) -> String {
+ let mut fields = existing
+ .into_iter()
+ .flat_map(|value| value.split(','))
+ .map(str::trim)
+ .filter(|field| !field.is_empty())
+ .map(ToString::to_string)
+ .collect::<Vec<_>>();
+ if !fields.iter().any(|field| field == field_name) {
+ fields.push(field_name.to_string());
+ }
+ fields.join(",")
+}
+
+fn normalize_blob_field_type(field_name: &str, data_type: DataType) ->
crate::Result<DataType> {
+ let nullable = data_type.is_nullable();
+ match data_type {
+ DataType::Blob(_) => Ok(data_type),
+ DataType::Binary(_) | DataType::VarBinary(_) => {
+ Ok(DataType::Blob(BlobType::with_nullable(nullable)))
+ }
+ other => Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "BLOB field option references non-binary column '{field_name}'
with type {other:?}"
+ ),
+ }),
+ }
+}
+
/// Insert a brand-new field according to an optional move (used by
`AddColumn`).
fn insert_field_with_move(
fields: &mut Vec<DataField>,
@@ -792,7 +894,7 @@ pub struct Schema {
impl Schema {
/// Build a schema with validation. Normalizes partition/primary keys from
options if present.
fn new(
- fields: Vec<DataField>,
+ mut fields: Vec<DataField>,
partition_keys: Vec<String>,
primary_keys: Vec<String>,
mut options: HashMap<String, String>,
@@ -800,7 +902,8 @@ impl Schema {
) -> crate::Result<Self> {
let primary_keys = Self::normalize_primary_keys(&primary_keys, &mut
options)?;
let partition_keys = Self::normalize_partition_keys(&partition_keys,
&mut options)?;
- let fields = Self::normalize_fields(&fields, &partition_keys,
&primary_keys)?;
+ Self::normalize_blob_comment_directives(&mut fields, &mut options)?;
+ let fields = Self::normalize_fields(&fields, &partition_keys,
&primary_keys, &options)?;
Self::validate_key_field_types(&fields, &primary_keys, &options)?;
Self::validate_blob_fields(&fields, &partition_keys, &options)?;
Self::validate_vector_store_fields(&fields, &partition_keys,
&options)?;
@@ -861,12 +964,14 @@ impl Schema {
Ok(partition_keys.to_vec())
}
- /// Normalize fields: validate (duplicate/subset checks) and make primary
key columns non-nullable.
+ /// Normalize fields: validate duplicate/subset checks, promote configured
+ /// binary BLOB fields, and make primary key columns non-nullable.
/// Corresponds to Java `normalizeFields`.
fn normalize_fields(
fields: &[DataField],
partition_keys: &[String],
primary_keys: &[String],
+ options: &HashMap<String, String>,
) -> crate::Result<Vec<DataField>> {
let field_names: Vec<String> = fields.iter().map(|f|
f.name().to_string()).collect();
Self::validate_no_duplicate_fields(&field_names)?;
@@ -874,25 +979,34 @@ impl Schema {
Self::validate_primary_keys(&field_names, primary_keys)?;
Self::validate_primary_keys_not_partition_only(partition_keys,
primary_keys)?;
- if primary_keys.is_empty() {
+ let blob_field_names = CoreOptions::new(options).blob_fields();
+ for name in &blob_field_names {
+ if !field_names.iter().any(|field| field == name) {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!("BLOB field option references missing
column '{name}'"),
+ });
+ }
+ }
+
+ if primary_keys.is_empty() && blob_field_names.is_empty() {
return Ok(fields.to_vec());
}
let pk_set: HashSet<&str> =
primary_keys.iter().map(String::as_str).collect();
let mut new_fields = Vec::with_capacity(fields.len());
for f in fields {
- if pk_set.contains(f.name()) && f.data_type().is_nullable() {
- new_fields.push(
- DataField::new(
- f.id(),
- f.name().to_string(),
- f.data_type().copy_with_nullable(false)?,
- )
- .with_description(f.description().map(|s| s.to_string())),
- );
+ let mut data_type = if blob_field_names.contains(f.name()) {
+ normalize_blob_field_type(f.name(), f.data_type().clone())?
} else {
- new_fields.push(f.clone());
+ f.data_type().clone()
+ };
+ if pk_set.contains(f.name()) && data_type.is_nullable() {
+ data_type = data_type.copy_with_nullable(false)?;
}
+ new_fields.push(
+ DataField::new(f.id(), f.name().to_string(), data_type)
+ .with_description(f.description().map(|s| s.to_string())),
+ );
}
Ok(new_fields)
}
@@ -911,6 +1025,20 @@ impl Schema {
}
}
+ fn normalize_blob_comment_directives(
+ fields: &mut [DataField],
+ options: &mut HashMap<String, String>,
+ ) -> crate::Result<()> {
+ for field in fields {
+ let Some(directive) =
parse_blob_comment_directive(field.description())? else {
+ continue;
+ };
+ append_csv_option(options, directive.option_key, field.name());
+ *field = field.clone().with_description(directive.comment);
+ }
+ Ok(())
+ }
+
/// Partition key constraint must not contain duplicates; all partition
keys must be in table columns.
fn validate_partition_keys(
field_names: &[String],
@@ -1029,6 +1157,22 @@ impl Schema {
}
let core_options = CoreOptions::new(options);
+ let blob_descriptor_fields = core_options.blob_descriptor_fields();
+ let blob_view_fields = core_options.blob_view_fields();
+ let mut overlapping_fields = blob_view_fields
+ .intersection(&blob_descriptor_fields)
+ .cloned()
+ .collect::<Vec<_>>();
+ overlapping_fields.sort();
+ if let Some(field) = overlapping_fields.first() {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "Field '{field}' in '{BLOB_VIEW_FIELD_OPTION}' can not
also be in \
+ '{BLOB_DESCRIPTOR_FIELD_OPTION}'."
+ ),
+ });
+ }
+
if !core_options.data_evolution_enabled() {
return Err(crate::Error::ConfigInvalid {
message: "Data evolution config must enabled for table with
BLOB type column."
@@ -1643,6 +1787,199 @@ mod tests {
assert_eq!(schema.fields().len(), 2);
}
+ #[test]
+ fn test_blob_field_option_promotes_binary_column() {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column(
+ "payload",
+ DataType::VarBinary(
+
crate::spec::VarBinaryType::new(crate::spec::VarBinaryType::DEFAULT_LENGTH)
+ .unwrap(),
+ ),
+ )
+ .option("blob-field", "payload")
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap();
+
+ assert!(matches!(schema.fields()[1].data_type(), DataType::Blob(_)));
+ }
+
+ #[test]
+ fn test_blob_comment_directive_promotes_column_and_strips_comment() {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column_with_description(
+ "payload",
+ DataType::VarBinary(
+
crate::spec::VarBinaryType::new(crate::spec::VarBinaryType::DEFAULT_LENGTH)
+ .unwrap(),
+ ),
+ Some("__BLOB_FIELD; payload bytes".to_string()),
+ )
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap();
+
+ assert!(matches!(schema.fields()[1].data_type(), DataType::Blob(_)));
+ assert_eq!(schema.fields()[1].description(), Some("payload bytes"));
+ assert_eq!(
+ schema.options().get("blob-field").map(String::as_str),
+ Some("payload")
+ );
+ }
+
+ #[test]
+ fn test_blob_comment_directive_rejects_unknown_create_directive() {
+ let err = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column_with_description(
+ "payload",
+ DataType::VarBinary(
+
crate::spec::VarBinaryType::new(crate::spec::VarBinaryType::DEFAULT_LENGTH)
+ .unwrap(),
+ ),
+ Some("__BLOB_FIEL; payload bytes".to_string()),
+ )
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap_err();
+
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { message } if
message.contains("Unsupported BLOB comment directive") &&
message.contains("__BLOB_FIEL")),
+ "unknown __BLOB* comment directive should be rejected"
+ );
+ }
+
+ #[test]
+ fn test_blob_comment_directive_rejects_malformed_marker_separator() {
+ let err = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column_with_description(
+ "payload",
+ DataType::VarBinary(
+
crate::spec::VarBinaryType::new(crate::spec::VarBinaryType::DEFAULT_LENGTH)
+ .unwrap(),
+ ),
+ Some("__BLOB_FIELD ; payload bytes".to_string()),
+ )
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap_err();
+
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { message } if
message.contains("Unsupported BLOB comment directive") &&
message.contains("__BLOB_FIELD ; payload bytes")),
+ "BLOB directive marker should be followed immediately by ';'"
+ );
+ }
+
+ #[test]
+ fn test_blob_comment_directive_rejects_descriptor_view_conflict() {
+ let err = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column_with_description(
+ "preview",
+ DataType::VarBinary(
+
crate::spec::VarBinaryType::new(crate::spec::VarBinaryType::DEFAULT_LENGTH)
+ .unwrap(),
+ ),
+ Some("__BLOB_VIEW_FIELD".to_string()),
+ )
+ .option("blob-descriptor-field", "preview")
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap_err();
+
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { message }
+ if message.contains("preview")
+ && message.contains("blob-view-field")
+ && message.contains("blob-descriptor-field")),
+ "field configured as both blob descriptor and blob view should be
rejected"
+ );
+ }
+
+ #[test]
+ fn test_blob_comment_directive_add_column_updates_options() {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("thumb", DataType::Blob(BlobType::new()))
+ .option("blob-descriptor-field", "thumb")
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap();
+
+ let schema = TableSchema::new(0, &schema)
+ .apply_changes(vec![crate::spec::SchemaChange::AddColumn {
+ field_names: vec!["preview".to_string()],
+ data_type: DataType::VarBinary(
+
crate::spec::VarBinaryType::new(crate::spec::VarBinaryType::DEFAULT_LENGTH)
+ .unwrap(),
+ ),
+ comment: Some("__BLOB_DESCRIPTOR_FIELD; preview
descriptor".to_string()),
+ column_move: None,
+ }])
+ .unwrap();
+
+ let preview = schema
+ .fields()
+ .iter()
+ .find(|field| field.name() == "preview")
+ .unwrap();
+ assert!(matches!(preview.data_type(), DataType::Blob(_)));
+ assert_eq!(preview.description(), Some("preview descriptor"));
+ assert_eq!(
+ schema
+ .options()
+ .get("blob-descriptor-field")
+ .map(String::as_str),
+ Some("thumb,preview")
+ );
+ }
+
+ #[test]
+ fn test_blob_comment_directive_rejects_unknown_add_column_directive() {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap();
+
+ let err = TableSchema::new(0, &schema)
+ .apply_changes(vec![crate::spec::SchemaChange::AddColumn {
+ field_names: vec!["preview".to_string()],
+ data_type: DataType::VarBinary(
+
crate::spec::VarBinaryType::new(crate::spec::VarBinaryType::DEFAULT_LENGTH)
+ .unwrap(),
+ ),
+ comment: Some("__BLOB_UNKNOWN; preview
descriptor".to_string()),
+ column_move: None,
+ }])
+ .unwrap_err();
+
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { message } if
message.contains("Unsupported BLOB comment directive") &&
message.contains("__BLOB_UNKNOWN")),
+ "unknown __BLOB* add-column directive should be rejected"
+ );
+ }
+
+ #[test]
+ fn test_blob_field_option_rejects_non_binary_column() {
+ let err = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", DataType::Int(IntType::new()))
+ .option("blob-field", "payload")
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap_err();
+
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { message } if
message.contains("non-binary column")),
+ "blob-field on a non-binary column should be rejected"
+ );
+ }
+
#[test]
fn test_partial_update_schema_validation_rejects_unsupported_options() {
for (key, value) in [
diff --git a/docs/src/sql.md b/docs/src/sql.md
index c2b11cb..d3b2520 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -90,7 +90,7 @@ The following SQL data types are supported in CREATE TABLE
and mapped to their c
| `FLOAT` / `REAL` | FloatType | |
| `DOUBLE` / `DOUBLE PRECISION` | DoubleType | |
| `VARCHAR` / `TEXT` / `STRING` / `CHAR` | VarCharType | |
-| `BINARY` / `VARBINARY` / `BYTEA` | VarBinaryType | |
+| `BINARY` / `VARBINARY` / `BYTEA` / `BYTES` | VarBinaryType | |
| `VARIANT` | VariantType | Semi-structured value encoded as value + metadata
binary buffers |
| `BLOB` | BlobType | Binary large object |
| `DATE` | DateType | |
@@ -107,6 +107,48 @@ columns; DataFusion reads those as Arrow
`FixedSizeList<Float32>`, and vindex
index creation uses `N` as the vector dimension. `SHOW CREATE TABLE` currently
does not round-trip `VECTOR` columns.
+### Blob Columns
+
+BLOB columns store large binary values using Paimon's dedicated BLOB layout.
+Declare them as top-level columns and enable data evolution:
+
+```sql
+CREATE TABLE paimon.my_db.assets (
+ id INT,
+ picture BLOB
+) WITH (
+ 'data-evolution.enabled' = 'true'
+);
+```
+
+For Java-compatible DDL, DataFusion also supports the BLOB comment directives
+used by Java Paimon. A binary column with one of these directive comments is
+normalized to a Paimon BLOB column in the core schema layer:
+
+```sql
+CREATE TABLE paimon.my_db.assets (
+ id INT,
+ picture BYTES COMMENT '__BLOB_FIELD; original image',
+ thumbnail BYTES COMMENT '__BLOB_DESCRIPTOR_FIELD; descriptor bytes',
+ picture_ref BYTES COMMENT '__BLOB_VIEW_FIELD; upstream image reference'
+) WITH (
+ 'data-evolution.enabled' = 'true'
+);
+```
+
+The directive is stripped from the stored column comment; text after the first
+semicolon is kept as the real comment. The directives also populate the
matching
+table options. A comment directive that starts with `__BLOB` but is not one of
+the supported directives is rejected.
+
+| Comment directive | Table option | Storage semantics |
+| --- | --- | --- |
+| `__BLOB_FIELD` | `blob-field` | Store BLOB bytes in dedicated `.blob` files |
+| `__BLOB_DESCRIPTOR_FIELD` | `blob-descriptor-field` | Store serialized
`BlobDescriptor` bytes inline |
+| `__BLOB_VIEW_FIELD` | `blob-view-field` | Store serialized `BlobViewStruct`
bytes inline |
+
+The same directives are supported by `ALTER TABLE ... ADD COLUMN`.
+
### Blob View
Blob View stores an inline reference to a BLOB value in another table, using a