Skip to content

Commit 717fd48

Browse files
feat: enable "TimestampWithoutTimezone" table feature and add protocol validation for it (delta-io#988)
Resolves delta-io#987. And adds protocol validation: if the provided table has "TIMESTAMP_NTZ" column, it should have "timestampNtz" reader/writer features. ## What changes are proposed in this pull request? Support `TIMESTAMP_NTZ` column type for writes. ## How was this change tested? * Added test to `kernel/tests/write.rs` * Added test to new `kernel/src/table_features/timestamp_ntz.rs` --------- Co-authored-by: Zach Schuermann <zachary.zvs@gmail.com>
1 parent ab2eb39 commit 717fd48

4 files changed

Lines changed: 293 additions & 5 deletions

File tree

kernel/src/table_configuration.rs

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ use url::Url;
1515
use crate::actions::{ensure_supported_features, Metadata, Protocol};
1616
use crate::schema::{InvariantChecker, SchemaRef};
1717
use crate::table_features::{
18-
column_mapping_mode, validate_schema_column_mapping, ColumnMappingMode, ReaderFeature,
19-
WriterFeature,
18+
column_mapping_mode, validate_schema_column_mapping, validate_timestamp_ntz_feature_support,
19+
ColumnMappingMode, ReaderFeature, WriterFeature,
2020
};
2121
use crate::table_properties::TableProperties;
2222
use crate::{DeltaResult, Error, Version};
@@ -78,6 +78,9 @@ impl TableConfiguration {
7878

7979
// validate column mapping mode -- all schema fields should be correctly (un)annotated
8080
validate_schema_column_mapping(&schema, column_mapping_mode)?;
81+
82+
validate_timestamp_ntz_feature_support(&schema, &protocol)?;
83+
8184
Ok(Self {
8285
schema,
8386
metadata,
@@ -574,4 +577,55 @@ mod test {
574577
);
575578
assert_eq!(new_table_config.table_root(), table_config.table_root());
576579
}
580+
581+
#[test]
582+
fn test_timestamp_ntz_validation_integration() {
583+
// Schema with TIMESTAMP_NTZ column
584+
let schema_string = r#"{"type":"struct","fields":[{"name":"ts","type":"timestamp_ntz","nullable":true,"metadata":{}}]}"#.to_string();
585+
let metadata = Metadata {
586+
schema_string,
587+
..Default::default()
588+
};
589+
590+
let protocol_without_timestamp_ntz_features = Protocol::try_new(
591+
3,
592+
7,
593+
Some::<Vec<String>>(vec![]),
594+
Some::<Vec<String>>(vec![]),
595+
)
596+
.unwrap();
597+
598+
let protocol_with_timestamp_ntz_features = Protocol::try_new(
599+
3,
600+
7,
601+
Some([ReaderFeature::TimestampWithoutTimezone]),
602+
Some([WriterFeature::TimestampWithoutTimezone]),
603+
)
604+
.unwrap();
605+
606+
let table_root = Url::try_from("file:///").unwrap();
607+
608+
let result = TableConfiguration::try_new(
609+
metadata.clone(),
610+
protocol_without_timestamp_ntz_features,
611+
table_root.clone(),
612+
0,
613+
);
614+
assert!(
615+
result.is_err(),
616+
"Should fail when TIMESTAMP_NTZ is used without required features"
617+
);
618+
assert!(result.unwrap_err().to_string().contains("timestampNtz"));
619+
620+
let result = TableConfiguration::try_new(
621+
metadata,
622+
protocol_with_timestamp_ntz_features,
623+
table_root,
624+
0,
625+
);
626+
assert!(
627+
result.is_ok(),
628+
"Should succeed when TIMESTAMP_NTZ is used with required features"
629+
);
630+
}
577631
}

kernel/src/table_features/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ use crate::schema::DataType;
88

99
pub(crate) use column_mapping::column_mapping_mode;
1010
pub use column_mapping::{validate_schema_column_mapping, ColumnMappingMode};
11+
pub(crate) use timestamp_ntz::validate_timestamp_ntz_feature_support;
1112
mod column_mapping;
13+
mod timestamp_ntz;
1214

1315
/// Reader features communicate capabilities that must be implemented in order to correctly read a
1416
/// given table. That is, readers must implement and respect all features listed in a table's
@@ -167,6 +169,7 @@ pub(crate) static SUPPORTED_WRITER_FEATURES: LazyLock<Vec<WriterFeature>> = Lazy
167169
WriterFeature::AppendOnly,
168170
WriterFeature::DeletionVectors,
169171
WriterFeature::Invariants,
172+
WriterFeature::TimestampWithoutTimezone,
170173
]
171174
});
172175

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
//! Validation for TIMESTAMP_NTZ feature support
2+
3+
use super::{ReaderFeature, WriterFeature};
4+
use crate::actions::Protocol;
5+
use crate::schema::{PrimitiveType, Schema, SchemaTransform};
6+
use crate::utils::require;
7+
use crate::{DeltaResult, Error};
8+
9+
use std::borrow::Cow;
10+
11+
/// Validates that if a table schema contains TIMESTAMP_NTZ columns, the table must have the
12+
/// TimestampWithoutTimezone feature in both reader and writer features.
13+
pub(crate) fn validate_timestamp_ntz_feature_support(
14+
schema: &Schema,
15+
protocol: &Protocol,
16+
) -> DeltaResult<()> {
17+
if !protocol.has_reader_feature(&ReaderFeature::TimestampWithoutTimezone)
18+
|| !protocol.has_writer_feature(&WriterFeature::TimestampWithoutTimezone)
19+
{
20+
let mut uses_timestamp_ntz = UsesTimestampNtz(false);
21+
let _ = uses_timestamp_ntz.transform_struct(schema);
22+
require!(
23+
!uses_timestamp_ntz.0,
24+
Error::unsupported(
25+
"Table contains TIMESTAMP_NTZ columns but does not have the required 'timestampNtz' feature in reader and writer features"
26+
)
27+
);
28+
}
29+
Ok(())
30+
}
31+
32+
/// Schema visitor that checks if any column in the schema uses TIMESTAMP_NTZ type
33+
struct UsesTimestampNtz(bool);
34+
35+
impl<'a> SchemaTransform<'a> for UsesTimestampNtz {
36+
fn transform_primitive(&mut self, ptype: &'a PrimitiveType) -> Option<Cow<'a, PrimitiveType>> {
37+
if *ptype == PrimitiveType::TimestampNtz {
38+
self.0 = true;
39+
}
40+
None
41+
}
42+
}
43+
44+
#[cfg(test)]
45+
mod tests {
46+
use super::*;
47+
use crate::actions::Protocol;
48+
use crate::schema::{DataType, PrimitiveType, StructField, StructType};
49+
use crate::table_features::{ReaderFeature, WriterFeature};
50+
51+
#[test]
52+
fn test_timestamp_ntz_feature_validation() {
53+
let schema_with_timestamp_ntz = StructType::new([
54+
StructField::new("id", DataType::INTEGER, false),
55+
StructField::new("ts", DataType::Primitive(PrimitiveType::TimestampNtz), true),
56+
]);
57+
58+
let schema_without_timestamp_ntz = StructType::new([
59+
StructField::new("id", DataType::INTEGER, false),
60+
StructField::new("name", DataType::STRING, true),
61+
]);
62+
63+
// Protocol with TimestampWithoutTimezone features
64+
let protocol_with_features = Protocol::try_new(
65+
3,
66+
7,
67+
Some([ReaderFeature::TimestampWithoutTimezone]),
68+
Some([WriterFeature::TimestampWithoutTimezone]),
69+
)
70+
.unwrap();
71+
72+
// Protocol without TimestampWithoutTimezone features
73+
let protocol_without_features = Protocol::try_new(
74+
3,
75+
7,
76+
Some::<Vec<String>>(vec![]),
77+
Some::<Vec<String>>(vec![]),
78+
)
79+
.unwrap();
80+
81+
// Schema with TIMESTAMP_NTZ + Protocol with features = OK
82+
validate_timestamp_ntz_feature_support(&schema_with_timestamp_ntz, &protocol_with_features)
83+
.expect("Should succeed when features are present");
84+
85+
// Schema without TIMESTAMP_NTZ + Protocol without features = OK
86+
validate_timestamp_ntz_feature_support(
87+
&schema_without_timestamp_ntz,
88+
&protocol_without_features,
89+
)
90+
.expect("Should succeed when no TIMESTAMP_NTZ columns are present");
91+
92+
// Schema without TIMESTAMP_NTZ + Protocol with features = OK
93+
validate_timestamp_ntz_feature_support(
94+
&schema_without_timestamp_ntz,
95+
&protocol_with_features,
96+
)
97+
.expect("Should succeed when no TIMESTAMP_NTZ columns are present, even with features");
98+
99+
// Schema with TIMESTAMP_NTZ + Protocol without features = ERROR
100+
let result = validate_timestamp_ntz_feature_support(
101+
&schema_with_timestamp_ntz,
102+
&protocol_without_features,
103+
);
104+
assert!(
105+
result.is_err(),
106+
"Should fail when TIMESTAMP_NTZ columns are present but features are missing"
107+
);
108+
assert!(result.unwrap_err().to_string().contains("timestampNtz"));
109+
110+
// Nested schema with TIMESTAMP_NTZ
111+
let nested_schema_with_timestamp_ntz = StructType::new([
112+
StructField::new("id", DataType::INTEGER, false),
113+
StructField::new(
114+
"nested",
115+
DataType::Struct(Box::new(StructType::new([StructField::new(
116+
"inner_ts",
117+
DataType::Primitive(PrimitiveType::TimestampNtz),
118+
true,
119+
)]))),
120+
true,
121+
),
122+
]);
123+
124+
let result = validate_timestamp_ntz_feature_support(
125+
&nested_schema_with_timestamp_ntz,
126+
&protocol_without_features,
127+
);
128+
assert!(
129+
result.is_err(),
130+
"Should fail for nested TIMESTAMP_NTZ columns when features are missing"
131+
);
132+
}
133+
}

kernel/tests/write.rs

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::collections::HashMap;
22
use std::sync::Arc;
33

44
use delta_kernel::arrow::array::{
5-
Int32Array, MapBuilder, MapFieldNames, StringArray, StringBuilder,
5+
Int32Array, MapBuilder, MapFieldNames, StringArray, StringBuilder, TimestampMicrosecondArray,
66
};
77
use delta_kernel::arrow::datatypes::{DataType as ArrowDataType, Field, Schema as ArrowSchema};
88
use delta_kernel::arrow::error::ArrowError;
@@ -63,17 +63,28 @@ async fn create_table(
6363
schema: SchemaRef,
6464
partition_columns: &[&str],
6565
use_37_protocol: bool,
66+
enable_timestamp_without_timezone: bool,
6667
) -> Result<Table, Box<dyn std::error::Error>> {
6768
let table_id = "test_id";
6869
let schema = serde_json::to_string(&schema)?;
6970

71+
let (reader_features, writer_features) = {
72+
let mut reader_features = vec![];
73+
let mut writer_features = vec![];
74+
if enable_timestamp_without_timezone {
75+
reader_features.push("timestampNtz");
76+
writer_features.push("timestampNtz");
77+
}
78+
(reader_features, writer_features)
79+
};
80+
7081
let protocol = if use_37_protocol {
7182
json!({
7283
"protocol": {
7384
"minReaderVersion": 3,
7485
"minWriterVersion": 7,
75-
"readerFeatures": [],
76-
"writerFeatures": []
86+
"readerFeatures": reader_features,
87+
"writerFeatures": writer_features,
7788
}
7889
})
7990
} else {
@@ -175,6 +186,7 @@ async fn setup_tables(
175186
schema.clone(),
176187
partition_columns,
177188
true,
189+
false,
178190
)
179191
.await?,
180192
engine_37,
@@ -188,6 +200,7 @@ async fn setup_tables(
188200
schema,
189201
partition_columns,
190202
false,
203+
false,
191204
)
192205
.await?,
193206
engine_11,
@@ -837,3 +850,88 @@ async fn test_write_txn_actions() -> Result<(), Box<dyn std::error::Error>> {
837850
}
838851
Ok(())
839852
}
853+
854+
#[tokio::test]
855+
async fn test_append_timestamp_ntz() -> Result<(), Box<dyn std::error::Error>> {
856+
// setup tracing
857+
let _ = tracing_subscriber::fmt::try_init();
858+
859+
// create a table with TIMESTAMP_NTZ column
860+
let schema = Arc::new(StructType::new(vec![StructField::nullable(
861+
"ts_ntz",
862+
DataType::TIMESTAMP_NTZ,
863+
)]));
864+
865+
let (store, engine, table_location) = setup("test_table_timestamp_ntz", true);
866+
let table = create_table(
867+
store.clone(),
868+
table_location,
869+
schema.clone(),
870+
&[],
871+
true,
872+
true, // enable "timestamp without timezone" feature
873+
)
874+
.await?;
875+
876+
let commit_info = new_commit_info()?;
877+
878+
let mut txn = table
879+
.new_transaction(&engine)?
880+
.with_commit_info(commit_info);
881+
882+
// Create Arrow data with TIMESTAMP_NTZ values including edge cases
883+
// These are microseconds since Unix epoch
884+
let timestamp_values = vec![
885+
0i64, // Unix epoch (1970-01-01T00:00:00.000000)
886+
1634567890123456i64, // 2021-10-18T12:31:30.123456
887+
1634567950654321i64, // 2021-10-18T12:32:30.654321
888+
1672531200000000i64, // 2023-01-01T00:00:00.000000
889+
253402300799999999i64, // 9999-12-31T23:59:59.999999 (near max valid timestamp)
890+
-62135596800000000i64, // 0001-01-01T00:00:00.000000 (near min valid timestamp)
891+
];
892+
893+
let data = RecordBatch::try_new(
894+
Arc::new(schema.as_ref().try_into_arrow()?),
895+
vec![Arc::new(TimestampMicrosecondArray::from(timestamp_values))],
896+
)?;
897+
898+
// Write data
899+
let engine = Arc::new(engine);
900+
let write_context = Arc::new(txn.get_write_context());
901+
902+
let write_metadata = engine
903+
.write_parquet(
904+
&ArrowEngineData::new(data.clone()),
905+
write_context.as_ref(),
906+
HashMap::new(),
907+
true,
908+
)
909+
.await?;
910+
911+
txn.add_write_metadata(write_metadata);
912+
913+
// Commit the transaction
914+
txn.commit(engine.as_ref())?;
915+
916+
// Verify the commit was written correctly
917+
let commit1 = store
918+
.get(&Path::from(
919+
"/test_table_timestamp_ntz/_delta_log/00000000000000000001.json",
920+
))
921+
.await?;
922+
923+
let parsed_commits: Vec<_> = Deserializer::from_slice(&commit1.bytes().await?)
924+
.into_iter::<serde_json::Value>()
925+
.try_collect()?;
926+
927+
// Check that we have the expected number of commits (commitInfo + add)
928+
assert_eq!(parsed_commits.len(), 2);
929+
930+
// Check that the add action exists
931+
assert!(parsed_commits[1].get("add").is_some());
932+
933+
// Verify the data can be read back correctly
934+
test_read(&ArrowEngineData::new(data), &table, engine)?;
935+
936+
Ok(())
937+
}

0 commit comments

Comments
 (0)