mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-16 09:58:21 +00:00
db8f55cb97
* feat(table-catalog): finalize Iceberg REST behavior * fix(table-catalog): address REST finalization regressions * test(table-catalog): expect REST commit conflicts * test(table-catalog): avoid serialized view test deadlocks * fix(table-catalog): adapt shared test backend * fix(table-catalog): enforce Iceberg metadata invariants * fix(table-catalog): preserve manifest length in test * test(table-catalog): use valid metadata fixtures * test(table-catalog): seed manifests before manifest lists * fix(table-catalog): restore validation gates --------- Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com> Co-authored-by: overtrue <anzhengchao@gmail.com>
3158 lines
135 KiB
Rust
3158 lines
135 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed 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::io::Read;
|
|
|
|
use futures::{StreamExt, TryStreamExt, stream};
|
|
|
|
use super::super::*;
|
|
|
|
const ICEBERG_MAX_USER_FIELD_ID: i32 = i32::MAX - 200;
|
|
|
|
fn normalize_warehouse_object_prefix(object_prefix: &str, max_prefix_depth: Option<usize>) -> TableCatalogStoreResult<String> {
|
|
let object_prefix = object_prefix.strip_suffix('/').unwrap_or(object_prefix);
|
|
if object_prefix.is_empty() {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"table warehouse location must include an object prefix".to_string(),
|
|
));
|
|
}
|
|
if object_prefix.contains('\\') {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"table warehouse location contains an invalid path separator".to_string(),
|
|
));
|
|
}
|
|
let mut segment_count = 0;
|
|
for segment in object_prefix.split('/') {
|
|
segment_count += 1;
|
|
if segment.is_empty() || segment == "." || segment == ".." {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"table warehouse location contains an invalid path segment".to_string(),
|
|
));
|
|
}
|
|
}
|
|
if max_prefix_depth.is_some_and(|max_prefix_depth| segment_count > max_prefix_depth) {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"table warehouse location exceeds the maximum prefix depth".to_string(),
|
|
));
|
|
}
|
|
|
|
let mut normalized = object_prefix.to_string();
|
|
normalized.push('/');
|
|
Ok(normalized)
|
|
}
|
|
|
|
fn warehouse_object_prefix_from_location(
|
|
table_bucket: &str,
|
|
warehouse_location: &str,
|
|
max_prefix_depth: Option<usize>,
|
|
) -> TableCatalogStoreResult<String> {
|
|
let location = warehouse_location
|
|
.strip_prefix("s3://")
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("table warehouse location must be an s3 URI".to_string()))?;
|
|
let (bucket, object_prefix) = location
|
|
.split_once('/')
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("table warehouse location must include an object prefix".to_string()))?;
|
|
if bucket != table_bucket {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"table warehouse location must be inside the table bucket".to_string(),
|
|
));
|
|
}
|
|
normalize_warehouse_object_prefix(object_prefix, max_prefix_depth)
|
|
}
|
|
|
|
fn table_warehouse_object_prefix_from_location(table_bucket: &str, warehouse_location: &str) -> TableCatalogStoreResult<String> {
|
|
warehouse_object_prefix_from_location(table_bucket, warehouse_location, Some(WAREHOUSE_INDEX_MAX_PREFIX_DEPTH))
|
|
}
|
|
|
|
fn view_warehouse_object_prefix_from_location(table_bucket: &str, warehouse_location: &str) -> TableCatalogStoreResult<String> {
|
|
warehouse_object_prefix_from_location(table_bucket, warehouse_location, None)
|
|
}
|
|
|
|
pub(crate) fn validate_table_warehouse_location(table_bucket: &str, warehouse_location: &str) -> TableCatalogStoreResult<()> {
|
|
table_warehouse_object_prefix_from_location(table_bucket, warehouse_location).map(|_| ())
|
|
}
|
|
|
|
pub(crate) fn validate_view_warehouse_location(table_bucket: &str, warehouse_location: &str) -> TableCatalogStoreResult<()> {
|
|
view_warehouse_object_prefix_from_location(table_bucket, warehouse_location).map(|_| ())
|
|
}
|
|
|
|
pub(crate) fn table_warehouse_object_prefix(entry: &TableEntry) -> TableCatalogStoreResult<String> {
|
|
table_warehouse_object_prefix_from_location(&entry.table_bucket, &entry.warehouse_location)
|
|
}
|
|
|
|
pub(crate) fn table_warehouse_index_entry(entry: &TableEntry) -> TableCatalogStoreResult<TableWarehouseIndexEntry> {
|
|
Ok(TableWarehouseIndexEntry {
|
|
version: TABLE_CATALOG_ENTRY_VERSION,
|
|
table_bucket: entry.table_bucket.clone(),
|
|
namespace: entry.namespace.clone(),
|
|
table: entry.table.clone(),
|
|
table_id: entry.table_id.clone(),
|
|
warehouse_object_prefix: table_warehouse_object_prefix(entry)?,
|
|
state: entry.state.clone(),
|
|
})
|
|
}
|
|
|
|
fn table_warehouse_data_dir_path(entry: &TableEntry) -> TableCatalogStoreResult<String> {
|
|
Ok(format!("{}{}", table_warehouse_object_prefix(entry)?, DATA_DIR))
|
|
}
|
|
|
|
pub(crate) fn table_object_s3_location(table_bucket: &str, object_key: &str) -> String {
|
|
format!("s3://{table_bucket}/{object_key}")
|
|
}
|
|
|
|
pub(crate) struct TableMetadataCommitState {
|
|
pub(crate) warehouse_location: Option<String>,
|
|
pub(crate) format_version: Option<u16>,
|
|
}
|
|
|
|
pub(crate) fn table_metadata_commit_state(
|
|
table_bucket: &str,
|
|
metadata_location: &str,
|
|
metadata_object: &TableCatalogObject,
|
|
) -> TableCatalogStoreResult<TableMetadataCommitState> {
|
|
let metadata = decode_table_metadata_json(metadata_location, &metadata_object.data)?;
|
|
let warehouse_location = metadata
|
|
.get("location")
|
|
.and_then(serde_json::Value::as_str)
|
|
.map(|location| {
|
|
validate_table_warehouse_location(table_bucket, location)?;
|
|
Ok(location.to_string())
|
|
})
|
|
.transpose()?;
|
|
let format_version = metadata
|
|
.get("format-version")
|
|
.map(|_| table_metadata_format_version(&metadata))
|
|
.transpose()?;
|
|
Ok(TableMetadataCommitState {
|
|
warehouse_location,
|
|
format_version,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn decode_table_metadata_json(metadata_location: &str, data: &[u8]) -> TableCatalogStoreResult<serde_json::Value> {
|
|
if data.len() > TABLE_METADATA_JSON_MAX_SIZE {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"table metadata {metadata_location} exceeds the maximum encoded size of {TABLE_METADATA_JSON_MAX_SIZE} bytes"
|
|
)));
|
|
}
|
|
|
|
if !table_metadata_location_is_gzip(metadata_location) {
|
|
return parse_table_metadata_json(metadata_location, data);
|
|
}
|
|
|
|
let decoded_limit = TABLE_METADATA_JSON_MAX_SIZE
|
|
.checked_add(1)
|
|
.ok_or_else(|| TableCatalogStoreError::Internal("table metadata size limit overflowed".to_string()))?;
|
|
let decoded_limit = u64::try_from(decoded_limit)
|
|
.map_err(|_| TableCatalogStoreError::Internal("table metadata size limit is invalid".to_string()))?;
|
|
let mut decoder = flate2::read::GzDecoder::new(data).take(decoded_limit);
|
|
let mut decoded = Vec::new();
|
|
decoder.read_to_end(&mut decoded).map_err(|err| {
|
|
TableCatalogStoreError::Invalid(format!("failed to decompress table metadata {metadata_location}: {err}"))
|
|
})?;
|
|
if decoded.len() > TABLE_METADATA_JSON_MAX_SIZE {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"table metadata {metadata_location} exceeds the maximum decoded size of {TABLE_METADATA_JSON_MAX_SIZE} bytes"
|
|
)));
|
|
}
|
|
parse_table_metadata_json(metadata_location, &decoded)
|
|
}
|
|
|
|
fn parse_table_metadata_json(metadata_location: &str, data: &[u8]) -> TableCatalogStoreResult<serde_json::Value> {
|
|
let metadata = serde_json::from_slice::<serde_json::Value>(data)
|
|
.map_err(|err| TableCatalogStoreError::Invalid(format!("failed to parse table metadata {metadata_location}: {err}")))?;
|
|
if !metadata.is_object() {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"table metadata {metadata_location} must be a JSON object"
|
|
)));
|
|
}
|
|
Ok(metadata)
|
|
}
|
|
|
|
fn table_metadata_location_is_gzip(metadata_location: &str) -> bool {
|
|
metadata_location.ends_with(".gz.metadata.json") || metadata_location.ends_with(".metadata.json.gz")
|
|
}
|
|
|
|
pub(crate) fn canonical_json_sha256(metadata: &serde_json::Value) -> TableCatalogStoreResult<String> {
|
|
let canonical = serde_json::to_vec(metadata)
|
|
.map_err(|err| TableCatalogStoreError::Internal(format!("failed to encode metadata digest input: {err}")))?;
|
|
Ok(hex_simd::encode_to_string(Sha256::digest(canonical), hex_simd::AsciiCase::Lower))
|
|
}
|
|
|
|
pub(crate) fn validate_commit_metadata_digest(
|
|
request: &TableCommitRequest,
|
|
metadata_object: &TableCatalogObject,
|
|
) -> TableCatalogStoreResult<()> {
|
|
let mut expected_digest = None;
|
|
for requirement in &request.requirements {
|
|
if requirement.get("type").and_then(serde_json::Value::as_str) != Some(TABLE_METADATA_DIGEST_REQUIREMENT_TYPE) {
|
|
continue;
|
|
}
|
|
if expected_digest.is_some() {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"commit contains duplicate metadata digest requirements".to_string(),
|
|
));
|
|
}
|
|
expected_digest = Some(
|
|
requirement
|
|
.get("sha256")
|
|
.and_then(serde_json::Value::as_str)
|
|
.filter(|digest| rustfs_utils::crypto::is_sha256_checksum(digest))
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("commit metadata digest is invalid".to_string()))?,
|
|
);
|
|
}
|
|
let Some(expected_digest) = expected_digest else {
|
|
return Ok(());
|
|
};
|
|
let metadata = decode_table_metadata_json(&request.new_metadata_location, &metadata_object.data)?;
|
|
if canonical_json_sha256(&metadata)? != expected_digest {
|
|
return Err(TableCatalogStoreError::Conflict(
|
|
"new metadata object changed after commit validation".to_string(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn view_metadata_warehouse_location(
|
|
table_bucket: &str,
|
|
metadata_location: &str,
|
|
metadata_object: &TableCatalogObject,
|
|
) -> TableCatalogStoreResult<Option<String>> {
|
|
let metadata = decode_table_metadata_json(metadata_location, &metadata_object.data)?;
|
|
let Some(location) = metadata.get("location").and_then(serde_json::Value::as_str) else {
|
|
return Ok(None);
|
|
};
|
|
validate_view_warehouse_location(table_bucket, location)?;
|
|
Ok(Some(location.to_string()))
|
|
}
|
|
|
|
pub(crate) fn warehouse_index_candidate_prefixes(object: &str) -> Vec<&str> {
|
|
let mut prefixes = Vec::new();
|
|
for (index, byte) in object.as_bytes().iter().enumerate() {
|
|
if *byte == b'/' {
|
|
prefixes.push(&object[..=index]);
|
|
if prefixes.len() >= WAREHOUSE_INDEX_MAX_PREFIX_DEPTH {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
prefixes.reverse();
|
|
prefixes
|
|
}
|
|
|
|
pub(crate) fn warehouse_object_prefixes_overlap(left: &str, right: &str) -> bool {
|
|
left.starts_with(right) || right.starts_with(left)
|
|
}
|
|
|
|
pub(crate) fn table_data_plane_resource_from_entry(table: TableEntry, warehouse_object_prefix: String) -> TableDataPlaneResource {
|
|
TableDataPlaneResource {
|
|
table_bucket: table.table_bucket,
|
|
namespace: table.namespace,
|
|
table: table.table,
|
|
table_id: table.table_id,
|
|
warehouse_object_prefix,
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn table_data_plane_resource_for_object<S>(
|
|
store: &S,
|
|
bucket: &str,
|
|
object: &str,
|
|
) -> TableCatalogStoreResult<Option<TableDataPlaneResource>>
|
|
where
|
|
S: TableCatalogStore + ?Sized,
|
|
{
|
|
store.resolve_table_data_plane_resource(bucket, object).await
|
|
}
|
|
|
|
pub(crate) async fn scan_table_data_plane_resource_for_object<S>(
|
|
store: &S,
|
|
bucket: &str,
|
|
object: &str,
|
|
) -> TableCatalogStoreResult<Option<TableDataPlaneResource>>
|
|
where
|
|
S: TableCatalogStore + ?Sized,
|
|
{
|
|
if bucket.is_empty() || object.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let Some(table_bucket) = store.get_table_bucket(bucket).await? else {
|
|
return Ok(None);
|
|
};
|
|
if table_bucket.state != TableCatalogEntryState::Active {
|
|
return Ok(None);
|
|
}
|
|
|
|
let mut matched: Option<TableDataPlaneResource> = None;
|
|
for table in store.list_all_tables(bucket).await? {
|
|
if table.state != TableCatalogEntryState::Active {
|
|
continue;
|
|
}
|
|
let warehouse_object_prefix = table_warehouse_object_prefix(&table)?;
|
|
if !object.starts_with(&warehouse_object_prefix) {
|
|
continue;
|
|
}
|
|
if let Some(current) = matched.as_ref() {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"object {object} matches overlapping active table warehouse prefixes {} and {warehouse_object_prefix}",
|
|
current.warehouse_object_prefix
|
|
)));
|
|
}
|
|
matched = Some(table_data_plane_resource_from_entry(table, warehouse_object_prefix));
|
|
}
|
|
|
|
Ok(matched)
|
|
}
|
|
|
|
pub(crate) fn table_metadata_uuid(metadata: &serde_json::Value) -> TableCatalogStoreResult<&str> {
|
|
metadata
|
|
.get("table-uuid")
|
|
.and_then(serde_json::Value::as_str)
|
|
.filter(|uuid| !uuid.is_empty())
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("table metadata is missing table-uuid".to_string()))
|
|
}
|
|
|
|
pub(crate) fn table_metadata_location(metadata: &serde_json::Value) -> TableCatalogStoreResult<&str> {
|
|
metadata
|
|
.get("location")
|
|
.and_then(serde_json::Value::as_str)
|
|
.filter(|location| !location.is_empty())
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("table metadata is missing location".to_string()))
|
|
}
|
|
|
|
pub(crate) fn table_metadata_format_version(metadata: &serde_json::Value) -> TableCatalogStoreResult<u16> {
|
|
let version = metadata
|
|
.get("format-version")
|
|
.and_then(serde_json::Value::as_u64)
|
|
.filter(|version| *version > 0)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("table metadata is missing format-version".to_string()))?;
|
|
let version = u16::try_from(version)
|
|
.map_err(|_| TableCatalogStoreError::Invalid("table metadata format-version is too large".to_string()))?;
|
|
if !(1..=2).contains(&version) {
|
|
return Err(TableCatalogStoreError::Unsupported(format!(
|
|
"unsupported Iceberg table format-version: {version}"
|
|
)));
|
|
}
|
|
Ok(version)
|
|
}
|
|
|
|
fn normalize_v1_table_metadata_update_fields(metadata: &mut serde_json::Value) -> TableCatalogStoreResult<()> {
|
|
if metadata.get("schemas").is_none() {
|
|
let mut schema = metadata
|
|
.get("schema")
|
|
.cloned()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 table metadata is missing schema".to_string()))?;
|
|
schema
|
|
.as_object_mut()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 schema must be an object".to_string()))?
|
|
.entry("schema-id".to_string())
|
|
.or_insert_with(|| serde_json::Value::from(0));
|
|
let schema_id = schema
|
|
.get("schema-id")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 schema-id must be an integer".to_string()))?;
|
|
let object = metadata_object_mut(metadata)?;
|
|
object.insert("schemas".to_string(), serde_json::json!([schema]));
|
|
object.insert("current-schema-id".to_string(), serde_json::Value::from(schema_id));
|
|
} else if metadata.get("current-schema-id").is_none() {
|
|
let schema_id = metadata
|
|
.get("schema")
|
|
.and_then(|schema| schema.get("schema-id"))
|
|
.and_then(serde_json::Value::as_i64)
|
|
.unwrap_or(0);
|
|
if !require_metadata_array(metadata, "schemas")?
|
|
.iter()
|
|
.any(|schema| schema.get("schema-id").and_then(serde_json::Value::as_i64) == Some(schema_id))
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid("Iceberg v1 current schema does not exist".to_string()));
|
|
}
|
|
metadata_object_mut(metadata)?.insert("current-schema-id".to_string(), serde_json::Value::from(schema_id));
|
|
}
|
|
|
|
if metadata.get("partition-specs").is_none() {
|
|
let mut fields = metadata
|
|
.get("partition-spec")
|
|
.cloned()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 table metadata is missing partition-spec".to_string()))?;
|
|
let fields = fields
|
|
.as_array_mut()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 partition-spec must be an array".to_string()))?;
|
|
for (index, field) in fields.iter_mut().enumerate() {
|
|
let field_id = i32::try_from(index)
|
|
.ok()
|
|
.and_then(|index| 1000_i32.checked_add(index))
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 partition spec has too many fields".to_string()))?;
|
|
field
|
|
.as_object_mut()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 partition spec fields must be objects".to_string()))?
|
|
.entry("field-id".to_string())
|
|
.or_insert_with(|| serde_json::Value::from(field_id));
|
|
}
|
|
let object = metadata_object_mut(metadata)?;
|
|
object.insert("partition-specs".to_string(), serde_json::json!([{"spec-id": 0, "fields": fields}]));
|
|
object.insert("default-spec-id".to_string(), serde_json::Value::from(0));
|
|
} else if metadata.get("default-spec-id").is_none() {
|
|
let default_spec_id = require_metadata_array(metadata, "partition-specs")?
|
|
.last()
|
|
.and_then(|spec| spec.get("spec-id"))
|
|
.and_then(serde_json::Value::as_i64)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 default partition spec does not exist".to_string()))?;
|
|
metadata_object_mut(metadata)?.insert("default-spec-id".to_string(), serde_json::Value::from(default_spec_id));
|
|
}
|
|
|
|
if metadata.get("sort-orders").is_none() {
|
|
let object = metadata_object_mut(metadata)?;
|
|
object.insert("sort-orders".to_string(), serde_json::json!([{"order-id": 0, "fields": []}]));
|
|
object.insert("default-sort-order-id".to_string(), serde_json::Value::from(0));
|
|
} else if metadata.get("default-sort-order-id").is_none() {
|
|
let default_sort_order_id = require_metadata_array(metadata, "sort-orders")?
|
|
.last()
|
|
.and_then(|order| order.get("order-id"))
|
|
.and_then(serde_json::Value::as_i64)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 default sort order does not exist".to_string()))?;
|
|
metadata_object_mut(metadata)?
|
|
.insert("default-sort-order-id".to_string(), serde_json::Value::from(default_sort_order_id));
|
|
}
|
|
|
|
if metadata.get("last-partition-id").is_none() {
|
|
let last_partition_id = require_metadata_array(metadata, "partition-specs")?
|
|
.iter()
|
|
.map(max_partition_field_id)
|
|
.max()
|
|
.unwrap_or(999);
|
|
metadata_object_mut(metadata)?.insert("last-partition-id".to_string(), serde_json::Value::from(last_partition_id));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn synchronize_table_metadata_version_fields(metadata: &mut serde_json::Value) -> TableCatalogStoreResult<()> {
|
|
match table_metadata_format_version(metadata)? {
|
|
1 => {
|
|
normalize_v1_table_metadata_update_fields(metadata)?;
|
|
if let Some(schemas) = metadata.get("schemas").and_then(serde_json::Value::as_array)
|
|
&& !schemas.is_empty()
|
|
{
|
|
let current_schema_id = metadata
|
|
.get("current-schema-id")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.or_else(|| {
|
|
schemas
|
|
.last()
|
|
.and_then(|schema| schema.get("schema-id"))
|
|
.and_then(serde_json::Value::as_i64)
|
|
});
|
|
let schema = current_schema_id
|
|
.and_then(|schema_id| {
|
|
schemas
|
|
.iter()
|
|
.find(|schema| schema.get("schema-id").and_then(serde_json::Value::as_i64) == Some(schema_id))
|
|
})
|
|
.cloned()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 current schema does not exist".to_string()))?;
|
|
metadata_object_mut(metadata)?.insert("schema".to_string(), schema);
|
|
}
|
|
if let Some(specs) = metadata.get("partition-specs").and_then(serde_json::Value::as_array)
|
|
&& !specs.is_empty()
|
|
{
|
|
let default_spec_id = metadata
|
|
.get("default-spec-id")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.or_else(|| {
|
|
specs
|
|
.last()
|
|
.and_then(|spec| spec.get("spec-id"))
|
|
.and_then(serde_json::Value::as_i64)
|
|
});
|
|
let fields = default_spec_id
|
|
.and_then(|spec_id| {
|
|
specs
|
|
.iter()
|
|
.find(|spec| spec.get("spec-id").and_then(serde_json::Value::as_i64) == Some(spec_id))
|
|
})
|
|
.and_then(|spec| spec.get("fields"))
|
|
.cloned()
|
|
.ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid("Iceberg v1 default partition spec does not exist".to_string())
|
|
})?;
|
|
metadata_object_mut(metadata)?.insert("partition-spec".to_string(), fields);
|
|
}
|
|
}
|
|
2 => {
|
|
if metadata.get("schemas").is_none() {
|
|
let mut schema = metadata
|
|
.get("schema")
|
|
.cloned()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 table metadata is missing schema".to_string()))?;
|
|
schema
|
|
.as_object_mut()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 schema must be an object".to_string()))?
|
|
.entry("schema-id".to_string())
|
|
.or_insert_with(|| serde_json::Value::from(0));
|
|
let schema_id = schema
|
|
.get("schema-id")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 schema-id must be an integer".to_string()))?;
|
|
metadata_object_mut(metadata)?.insert("schemas".to_string(), serde_json::json!([schema]));
|
|
metadata_object_mut(metadata)?.insert("current-schema-id".to_string(), serde_json::Value::from(schema_id));
|
|
}
|
|
if metadata.get("partition-specs").is_none() {
|
|
let mut fields = metadata.get("partition-spec").cloned().ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid("Iceberg v1 table metadata is missing partition-spec".to_string())
|
|
})?;
|
|
let fields = fields
|
|
.as_array_mut()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 partition-spec must be an array".to_string()))?;
|
|
for (index, field) in fields.iter_mut().enumerate() {
|
|
let field_id = i32::try_from(index)
|
|
.ok()
|
|
.and_then(|index| 1000_i32.checked_add(index))
|
|
.ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid("Iceberg v1 partition spec has too many fields".to_string())
|
|
})?;
|
|
field
|
|
.as_object_mut()
|
|
.ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid("Iceberg v1 partition spec fields must be objects".to_string())
|
|
})?
|
|
.entry("field-id".to_string())
|
|
.or_insert_with(|| serde_json::Value::from(field_id));
|
|
}
|
|
metadata_object_mut(metadata)?
|
|
.insert("partition-specs".to_string(), serde_json::json!([{"spec-id": 0, "fields": fields}]));
|
|
metadata_object_mut(metadata)?.insert("default-spec-id".to_string(), serde_json::Value::from(0));
|
|
}
|
|
if metadata.get("sort-orders").is_none() {
|
|
metadata_object_mut(metadata)?
|
|
.insert("sort-orders".to_string(), serde_json::json!([{"order-id": 0, "fields": []}]));
|
|
metadata_object_mut(metadata)?.insert("default-sort-order-id".to_string(), serde_json::Value::from(0));
|
|
}
|
|
if metadata.get("last-partition-id").is_none() {
|
|
let last_partition_id = metadata
|
|
.get("partition-specs")
|
|
.and_then(serde_json::Value::as_array)
|
|
.map(|specs| specs.iter().map(max_partition_field_id).max().unwrap_or(999))
|
|
.unwrap_or(999);
|
|
metadata_object_mut(metadata)?
|
|
.insert("last-partition-id".to_string(), serde_json::Value::from(last_partition_id));
|
|
}
|
|
if metadata.get("last-sequence-number").is_none() {
|
|
let last_sequence_number = metadata
|
|
.get("snapshots")
|
|
.and_then(serde_json::Value::as_array)
|
|
.and_then(|snapshots| {
|
|
snapshots
|
|
.iter()
|
|
.filter_map(|snapshot| snapshot.get("sequence-number").and_then(serde_json::Value::as_i64))
|
|
.max()
|
|
})
|
|
.unwrap_or(0);
|
|
metadata_object_mut(metadata)?
|
|
.insert("last-sequence-number".to_string(), serde_json::Value::from(last_sequence_number));
|
|
}
|
|
let object = metadata_object_mut(metadata)?;
|
|
object.remove("schema");
|
|
object.remove("partition-spec");
|
|
}
|
|
version => {
|
|
return Err(TableCatalogStoreError::Internal(format!(
|
|
"validated Iceberg format version {version} is unsupported"
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_table_history_logs(metadata: &serde_json::Value) -> TableCatalogStoreResult<()> {
|
|
for field in ["snapshot-log", "metadata-log"] {
|
|
let Some(entries) = metadata.get(field) else {
|
|
continue;
|
|
};
|
|
let entries = entries
|
|
.as_array()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{field} must be an array")))?;
|
|
for entry in entries {
|
|
let entry = entry
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{field} entries must be JSON objects")))?;
|
|
if entry.get("timestamp-ms").and_then(serde_json::Value::as_i64).is_none() {
|
|
return Err(TableCatalogStoreError::Invalid(format!("{field} entries require integer timestamp-ms")));
|
|
}
|
|
match field {
|
|
"snapshot-log" if entry.get("snapshot-id").and_then(serde_json::Value::as_i64).is_none() => {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"snapshot-log entries require integer snapshot-id".to_string(),
|
|
));
|
|
}
|
|
"metadata-log"
|
|
if !entry
|
|
.get("metadata-file")
|
|
.and_then(serde_json::Value::as_str)
|
|
.is_some_and(|location| !location.is_empty()) =>
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"metadata-log entries require non-empty metadata-file".to_string(),
|
|
));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn validate_supported_table_metadata(metadata: &serde_json::Value) -> TableCatalogStoreResult<()> {
|
|
validate_supported_table_metadata_fields(metadata)?;
|
|
validate_table_history_logs(metadata)?;
|
|
validate_table_metadata_references(metadata)
|
|
}
|
|
|
|
pub(crate) fn validate_table_metadata_transition(
|
|
current_metadata: &serde_json::Value,
|
|
target_metadata: &serde_json::Value,
|
|
) -> TableCatalogStoreResult<()> {
|
|
let current_last_column_id = require_metadata_i32(current_metadata, "last-column-id")?;
|
|
let target_last_column_id = require_metadata_i32(target_metadata, "last-column-id")?;
|
|
if target_last_column_id < current_last_column_id {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"last-column-id must not decrease across table metadata commits".to_string(),
|
|
));
|
|
}
|
|
|
|
let current_format_version = table_metadata_format_version(current_metadata)?;
|
|
let target_format_version = table_metadata_format_version(target_metadata)?;
|
|
let current_last_partition_id = table_metadata_last_partition_id(current_metadata, current_format_version)?;
|
|
let target_last_partition_id = table_metadata_last_partition_id(target_metadata, target_format_version)?;
|
|
if target_last_partition_id < current_last_partition_id {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"last-partition-id must not decrease across table metadata commits".to_string(),
|
|
));
|
|
}
|
|
let current_last_sequence_number = table_metadata_last_sequence_number(current_metadata, current_format_version)?;
|
|
let target_last_sequence_number = table_metadata_last_sequence_number(target_metadata, target_format_version)?;
|
|
if target_last_sequence_number < current_last_sequence_number {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"last-sequence-number must not decrease across table metadata commits".to_string(),
|
|
));
|
|
}
|
|
|
|
validate_existing_partition_specs_unchanged(current_metadata, target_metadata)?;
|
|
validate_existing_metadata_entries_unchanged(
|
|
&normalized_sort_order_definitions(current_metadata, current_format_version)?,
|
|
&normalized_sort_order_definitions(target_metadata, target_format_version)?,
|
|
"sort order",
|
|
)?;
|
|
validate_existing_snapshots_unchanged(current_metadata, target_metadata, current_format_version, target_format_version)?;
|
|
|
|
let current_schemas = table_metadata_schemas_by_id(current_metadata, current_format_version)?;
|
|
let target_schemas = table_metadata_schemas_by_id(target_metadata, target_format_version)?;
|
|
for (schema_id, current_schema) in ¤t_schemas {
|
|
if let Some(target_schema) = target_schemas.get(schema_id)
|
|
&& normalized_schema_definition(current_schema, *schema_id)?
|
|
!= normalized_schema_definition(target_schema, *schema_id)?
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"existing schema {schema_id} must not be modified"
|
|
)));
|
|
}
|
|
}
|
|
|
|
let current_schema_id = table_metadata_current_schema_id(current_metadata, current_format_version)?;
|
|
let current_schema = current_schemas
|
|
.get(¤t_schema_id)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("current table metadata schema does not exist".to_string()))?;
|
|
let current_fields = validate_iceberg_schema_fields(current_schema, "current schema")?;
|
|
for (target_schema_id, target_schema) in &target_schemas {
|
|
if current_schemas.contains_key(target_schema_id) {
|
|
continue;
|
|
}
|
|
let target_fields = validate_iceberg_schema_fields(target_schema, "target schema")?;
|
|
for (field_id, target_field) in &target_fields.descriptors {
|
|
match current_fields.descriptors.get(field_id) {
|
|
Some(current_field) => validate_schema_field_evolution(*field_id, current_field, target_field)?,
|
|
None if *field_id <= current_last_column_id => {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"schema field {field_id} cannot reuse a previously assigned field id"
|
|
)));
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn table_metadata_last_sequence_number(metadata: &serde_json::Value, format_version: u16) -> TableCatalogStoreResult<i64> {
|
|
if format_version == 1 {
|
|
return Ok(0);
|
|
}
|
|
require_metadata_i64(metadata, "last-sequence-number")
|
|
}
|
|
|
|
fn normalized_sort_order_definitions(
|
|
metadata: &serde_json::Value,
|
|
format_version: u16,
|
|
) -> TableCatalogStoreResult<BTreeMap<i64, serde_json::Value>> {
|
|
let Some(sort_orders) = metadata.get("sort-orders") else {
|
|
return if format_version == 1 {
|
|
Ok(BTreeMap::from([(0, serde_json::json!({"order-id": 0, "fields": []}))]))
|
|
} else {
|
|
Err(TableCatalogStoreError::Invalid("sort-orders must be an array".to_string()))
|
|
};
|
|
};
|
|
metadata_entries_by_id(sort_orders, "order-id", "sort order")
|
|
}
|
|
|
|
fn validate_existing_snapshots_unchanged(
|
|
current_metadata: &serde_json::Value,
|
|
target_metadata: &serde_json::Value,
|
|
current_format_version: u16,
|
|
target_format_version: u16,
|
|
) -> TableCatalogStoreResult<()> {
|
|
let current = current_metadata
|
|
.get("snapshots")
|
|
.map(|snapshots| metadata_entries_by_id(snapshots, "snapshot-id", "snapshot"))
|
|
.transpose()?
|
|
.unwrap_or_default();
|
|
let target = target_metadata
|
|
.get("snapshots")
|
|
.map(|snapshots| metadata_entries_by_id(snapshots, "snapshot-id", "snapshot"))
|
|
.transpose()?
|
|
.unwrap_or_default();
|
|
for (snapshot_id, current_snapshot) in current {
|
|
let Some(target_snapshot) = target.get(&snapshot_id) else {
|
|
continue;
|
|
};
|
|
let mut current_snapshot = current_snapshot;
|
|
let mut target_snapshot = target_snapshot.clone();
|
|
if current_format_version == 1 && target_format_version == 2 {
|
|
for snapshot in [&mut current_snapshot, &mut target_snapshot] {
|
|
if snapshot.get("sequence-number").and_then(serde_json::Value::as_i64) == Some(0)
|
|
&& let Some(object) = snapshot.as_object_mut()
|
|
{
|
|
object.remove("sequence-number");
|
|
}
|
|
}
|
|
}
|
|
if current_snapshot != target_snapshot {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"existing snapshot {snapshot_id} must not be modified"
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_existing_metadata_entries_unchanged(
|
|
current: &BTreeMap<i64, serde_json::Value>,
|
|
target: &BTreeMap<i64, serde_json::Value>,
|
|
label: &str,
|
|
) -> TableCatalogStoreResult<()> {
|
|
for (id, current_value) in current {
|
|
if let Some(target_value) = target.get(id)
|
|
&& target_value != current_value
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(format!("existing {label} {id} must not be modified")));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_existing_partition_specs_unchanged(
|
|
current_metadata: &serde_json::Value,
|
|
target_metadata: &serde_json::Value,
|
|
) -> TableCatalogStoreResult<()> {
|
|
let current = normalized_partition_spec_definitions(current_metadata)?;
|
|
let target = normalized_partition_spec_definitions(target_metadata)?;
|
|
for (spec_id, current_fields) in current {
|
|
if let Some(target_fields) = target.get(&spec_id)
|
|
&& target_fields != ¤t_fields
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"existing partition spec {spec_id} must not be modified"
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn metadata_entries_by_id(
|
|
value: &serde_json::Value,
|
|
id_field: &str,
|
|
label: &str,
|
|
) -> TableCatalogStoreResult<BTreeMap<i64, serde_json::Value>> {
|
|
if value.is_null() {
|
|
return Ok(BTreeMap::new());
|
|
}
|
|
let values = value
|
|
.as_array()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label}s must be an array")))?;
|
|
let mut entries = BTreeMap::new();
|
|
for value in values {
|
|
let id = value
|
|
.get(id_field)
|
|
.and_then(serde_json::Value::as_i64)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label} is missing {id_field}")))?;
|
|
if entries.insert(id, value.clone()).is_some() {
|
|
return Err(TableCatalogStoreError::Invalid(format!("duplicate {label} id {id}")));
|
|
}
|
|
}
|
|
Ok(entries)
|
|
}
|
|
|
|
fn normalized_schema_definition(schema: &serde_json::Value, schema_id: i64) -> TableCatalogStoreResult<serde_json::Value> {
|
|
let mut schema = schema.clone();
|
|
schema
|
|
.as_object_mut()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("schema must be a JSON object".to_string()))?
|
|
.entry("schema-id".to_string())
|
|
.or_insert_with(|| serde_json::Value::from(schema_id));
|
|
Ok(schema)
|
|
}
|
|
|
|
fn table_metadata_last_partition_id(metadata: &serde_json::Value, format_version: u16) -> TableCatalogStoreResult<i32> {
|
|
if format_version != 1 {
|
|
return require_metadata_i32(metadata, "last-partition-id");
|
|
}
|
|
let field_count = require_metadata_array(metadata, "partition-spec")?.len();
|
|
i32::try_from(field_count)
|
|
.ok()
|
|
.and_then(|field_count| 999_i32.checked_add(field_count))
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 partition spec has too many fields".to_string()))
|
|
}
|
|
|
|
fn table_metadata_current_schema_id(metadata: &serde_json::Value, format_version: u16) -> TableCatalogStoreResult<i64> {
|
|
if format_version == 1 {
|
|
return Ok(metadata
|
|
.get("schema")
|
|
.and_then(|schema| schema.get("schema-id"))
|
|
.and_then(serde_json::Value::as_i64)
|
|
.unwrap_or(0));
|
|
}
|
|
metadata
|
|
.get("current-schema-id")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("current-schema-id must be an integer".to_string()))
|
|
}
|
|
|
|
fn table_metadata_schemas_by_id(
|
|
metadata: &serde_json::Value,
|
|
format_version: u16,
|
|
) -> TableCatalogStoreResult<BTreeMap<i64, &serde_json::Value>> {
|
|
let mut schemas = BTreeMap::new();
|
|
if let Some(values) = metadata.get("schemas") {
|
|
for schema in values
|
|
.as_array()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("schemas must be an array".to_string()))?
|
|
{
|
|
let schema_id = schema
|
|
.get("schema-id")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("schema-id must be an integer".to_string()))?;
|
|
if schemas.insert(schema_id, schema).is_some() {
|
|
return Err(TableCatalogStoreError::Invalid(format!("duplicate schema id {schema_id}")));
|
|
}
|
|
}
|
|
}
|
|
if format_version == 1 {
|
|
let schema = metadata
|
|
.get("schema")
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 table metadata is missing schema".to_string()))?;
|
|
let schema_id = schema.get("schema-id").and_then(serde_json::Value::as_i64).unwrap_or(0);
|
|
if let Some(known_schema) = schemas.get(&schema_id) {
|
|
let mut normalized_schema = schema.clone();
|
|
normalized_schema
|
|
.as_object_mut()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 schema must be an object".to_string()))?
|
|
.entry("schema-id".to_string())
|
|
.or_insert_with(|| serde_json::Value::from(schema_id));
|
|
if *known_schema != &normalized_schema {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"Iceberg v1 current schema {schema_id} does not match schemas"
|
|
)));
|
|
}
|
|
} else {
|
|
schemas.insert(schema_id, schema);
|
|
}
|
|
}
|
|
Ok(schemas)
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum IcebergStatisticsFileKind {
|
|
Table,
|
|
Partition,
|
|
}
|
|
|
|
pub(crate) fn validate_iceberg_statistics_file(
|
|
value: &serde_json::Value,
|
|
label: &str,
|
|
kind: IcebergStatisticsFileKind,
|
|
) -> TableCatalogStoreResult<i64> {
|
|
let object = value
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label} must be a JSON object")))?;
|
|
let snapshot_id = object
|
|
.get("snapshot-id")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label}.snapshot-id must be an integer")))?;
|
|
if !object
|
|
.get("statistics-path")
|
|
.and_then(serde_json::Value::as_str)
|
|
.is_some_and(|path| !path.is_empty())
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"{label}.statistics-path must be a non-empty string"
|
|
)));
|
|
}
|
|
let file_size = statistics_non_negative_i64(object, "file-size-in-bytes", label)?;
|
|
if matches!(kind, IcebergStatisticsFileKind::Table) {
|
|
let footer_size = statistics_non_negative_i64(object, "file-footer-size-in-bytes", label)?;
|
|
if footer_size > file_size {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"{label}.file-footer-size-in-bytes must not exceed file-size-in-bytes"
|
|
)));
|
|
}
|
|
let blobs = object
|
|
.get("blob-metadata")
|
|
.and_then(serde_json::Value::as_array)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label}.blob-metadata must be an array")))?;
|
|
for blob in blobs {
|
|
validate_statistics_blob_metadata(blob, label)?;
|
|
}
|
|
}
|
|
Ok(snapshot_id)
|
|
}
|
|
|
|
fn validate_statistics_blob_metadata(value: &serde_json::Value, label: &str) -> TableCatalogStoreResult<()> {
|
|
let object = value
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label}.blob-metadata entries must be JSON objects")))?;
|
|
if !object
|
|
.get("type")
|
|
.and_then(serde_json::Value::as_str)
|
|
.is_some_and(|value| !value.is_empty())
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"{label}.blob-metadata type must be a non-empty string"
|
|
)));
|
|
}
|
|
for field in ["snapshot-id", "sequence-number"] {
|
|
if object.get(field).and_then(serde_json::Value::as_i64).is_none() {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"{label}.blob-metadata {field} must be an integer"
|
|
)));
|
|
}
|
|
}
|
|
let fields = object
|
|
.get("fields")
|
|
.and_then(serde_json::Value::as_array)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label}.blob-metadata fields must be an array")))?;
|
|
if fields.iter().any(|field| field.as_i64().is_none()) {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"{label}.blob-metadata fields must contain integers"
|
|
)));
|
|
}
|
|
if let Some(properties) = object.get("properties") {
|
|
let properties = properties
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label}.blob-metadata properties must be a JSON object")))?;
|
|
if properties.values().any(|value| !value.is_string()) {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"{label}.blob-metadata property values must be strings"
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn statistics_non_negative_i64(
|
|
object: &serde_json::Map<String, serde_json::Value>,
|
|
field: &str,
|
|
label: &str,
|
|
) -> TableCatalogStoreResult<i64> {
|
|
let value = object
|
|
.get(field)
|
|
.and_then(serde_json::Value::as_i64)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label}.{field} must be an integer")))?;
|
|
if value < 0 {
|
|
return Err(TableCatalogStoreError::Invalid(format!("{label}.{field} must not be negative")));
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
fn validate_table_statistics_references(
|
|
metadata: &serde_json::Value,
|
|
snapshot_ids: &BTreeSet<i64>,
|
|
) -> TableCatalogStoreResult<()> {
|
|
for (field, kind) in [
|
|
("statistics", IcebergStatisticsFileKind::Table),
|
|
("partition-statistics", IcebergStatisticsFileKind::Partition),
|
|
] {
|
|
let Some(values) = metadata.get(field) else {
|
|
continue;
|
|
};
|
|
let values = values
|
|
.as_array()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("table metadata field {field} must be an array")))?;
|
|
let mut snapshot_ids_with_statistics = BTreeSet::new();
|
|
for value in values {
|
|
let snapshot_id = validate_iceberg_statistics_file(value, field, kind)?;
|
|
if !snapshot_ids.contains(&snapshot_id) {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"{field} references missing snapshot {snapshot_id}"
|
|
)));
|
|
}
|
|
if !snapshot_ids_with_statistics.insert(snapshot_id) {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"{field} contains duplicate entries for snapshot {snapshot_id}"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_supported_table_metadata_fields(metadata: &serde_json::Value) -> TableCatalogStoreResult<()> {
|
|
table_metadata_uuid(metadata)?;
|
|
table_metadata_location(metadata)?;
|
|
require_metadata_i64(metadata, "last-updated-ms")?;
|
|
require_metadata_i32(metadata, "last-column-id")?;
|
|
|
|
match table_metadata_format_version(metadata)? {
|
|
1 => {
|
|
if !metadata.get("schema").is_some_and(serde_json::Value::is_object) {
|
|
return Err(TableCatalogStoreError::Invalid("Iceberg v1 table metadata is missing schema".to_string()));
|
|
}
|
|
require_metadata_array(metadata, "partition-spec")?;
|
|
if let Some(snapshots) = metadata.get("snapshots").and_then(serde_json::Value::as_array) {
|
|
for snapshot in snapshots {
|
|
validate_table_snapshot_fields(snapshot, 1)?;
|
|
if snapshot
|
|
.get("sequence-number")
|
|
.is_some_and(|sequence_number| sequence_number.as_i64() != Some(0))
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"Iceberg v1 snapshot sequence-number must be zero when present".to_string(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
2 => {
|
|
let last_sequence_number = require_metadata_i64(metadata, "last-sequence-number")?;
|
|
if last_sequence_number < 0 {
|
|
return Err(TableCatalogStoreError::Invalid("last-sequence-number must not be negative".to_string()));
|
|
}
|
|
if require_metadata_array(metadata, "schemas")?.is_empty() {
|
|
return Err(TableCatalogStoreError::Invalid("table metadata schemas must not be empty".to_string()));
|
|
}
|
|
require_metadata_i32(metadata, "current-schema-id")?;
|
|
if require_metadata_array(metadata, "partition-specs")?.is_empty() {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"table metadata partition-specs must not be empty".to_string(),
|
|
));
|
|
}
|
|
require_metadata_i32(metadata, "default-spec-id")?;
|
|
require_metadata_i32(metadata, "last-partition-id")?;
|
|
if require_metadata_array(metadata, "sort-orders")?.is_empty() {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"table metadata sort-orders must not be empty".to_string(),
|
|
));
|
|
}
|
|
require_metadata_i32(metadata, "default-sort-order-id")?;
|
|
if let Some(snapshots) = metadata.get("snapshots").and_then(serde_json::Value::as_array) {
|
|
for snapshot in snapshots {
|
|
validate_table_snapshot_fields(snapshot, 2)?;
|
|
let sequence_number = match snapshot.get("sequence-number") {
|
|
Some(sequence_number) => sequence_number.as_i64().ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid("Iceberg v2 snapshot sequence-number must be an integer".to_string())
|
|
})?,
|
|
None => {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"Iceberg v2 snapshot sequence-number is required".to_string(),
|
|
));
|
|
}
|
|
};
|
|
if sequence_number < 0 || sequence_number > last_sequence_number {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"Iceberg v2 snapshot sequence-number must be between zero and last-sequence-number".to_string(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
version => {
|
|
return Err(TableCatalogStoreError::Internal(format!(
|
|
"validated Iceberg format version {version} is unsupported"
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn validate_table_metadata_references(metadata: &serde_json::Value) -> TableCatalogStoreResult<()> {
|
|
let format_version = table_metadata_format_version(metadata)?;
|
|
let schema_fields = validate_table_schemas(metadata, format_version)?;
|
|
let current_schema_fields = current_table_schema_fields(metadata, format_version)?;
|
|
let last_column_id = require_metadata_i32(metadata, "last-column-id")?;
|
|
if last_column_id < 0
|
|
|| schema_fields
|
|
.field_ids
|
|
.last()
|
|
.is_some_and(|field_id| *field_id > last_column_id)
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"last-column-id must be non-negative and cover every assigned schema field id".to_string(),
|
|
));
|
|
}
|
|
let mut schema_ids = metadata_array_i32_ids(metadata, "schemas", "schema-id", "schema")?;
|
|
if format_version == 1 && schema_ids.is_empty() {
|
|
let schema = metadata
|
|
.get("schema")
|
|
.and_then(serde_json::Value::as_object)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 table metadata is missing schema".to_string()))?;
|
|
let schema_id = match schema.get("schema-id") {
|
|
Some(schema_id) => schema_id
|
|
.as_i64()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 schema-id must be an integer".to_string()))?,
|
|
None => 0,
|
|
};
|
|
if schema_id < 0 {
|
|
return Err(TableCatalogStoreError::Invalid(format!("schema id {schema_id} must not be negative")));
|
|
}
|
|
if i32::try_from(schema_id).is_err() {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"schema id {schema_id} exceeds the signed 32-bit range"
|
|
)));
|
|
}
|
|
schema_ids.insert(schema_id);
|
|
}
|
|
validate_metadata_id_reference(metadata, "current-schema-id", &schema_ids, "schema")?;
|
|
validate_partition_specs(metadata, format_version, &schema_fields, ¤t_schema_fields)?;
|
|
let spec_ids = metadata_array_i32_ids(metadata, "partition-specs", "spec-id", "partition spec")?;
|
|
validate_metadata_id_reference(metadata, "default-spec-id", &spec_ids, "partition spec")?;
|
|
validate_sort_orders(metadata, &schema_fields, ¤t_schema_fields)?;
|
|
let sort_order_ids = metadata_array_i32_ids(metadata, "sort-orders", "order-id", "sort order")?;
|
|
validate_metadata_id_reference(metadata, "default-sort-order-id", &sort_order_ids, "sort order")?;
|
|
let snapshot_ids = metadata_array_ids(metadata, "snapshots", "snapshot-id", "snapshot")?;
|
|
validate_table_statistics_references(metadata, &snapshot_ids)?;
|
|
|
|
let current_snapshot_id = match metadata.get("current-snapshot-id").filter(|value| !value.is_null()) {
|
|
Some(current_snapshot_id) => {
|
|
let current_snapshot_id = current_snapshot_id
|
|
.as_i64()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("current-snapshot-id must be an integer".to_string()))?;
|
|
if current_snapshot_id != -1 && !snapshot_ids.contains(¤t_snapshot_id) {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"current snapshot {current_snapshot_id} does not exist in table metadata"
|
|
)));
|
|
}
|
|
(current_snapshot_id != -1).then_some(current_snapshot_id)
|
|
}
|
|
None => None,
|
|
};
|
|
if let Some(refs) = metadata.get("refs").filter(|value| !value.is_null()) {
|
|
let refs = refs
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("refs must be an object".to_string()))?;
|
|
for (name, reference) in refs {
|
|
if name.is_empty() {
|
|
return Err(TableCatalogStoreError::Invalid("snapshot ref name must not be empty".to_string()));
|
|
}
|
|
let reference = reference
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("snapshot ref {name} must be an object")))?;
|
|
let snapshot_id = reference
|
|
.get("snapshot-id")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("snapshot ref {name} is missing snapshot-id")))?;
|
|
if !snapshot_ids.contains(&snapshot_id) {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"snapshot ref {name} targets snapshot {snapshot_id}, which does not exist"
|
|
)));
|
|
}
|
|
let reference_type = reference
|
|
.get("type")
|
|
.and_then(serde_json::Value::as_str)
|
|
.filter(|reference_type| matches!(*reference_type, "branch" | "tag"))
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("snapshot ref {name} must have type branch or tag")))?;
|
|
if name == "main" && (reference_type != "branch" || current_snapshot_id != Some(snapshot_id)) {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"main snapshot ref must be a branch pointing to current-snapshot-id".to_string(),
|
|
));
|
|
}
|
|
validate_snapshot_ref_retention(name, reference_type, reference)?;
|
|
}
|
|
}
|
|
if let Some(snapshots) = metadata.get("snapshots").and_then(serde_json::Value::as_array) {
|
|
for snapshot in snapshots {
|
|
if let Some(schema_id) = snapshot.get("schema-id") {
|
|
let schema_id = schema_id
|
|
.as_i64()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("snapshot schema-id must be an integer".to_string()))?;
|
|
if !schema_ids.contains(&schema_id) {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"snapshot schema-id targets schema {schema_id}, which does not exist"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_table_schemas(metadata: &serde_json::Value, format_version: u16) -> TableCatalogStoreResult<IcebergSchemaFields> {
|
|
let mut schemas = Vec::new();
|
|
if format_version == 1 {
|
|
let schema = metadata
|
|
.get("schema")
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 table metadata is missing schema".to_string()))?;
|
|
let schema_id = schema.get("schema-id").and_then(serde_json::Value::as_i64).unwrap_or(0);
|
|
schemas.push((schema_id, schema));
|
|
}
|
|
if let Some(metadata_schemas) = metadata.get("schemas") {
|
|
let metadata_schemas = metadata_schemas
|
|
.as_array()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("schemas must be an array".to_string()))?;
|
|
for schema in metadata_schemas {
|
|
let schema_id = schema
|
|
.get("schema-id")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("schema-id must be an integer".to_string()))?;
|
|
schemas.push((schema_id, schema));
|
|
}
|
|
}
|
|
schemas.sort_by_key(|(schema_id, _)| *schema_id);
|
|
|
|
let mut historical = BTreeMap::new();
|
|
let mut active_fields = BTreeSet::new();
|
|
let mut retired_fields = BTreeSet::new();
|
|
let mut all_fields = IcebergSchemaFields::default();
|
|
for (_, schema) in schemas {
|
|
let schema_fields = validate_iceberg_schema_fields(schema, "schema")?;
|
|
if let Some(field_id) = schema_fields
|
|
.field_ids
|
|
.iter()
|
|
.find(|field_id| retired_fields.contains(*field_id))
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"schema field {field_id} cannot be reused after removal"
|
|
)));
|
|
}
|
|
for (field_id, descriptor) in &schema_fields.descriptors {
|
|
if let Some(previous) = historical.get(field_id) {
|
|
validate_schema_field_evolution(*field_id, previous, descriptor)?;
|
|
}
|
|
historical.insert(*field_id, descriptor.clone());
|
|
}
|
|
retired_fields.extend(active_fields.difference(&schema_fields.field_ids).copied());
|
|
active_fields = schema_fields.field_ids.clone();
|
|
all_fields.field_ids.extend(schema_fields.field_ids);
|
|
all_fields.identifier_eligible.extend(schema_fields.identifier_eligible);
|
|
all_fields.descriptors.extend(schema_fields.descriptors);
|
|
}
|
|
Ok(all_fields)
|
|
}
|
|
|
|
fn current_table_schema_fields(
|
|
metadata: &serde_json::Value,
|
|
format_version: u16,
|
|
) -> TableCatalogStoreResult<IcebergSchemaFields> {
|
|
if metadata.get("schemas").is_some() {
|
|
let current_schema_id = require_metadata_i32(metadata, "current-schema-id")?;
|
|
let schema = require_metadata_array(metadata, "schemas")?
|
|
.iter()
|
|
.find(|schema| schema.get("schema-id").and_then(serde_json::Value::as_i64) == Some(i64::from(current_schema_id)))
|
|
.ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid(format!(
|
|
"current-schema-id targets schema {current_schema_id}, which does not exist"
|
|
))
|
|
})?;
|
|
return validate_iceberg_schema_fields(schema, "current schema");
|
|
}
|
|
if format_version == 1 {
|
|
let schema = metadata
|
|
.get("schema")
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v1 table metadata is missing schema".to_string()))?;
|
|
return validate_iceberg_schema_fields(schema, "schema");
|
|
}
|
|
Err(TableCatalogStoreError::Invalid("schemas must be an array".to_string()))
|
|
}
|
|
|
|
pub(crate) fn validate_partition_spec_sources_against_current_schema(
|
|
metadata: &serde_json::Value,
|
|
spec: &serde_json::Value,
|
|
) -> TableCatalogStoreResult<()> {
|
|
let current_schema_fields = current_table_schema_fields(metadata, table_metadata_format_version(metadata)?)?;
|
|
let fields = spec
|
|
.get("fields")
|
|
.and_then(serde_json::Value::as_array)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("partition spec fields must be an array".to_string()))?;
|
|
for field in fields {
|
|
let field = field
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("partition spec fields must be JSON objects".to_string()))?;
|
|
let source_id = required_positive_i32_value(field, "source-id", "partition source-id")?;
|
|
let transform = field
|
|
.get("transform")
|
|
.and_then(serde_json::Value::as_str)
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("partition field transform must be a non-empty string".to_string()))?;
|
|
if transform == "void" {
|
|
continue;
|
|
}
|
|
let source_type = current_schema_fields.descriptors.get(&source_id).ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid(format!("partition source-id {source_id} does not reference the current schema"))
|
|
})?;
|
|
if source_type.inside_collection {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"partition source-id {source_id} must not be nested in a list or map"
|
|
)));
|
|
}
|
|
validate_transform_for_source(transform, source_type, "partition field")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn validate_sort_order_sources_against_current_schema(
|
|
metadata: &serde_json::Value,
|
|
sort_order: &serde_json::Value,
|
|
) -> TableCatalogStoreResult<()> {
|
|
let current_schema_fields = current_table_schema_fields(metadata, table_metadata_format_version(metadata)?)?;
|
|
let fields = sort_order
|
|
.get("fields")
|
|
.and_then(serde_json::Value::as_array)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("sort order fields must be an array".to_string()))?;
|
|
for field in fields {
|
|
let field = field
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("sort order fields must be JSON objects".to_string()))?;
|
|
let source_id = required_positive_i32_value(field, "source-id", "sort field source-id")?;
|
|
let source_type = current_schema_fields.descriptors.get(&source_id).ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid(format!("sort field source-id {source_id} does not reference the current schema"))
|
|
})?;
|
|
let transform = field
|
|
.get("transform")
|
|
.and_then(serde_json::Value::as_str)
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("sort field transform must be a non-empty string".to_string()))?;
|
|
validate_transform_for_source(transform, source_type, "sort field")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_iceberg_schema(schema: &serde_json::Value, label: &str) -> TableCatalogStoreResult<BTreeSet<i32>> {
|
|
Ok(validate_iceberg_schema_fields(schema, label)?.field_ids)
|
|
}
|
|
|
|
fn validate_iceberg_schema_fields(schema: &serde_json::Value, label: &str) -> TableCatalogStoreResult<IcebergSchemaFields> {
|
|
let schema = schema
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label} must be a JSON object")))?;
|
|
if schema.get("type").and_then(serde_json::Value::as_str) != Some("struct") {
|
|
return Err(TableCatalogStoreError::Invalid(format!("{label} type must be struct")));
|
|
}
|
|
let fields = schema
|
|
.get("fields")
|
|
.and_then(serde_json::Value::as_array)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label} fields must be an array")))?;
|
|
let mut schema_fields = IcebergSchemaFields::default();
|
|
validate_struct_fields(fields, label, true, false, &mut schema_fields)?;
|
|
if let Some(identifier_field_ids) = schema.get("identifier-field-ids") {
|
|
let identifier_field_ids = identifier_field_ids
|
|
.as_array()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label} identifier-field-ids must be an array")))?;
|
|
let mut seen = BTreeSet::new();
|
|
for field_id in identifier_field_ids {
|
|
let field_id = required_positive_i32(field_id, &format!("{label} identifier field id"))?;
|
|
if !seen.insert(field_id) || schema_fields.identifier_eligible.get(&field_id) != Some(&true) {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"{label} identifier field id {field_id} must uniquely reference a required non-floating primitive outside lists, maps, and optional structs"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
Ok(schema_fields)
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct IcebergSchemaFields {
|
|
field_ids: BTreeSet<i32>,
|
|
identifier_eligible: BTreeMap<i32, bool>,
|
|
descriptors: BTreeMap<i32, IcebergSchemaFieldDescriptor>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct IcebergSchemaFieldDescriptor {
|
|
field_type: IcebergFieldType,
|
|
required: bool,
|
|
inside_collection: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
enum IcebergFieldType {
|
|
Primitive(String),
|
|
Struct,
|
|
List,
|
|
Map,
|
|
}
|
|
|
|
fn iceberg_field_type(value: &serde_json::Value, label: &str) -> TableCatalogStoreResult<IcebergFieldType> {
|
|
if let Some(primitive) = value.as_str() {
|
|
validate_iceberg_primitive_type(primitive, label)?;
|
|
return Ok(IcebergFieldType::Primitive(primitive.to_string()));
|
|
}
|
|
match value.get("type").and_then(serde_json::Value::as_str) {
|
|
Some("struct") => Ok(IcebergFieldType::Struct),
|
|
Some("list") => Ok(IcebergFieldType::List),
|
|
Some("map") => Ok(IcebergFieldType::Map),
|
|
_ => Err(TableCatalogStoreError::Invalid(format!("{label} contains an unsupported field type"))),
|
|
}
|
|
}
|
|
|
|
fn insert_schema_field(
|
|
schema_fields: &mut IcebergSchemaFields,
|
|
field_id: i32,
|
|
field_type: &serde_json::Value,
|
|
required: bool,
|
|
inside_collection: bool,
|
|
label: &str,
|
|
) -> TableCatalogStoreResult<()> {
|
|
if !schema_fields.field_ids.insert(field_id) {
|
|
return Err(TableCatalogStoreError::Invalid(format!("duplicate {label} field id {field_id}")));
|
|
}
|
|
schema_fields.descriptors.insert(
|
|
field_id,
|
|
IcebergSchemaFieldDescriptor {
|
|
field_type: iceberg_field_type(field_type, label)?,
|
|
required,
|
|
inside_collection,
|
|
},
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_schema_field_evolution(
|
|
field_id: i32,
|
|
previous: &IcebergSchemaFieldDescriptor,
|
|
next: &IcebergSchemaFieldDescriptor,
|
|
) -> TableCatalogStoreResult<()> {
|
|
if !previous.required && next.required {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"schema field {field_id} cannot evolve from optional to required"
|
|
)));
|
|
}
|
|
if previous.inside_collection != next.inside_collection {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"schema field {field_id} cannot move into or out of a list or map"
|
|
)));
|
|
}
|
|
if previous.field_type == next.field_type {
|
|
return Ok(());
|
|
}
|
|
let compatible = match (&previous.field_type, &next.field_type) {
|
|
(IcebergFieldType::Primitive(previous), IcebergFieldType::Primitive(next)) => {
|
|
primitive_type_promotion_is_valid(previous, next)
|
|
}
|
|
_ => false,
|
|
};
|
|
if !compatible {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"schema field {field_id} has an incompatible type evolution"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn primitive_type_promotion_is_valid(previous: &str, next: &str) -> bool {
|
|
if matches!((previous, next), ("int", "long") | ("float", "double")) {
|
|
return true;
|
|
}
|
|
let decimal = |value: &str| {
|
|
value
|
|
.strip_prefix("decimal(")
|
|
.and_then(|value| value.strip_suffix(')'))
|
|
.and_then(|parameters| parameters.split_once(','))
|
|
.and_then(|(precision, scale)| Some((precision.trim().parse::<u8>().ok()?, scale.trim().parse::<u8>().ok()?)))
|
|
};
|
|
matches!((decimal(previous), decimal(next)), (Some((previous_precision, previous_scale)), Some((next_precision, next_scale))) if previous_scale == next_scale && next_precision >= previous_precision)
|
|
}
|
|
|
|
fn validate_struct_fields(
|
|
fields: &[serde_json::Value],
|
|
label: &str,
|
|
required_ancestors: bool,
|
|
inside_collection: bool,
|
|
schema_fields: &mut IcebergSchemaFields,
|
|
) -> TableCatalogStoreResult<()> {
|
|
for field in fields {
|
|
let field = field
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label} fields must be JSON objects")))?;
|
|
let field_id = required_schema_field_id_value(field, "id", &format!("{label} field id"))?;
|
|
if !field
|
|
.get("name")
|
|
.and_then(serde_json::Value::as_str)
|
|
.is_some_and(|name| !name.is_empty())
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(format!("{label} field name must be a non-empty string")));
|
|
}
|
|
let required = field
|
|
.get("required")
|
|
.and_then(serde_json::Value::as_bool)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label} field required must be a boolean")))?;
|
|
let field_type = field
|
|
.get("type")
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label} field type is required")))?;
|
|
insert_schema_field(schema_fields, field_id, field_type, required, inside_collection, label)?;
|
|
let identifier_eligible = required_ancestors
|
|
&& required
|
|
&& !inside_collection
|
|
&& field_type
|
|
.as_str()
|
|
.is_some_and(|primitive| !matches!(primitive, "float" | "double"));
|
|
schema_fields.identifier_eligible.insert(field_id, identifier_eligible);
|
|
validate_iceberg_type(field_type, label, required_ancestors && required, inside_collection, schema_fields)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_iceberg_type(
|
|
field_type: &serde_json::Value,
|
|
label: &str,
|
|
required_ancestors: bool,
|
|
inside_collection: bool,
|
|
schema_fields: &mut IcebergSchemaFields,
|
|
) -> TableCatalogStoreResult<()> {
|
|
if let Some(primitive) = field_type.as_str() {
|
|
return validate_iceberg_primitive_type(primitive, label);
|
|
}
|
|
let field_type = field_type
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label} field type must be a string or JSON object")))?;
|
|
match field_type.get("type").and_then(serde_json::Value::as_str) {
|
|
Some("struct") => {
|
|
let fields = field_type
|
|
.get("fields")
|
|
.and_then(serde_json::Value::as_array)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label} struct fields must be an array")))?;
|
|
validate_struct_fields(fields, label, required_ancestors, inside_collection, schema_fields)
|
|
}
|
|
Some("list") => {
|
|
let element_id = required_schema_field_id_value(field_type, "element-id", &format!("{label} list element-id"))?;
|
|
let element_required = field_type
|
|
.get("element-required")
|
|
.and_then(serde_json::Value::as_bool)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label} list element-required must be a boolean")))?;
|
|
let element = field_type
|
|
.get("element")
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label} list element is required")))?;
|
|
insert_schema_field(schema_fields, element_id, element, element_required, true, label)?;
|
|
validate_iceberg_type(element, label, false, true, schema_fields)
|
|
}
|
|
Some("map") => {
|
|
let value_required = field_type
|
|
.get("value-required")
|
|
.and_then(serde_json::Value::as_bool)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label} map value-required must be a boolean")))?;
|
|
for (id_field, value_field, required) in [("key-id", "key", true), ("value-id", "value", value_required)] {
|
|
let field_id = required_schema_field_id_value(field_type, id_field, &format!("{label} map {id_field}"))?;
|
|
let value = field_type
|
|
.get(value_field)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label} map {value_field} is required")))?;
|
|
insert_schema_field(schema_fields, field_id, value, required, true, label)?;
|
|
validate_iceberg_type(value, label, false, true, schema_fields)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
_ => Err(TableCatalogStoreError::Invalid(format!("{label} contains an unsupported field type"))),
|
|
}
|
|
}
|
|
|
|
fn validate_iceberg_primitive_type(primitive: &str, label: &str) -> TableCatalogStoreResult<()> {
|
|
if matches!(
|
|
primitive,
|
|
"boolean"
|
|
| "int"
|
|
| "long"
|
|
| "float"
|
|
| "double"
|
|
| "date"
|
|
| "time"
|
|
| "timestamp"
|
|
| "timestamptz"
|
|
| "string"
|
|
| "uuid"
|
|
| "binary"
|
|
) {
|
|
return Ok(());
|
|
}
|
|
if let Some(length) = primitive.strip_prefix("fixed[").and_then(|value| value.strip_suffix(']'))
|
|
&& length.trim().parse::<i32>().is_ok_and(|length| length > 0)
|
|
{
|
|
return Ok(());
|
|
}
|
|
if let Some(parameters) = primitive.strip_prefix("decimal(").and_then(|value| value.strip_suffix(')'))
|
|
&& let Some((precision, scale)) = parameters.split_once(',')
|
|
&& !scale.contains(',')
|
|
&& let (Ok(precision), Ok(scale)) = (precision.trim().parse::<u8>(), scale.trim().parse::<u8>())
|
|
&& (1..=38).contains(&precision)
|
|
&& scale <= precision
|
|
{
|
|
return Ok(());
|
|
}
|
|
Err(TableCatalogStoreError::Invalid(format!(
|
|
"{label} contains unsupported primitive type {primitive}"
|
|
)))
|
|
}
|
|
|
|
fn required_i32_value(
|
|
object: &serde_json::Map<String, serde_json::Value>,
|
|
field: &str,
|
|
label: &str,
|
|
) -> TableCatalogStoreResult<i32> {
|
|
let value = object
|
|
.get(field)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label} is required")))?;
|
|
required_i32(value, label)
|
|
}
|
|
|
|
fn required_positive_i32_value(
|
|
object: &serde_json::Map<String, serde_json::Value>,
|
|
field: &str,
|
|
label: &str,
|
|
) -> TableCatalogStoreResult<i32> {
|
|
let value = object
|
|
.get(field)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label} is required")))?;
|
|
required_positive_i32(value, label)
|
|
}
|
|
|
|
fn required_schema_field_id_value(
|
|
object: &serde_json::Map<String, serde_json::Value>,
|
|
field: &str,
|
|
label: &str,
|
|
) -> TableCatalogStoreResult<i32> {
|
|
let field_id = required_positive_i32_value(object, field, label)?;
|
|
if field_id > ICEBERG_MAX_USER_FIELD_ID {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"{label} must not use the reserved Iceberg field ID range"
|
|
)));
|
|
}
|
|
Ok(field_id)
|
|
}
|
|
|
|
fn required_i32(value: &serde_json::Value, label: &str) -> TableCatalogStoreResult<i32> {
|
|
let value = value
|
|
.as_i64()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label} must be an integer")))?;
|
|
i32::try_from(value).map_err(|_| TableCatalogStoreError::Invalid(format!("{label} exceeds the signed 32-bit range")))
|
|
}
|
|
|
|
fn required_positive_i32(value: &serde_json::Value, label: &str) -> TableCatalogStoreResult<i32> {
|
|
let value = required_i32(value, label)?;
|
|
if value <= 0 {
|
|
return Err(TableCatalogStoreError::Invalid(format!("{label} must be positive")));
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
fn validate_partition_specs(
|
|
metadata: &serde_json::Value,
|
|
format_version: u16,
|
|
schema_fields: &IcebergSchemaFields,
|
|
current_schema_fields: &IcebergSchemaFields,
|
|
) -> TableCatalogStoreResult<()> {
|
|
let spec_fields = if format_version == 1 {
|
|
vec![(0, require_metadata_array(metadata, "partition-spec")?)]
|
|
} else {
|
|
metadata
|
|
.get("partition-specs")
|
|
.and_then(serde_json::Value::as_array)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("partition-specs must be an array".to_string()))?
|
|
.iter()
|
|
.map(|spec| {
|
|
let spec_id = required_i32_value(
|
|
spec.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("partition specs must be JSON objects".to_string()))?,
|
|
"spec-id",
|
|
"partition spec-id",
|
|
)?;
|
|
let fields = spec
|
|
.get("fields")
|
|
.and_then(serde_json::Value::as_array)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("partition spec fields must be an array".to_string()))?;
|
|
Ok((spec_id, fields))
|
|
})
|
|
.collect::<TableCatalogStoreResult<Vec<_>>>()?
|
|
};
|
|
let default_spec_id = if format_version == 1 {
|
|
Some(0)
|
|
} else {
|
|
Some(require_metadata_i32(metadata, "default-spec-id")?)
|
|
};
|
|
let last_partition_id = (format_version != 1)
|
|
.then(|| require_metadata_i32(metadata, "last-partition-id"))
|
|
.transpose()?;
|
|
if last_partition_id.is_some_and(|last_partition_id| last_partition_id < 0) {
|
|
return Err(TableCatalogStoreError::Invalid("last-partition-id must not be negative".to_string()));
|
|
}
|
|
let mut assigned_fields = BTreeMap::new();
|
|
for (spec_id, fields) in spec_fields {
|
|
let mut field_ids = BTreeSet::new();
|
|
for (field_index, field) in fields.iter().enumerate() {
|
|
let field = field
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("partition spec fields must be JSON objects".to_string()))?;
|
|
let source_id = required_positive_i32_value(field, "source-id", "partition source-id")?;
|
|
let transform = field
|
|
.get("transform")
|
|
.and_then(serde_json::Value::as_str)
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid("partition field transform must be a non-empty string".to_string())
|
|
})?;
|
|
let source_fields = if default_spec_id == Some(spec_id) {
|
|
current_schema_fields
|
|
} else {
|
|
schema_fields
|
|
};
|
|
let source_type = if transform == "void" {
|
|
None
|
|
} else {
|
|
let source_type = source_fields.descriptors.get(&source_id).ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid(format!("partition source-id {source_id} does not reference a schema field"))
|
|
})?;
|
|
if source_type.inside_collection {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"partition source-id {source_id} must not be nested in a list or map"
|
|
)));
|
|
}
|
|
Some(source_type)
|
|
};
|
|
let field_id = if format_version == 1 {
|
|
let expected_field_id = i32::try_from(field_index)
|
|
.ok()
|
|
.and_then(|field_index| 1000_i32.checked_add(field_index))
|
|
.ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid("Iceberg v1 partition spec has too many fields".to_string())
|
|
})?;
|
|
match field.get("field-id") {
|
|
Some(field_id) => {
|
|
let field_id = required_positive_i32(field_id, "partition field-id")?;
|
|
if field_id != expected_field_id {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"Iceberg v1 partition field-id must be sequential from 1000".to_string(),
|
|
));
|
|
}
|
|
field_id
|
|
}
|
|
None => expected_field_id,
|
|
}
|
|
} else {
|
|
required_positive_i32_value(field, "field-id", "partition field-id")?
|
|
};
|
|
if last_partition_id.is_some_and(|last_partition_id| field_id > last_partition_id) || !field_ids.insert(field_id) {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"partition field-id {field_id} must be unique and not exceed last-partition-id"
|
|
)));
|
|
}
|
|
if !field
|
|
.get("name")
|
|
.and_then(serde_json::Value::as_str)
|
|
.is_some_and(|value| !value.is_empty())
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"partition field name must be a non-empty string".to_string(),
|
|
));
|
|
}
|
|
if let Some(source_type) = source_type {
|
|
validate_transform_for_source(transform, source_type, "partition field")?;
|
|
}
|
|
let identity = (source_id, transform);
|
|
if format_version != 1
|
|
&& let Some(previous) = assigned_fields.insert(field_id, identity)
|
|
&& previous != identity
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"partition field-id {field_id} is assigned to multiple partition fields"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_sort_orders(
|
|
metadata: &serde_json::Value,
|
|
schema_fields: &IcebergSchemaFields,
|
|
current_schema_fields: &IcebergSchemaFields,
|
|
) -> TableCatalogStoreResult<()> {
|
|
let Some(sort_orders) = metadata.get("sort-orders") else {
|
|
return Ok(());
|
|
};
|
|
let sort_orders = sort_orders
|
|
.as_array()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("sort-orders must be an array".to_string()))?;
|
|
let default_sort_order_id = metadata.get("default-sort-order-id").and_then(serde_json::Value::as_i64);
|
|
for sort_order in sort_orders {
|
|
let sort_order = sort_order
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("sort orders must be JSON objects".to_string()))?;
|
|
let order_id = required_i32_value(sort_order, "order-id", "sort order-id")?;
|
|
if order_id < 0 {
|
|
return Err(TableCatalogStoreError::Invalid("sort order-id must not be negative".to_string()));
|
|
}
|
|
let fields = sort_order
|
|
.get("fields")
|
|
.and_then(serde_json::Value::as_array)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("sort order fields must be an array".to_string()))?;
|
|
if order_id == 0 && !fields.is_empty() {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"sort order 0 is reserved for the unsorted order".to_string(),
|
|
));
|
|
}
|
|
if order_id > 0 && fields.is_empty() {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"empty sort orders must use the reserved unsorted order-id 0".to_string(),
|
|
));
|
|
}
|
|
for field in fields {
|
|
let field = field
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("sort order fields must be JSON objects".to_string()))?;
|
|
let source_id = required_positive_i32_value(field, "source-id", "sort field source-id")?;
|
|
let source_fields = if default_sort_order_id == Some(i64::from(order_id)) {
|
|
current_schema_fields
|
|
} else {
|
|
schema_fields
|
|
};
|
|
let source_type = source_fields.descriptors.get(&source_id).ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid(format!("sort field source-id {source_id} does not reference a schema field"))
|
|
})?;
|
|
let transform = field
|
|
.get("transform")
|
|
.and_then(serde_json::Value::as_str)
|
|
.filter(|transform| !transform.is_empty())
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("sort field transform must be a non-empty string".to_string()))?;
|
|
validate_transform_for_source(transform, source_type, "sort field")?;
|
|
if !field
|
|
.get("direction")
|
|
.and_then(serde_json::Value::as_str)
|
|
.is_some_and(|direction| matches!(direction, "asc" | "desc"))
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid("sort field direction must be asc or desc".to_string()));
|
|
}
|
|
if !field
|
|
.get("null-order")
|
|
.and_then(serde_json::Value::as_str)
|
|
.is_some_and(|null_order| matches!(null_order, "nulls-first" | "nulls-last"))
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"sort field null-order must be nulls-first or nulls-last".to_string(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_transform_for_source(
|
|
transform: &str,
|
|
source: &IcebergSchemaFieldDescriptor,
|
|
label: &str,
|
|
) -> TableCatalogStoreResult<()> {
|
|
if transform == "void" {
|
|
return Ok(());
|
|
}
|
|
let IcebergFieldType::Primitive(source_type) = &source.field_type else {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"{label} transform {transform} requires a primitive source type"
|
|
)));
|
|
};
|
|
let valid = match transform {
|
|
"identity" => true,
|
|
"year" | "month" | "day" => matches!(source_type.as_str(), "date" | "timestamp" | "timestamptz"),
|
|
"hour" => matches!(source_type.as_str(), "timestamp" | "timestamptz"),
|
|
_ => match transform_parameter(transform, "bucket") {
|
|
Some(width) => {
|
|
width > 0
|
|
&& (matches!(
|
|
source_type.as_str(),
|
|
"int" | "long" | "date" | "time" | "timestamp" | "timestamptz" | "string" | "uuid" | "binary"
|
|
) || source_type.starts_with("decimal(")
|
|
|| source_type.starts_with("fixed["))
|
|
}
|
|
None => match transform_parameter(transform, "truncate") {
|
|
Some(width) => {
|
|
width > 0
|
|
&& (matches!(source_type.as_str(), "int" | "long" | "string" | "binary")
|
|
|| source_type.starts_with("decimal("))
|
|
}
|
|
None => false,
|
|
},
|
|
},
|
|
};
|
|
if !valid {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"{label} transform {transform} is invalid for source type {source_type}"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn transform_parameter(transform: &str, name: &str) -> Option<i32> {
|
|
transform
|
|
.strip_prefix(name)
|
|
.and_then(|value| value.strip_prefix('['))
|
|
.and_then(|value| value.strip_suffix(']'))
|
|
.and_then(|value| value.parse::<i32>().ok())
|
|
}
|
|
|
|
fn validate_table_snapshot_fields(snapshot: &serde_json::Value, format_version: u16) -> TableCatalogStoreResult<()> {
|
|
let snapshot = snapshot
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("snapshot must be an object".to_string()))?;
|
|
if snapshot.get("snapshot-id").and_then(serde_json::Value::as_i64).is_none() {
|
|
return Err(TableCatalogStoreError::Invalid("snapshot-id must be an integer".to_string()));
|
|
}
|
|
if snapshot.get("timestamp-ms").and_then(serde_json::Value::as_i64).is_none() {
|
|
return Err(TableCatalogStoreError::Invalid("snapshot timestamp-ms must be an integer".to_string()));
|
|
}
|
|
if snapshot
|
|
.get("parent-snapshot-id")
|
|
.is_some_and(|parent_snapshot_id| parent_snapshot_id.as_i64().is_none())
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"snapshot parent-snapshot-id must be an integer".to_string(),
|
|
));
|
|
}
|
|
let manifest_list = snapshot
|
|
.get("manifest-list")
|
|
.and_then(serde_json::Value::as_str)
|
|
.filter(|location| !location.is_empty());
|
|
let manifests = snapshot.get("manifests");
|
|
if manifests.is_some_and(|manifests| !manifests.is_array()) {
|
|
return Err(TableCatalogStoreError::Invalid("snapshot manifests must be an array".to_string()));
|
|
}
|
|
let summary = snapshot.get("summary");
|
|
if let Some(summary) = summary {
|
|
validate_string_map(summary, "snapshot summary")?;
|
|
}
|
|
|
|
match format_version {
|
|
1 => {
|
|
if manifest_list.is_some() && manifests.is_some() {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"Iceberg v1 snapshot must not contain both manifest-list and manifests".to_string(),
|
|
));
|
|
}
|
|
if manifest_list.is_none() && manifests.is_none() {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"Iceberg v1 snapshot requires manifest-list or manifests".to_string(),
|
|
));
|
|
}
|
|
}
|
|
2 => {
|
|
if manifest_list.is_some() && manifests.is_some() {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"Iceberg v2 snapshot must not contain both manifest-list and manifests".to_string(),
|
|
));
|
|
}
|
|
if manifest_list.is_none() && manifests.is_none() {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"Iceberg v2 snapshot requires manifest-list or v1-compatible manifests".to_string(),
|
|
));
|
|
}
|
|
let summary = summary
|
|
.and_then(serde_json::Value::as_object)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("Iceberg v2 snapshot requires summary".to_string()))?;
|
|
if !summary
|
|
.get("operation")
|
|
.and_then(serde_json::Value::as_str)
|
|
.is_some_and(|operation| !operation.is_empty())
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"Iceberg v2 snapshot summary requires a non-empty operation".to_string(),
|
|
));
|
|
}
|
|
}
|
|
_ => {
|
|
return Err(TableCatalogStoreError::Internal(format!(
|
|
"validated Iceberg format version {format_version} is unsupported"
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_snapshot_ref_retention(
|
|
name: &str,
|
|
reference_type: &str,
|
|
reference: &serde_json::Map<String, serde_json::Value>,
|
|
) -> TableCatalogStoreResult<()> {
|
|
for field in ["min-snapshots-to-keep", "max-snapshot-age-ms", "max-ref-age-ms"] {
|
|
if let Some(value) = reference.get(field)
|
|
&& !value.as_i64().is_some_and(|value| value > 0)
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"snapshot ref {name} field {field} must be a positive integer"
|
|
)));
|
|
}
|
|
}
|
|
if reference_type == "tag"
|
|
&& (reference.contains_key("min-snapshots-to-keep") || reference.contains_key("max-snapshot-age-ms"))
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"snapshot tag {name} contains branch-only retention fields"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn table_metadata_partition_spec_ids(metadata: &serde_json::Value) -> TableCatalogStoreResult<BTreeSet<i32>> {
|
|
let ids = metadata_array_i32_ids(metadata, "partition-specs", "spec-id", "partition spec")?;
|
|
if !ids.is_empty() {
|
|
return ids
|
|
.into_iter()
|
|
.map(|id| {
|
|
i32::try_from(id).map_err(|_| {
|
|
TableCatalogStoreError::Invalid(format!("partition spec id {id} exceeds the signed 32-bit range"))
|
|
})
|
|
})
|
|
.collect();
|
|
}
|
|
if metadata.get("partition-spec").is_some_and(serde_json::Value::is_array) || metadata.get("partition-specs").is_none() {
|
|
return Ok(BTreeSet::from([0]));
|
|
}
|
|
Err(TableCatalogStoreError::Invalid("table metadata has no partition specs".to_string()))
|
|
}
|
|
|
|
pub(crate) fn validate_supported_view_metadata(metadata: &serde_json::Value) -> TableCatalogStoreResult<()> {
|
|
let object = metadata
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("view metadata must be a JSON object".to_string()))?;
|
|
let format_version = object
|
|
.get("format-version")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("view metadata is missing integer field format-version".to_string()))?;
|
|
if format_version != 1 {
|
|
return Err(TableCatalogStoreError::Unsupported(format!(
|
|
"Iceberg view format-version {format_version}"
|
|
)));
|
|
}
|
|
for field in ["view-uuid", "location"] {
|
|
if !object
|
|
.get(field)
|
|
.and_then(serde_json::Value::as_str)
|
|
.is_some_and(|value| !value.is_empty())
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"view metadata is missing non-empty string field {field}"
|
|
)));
|
|
}
|
|
}
|
|
let schemas = view_metadata_array(metadata, "schemas")?;
|
|
if schemas.is_empty() {
|
|
return Err(TableCatalogStoreError::Invalid("view metadata schemas must not be empty".to_string()));
|
|
}
|
|
for schema in schemas {
|
|
validate_iceberg_schema(schema, "view schema")?;
|
|
}
|
|
let schema_ids = metadata_array_i32_ids(metadata, "schemas", "schema-id", "schema")?;
|
|
validate_metadata_id_reference(metadata, "current-schema-id", &schema_ids, "schema")?;
|
|
let versions = view_metadata_array(metadata, "versions")?;
|
|
if versions.is_empty() {
|
|
return Err(TableCatalogStoreError::Invalid("view metadata versions must not be empty".to_string()));
|
|
}
|
|
if object.get("current-version-id").and_then(serde_json::Value::as_i64).is_none() {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"view metadata is missing integer field current-version-id".to_string(),
|
|
));
|
|
}
|
|
let version_ids = metadata_array_i32_ids(metadata, "versions", "version-id", "view version")?;
|
|
validate_metadata_id_reference(metadata, "current-version-id", &version_ids, "view version")?;
|
|
for version in versions {
|
|
validate_view_version(version)?;
|
|
let schema_id = version
|
|
.get("schema-id")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("view version is missing schema-id".to_string()))?;
|
|
if !schema_ids.contains(&schema_id) {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"view version schema-id targets schema {schema_id}, which does not exist"
|
|
)));
|
|
}
|
|
}
|
|
let version_log = view_metadata_array(metadata, "version-log")?;
|
|
for entry in version_log {
|
|
let version_id = entry
|
|
.get("version-id")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("view version-log entry is missing version-id".to_string()))?;
|
|
if entry.get("timestamp-ms").and_then(serde_json::Value::as_i64).is_none() {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"view version-log entries require integer version-id and timestamp-ms".to_string(),
|
|
));
|
|
}
|
|
if !version_ids.contains(&version_id) {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"view version-log targets view version {version_id}, which does not exist"
|
|
)));
|
|
}
|
|
}
|
|
if let Some(properties) = object.get("properties") {
|
|
validate_string_map(properties, "view metadata properties")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn view_metadata_array<'a>(metadata: &'a serde_json::Value, field: &str) -> TableCatalogStoreResult<&'a Vec<serde_json::Value>> {
|
|
metadata
|
|
.get(field)
|
|
.and_then(serde_json::Value::as_array)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("view metadata is missing array field {field}")))
|
|
}
|
|
|
|
fn validate_view_version(version: &serde_json::Value) -> TableCatalogStoreResult<()> {
|
|
let version = version
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("view version must be a JSON object".to_string()))?;
|
|
for field in ["version-id", "timestamp-ms", "schema-id"] {
|
|
if version.get(field).and_then(serde_json::Value::as_i64).is_none() {
|
|
return Err(TableCatalogStoreError::Invalid(format!("view version is missing integer field {field}")));
|
|
}
|
|
}
|
|
let summary = version
|
|
.get("summary")
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("view version is missing summary".to_string()))?;
|
|
validate_string_map(summary, "view version summary")?;
|
|
let default_namespace = version
|
|
.get("default-namespace")
|
|
.and_then(serde_json::Value::as_array)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("view version is missing default-namespace".to_string()))?;
|
|
if default_namespace.iter().any(|segment| !segment.is_string()) {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"view version default-namespace must contain strings".to_string(),
|
|
));
|
|
}
|
|
if version.get("default-catalog").is_some_and(|value| !value.is_string()) {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"view version default-catalog must be a string".to_string(),
|
|
));
|
|
}
|
|
let representations = version
|
|
.get("representations")
|
|
.and_then(serde_json::Value::as_array)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("view version is missing representations".to_string()))?;
|
|
let mut dialects = BTreeSet::new();
|
|
for representation in representations {
|
|
let representation = representation
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("view representation must be a JSON object".to_string()))?;
|
|
let dialect = representation
|
|
.get("dialect")
|
|
.and_then(serde_json::Value::as_str)
|
|
.filter(|dialect| !dialect.is_empty());
|
|
if representation.get("type").and_then(serde_json::Value::as_str) != Some("sql")
|
|
|| representation.get("sql").and_then(serde_json::Value::as_str).is_none()
|
|
|| dialect.is_none()
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"view representation requires type sql, sql, and dialect strings".to_string(),
|
|
));
|
|
}
|
|
if !dialects.insert(
|
|
dialect
|
|
.ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid("view representation dialect must be a non-empty string".to_string())
|
|
})?
|
|
.to_lowercase(),
|
|
) {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"view version contains duplicate SQL dialect representations".to_string(),
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_string_map(value: &serde_json::Value, label: &str) -> TableCatalogStoreResult<()> {
|
|
let values = value
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label} must be a JSON object")))?;
|
|
if values.values().any(|value| !value.is_string()) {
|
|
return Err(TableCatalogStoreError::Invalid(format!("{label} values must be strings")));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn metadata_object_mut(
|
|
metadata: &mut serde_json::Value,
|
|
) -> TableCatalogStoreResult<&mut serde_json::Map<String, serde_json::Value>> {
|
|
metadata
|
|
.as_object_mut()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("table metadata must be a JSON object".to_string()))
|
|
}
|
|
|
|
fn metadata_array_ids(
|
|
metadata: &serde_json::Value,
|
|
array_field: &str,
|
|
id_field: &str,
|
|
label: &str,
|
|
) -> TableCatalogStoreResult<BTreeSet<i64>> {
|
|
let Some(values) = metadata.get(array_field) else {
|
|
return Ok(BTreeSet::new());
|
|
};
|
|
let values = values
|
|
.as_array()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{array_field} must be an array")))?;
|
|
let mut ids = BTreeSet::new();
|
|
for value in values {
|
|
let id = value
|
|
.get(id_field)
|
|
.and_then(serde_json::Value::as_i64)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{label} is missing {id_field}")))?;
|
|
if !ids.insert(id) {
|
|
return Err(TableCatalogStoreError::Invalid(format!("duplicate {label} id {id}")));
|
|
}
|
|
}
|
|
Ok(ids)
|
|
}
|
|
|
|
fn metadata_array_i32_ids(
|
|
metadata: &serde_json::Value,
|
|
array_field: &str,
|
|
id_field: &str,
|
|
label: &str,
|
|
) -> TableCatalogStoreResult<BTreeSet<i64>> {
|
|
let ids = metadata_array_ids(metadata, array_field, id_field, label)?;
|
|
if let Some(id) = ids.iter().find(|id| **id < 0) {
|
|
return Err(TableCatalogStoreError::Invalid(format!("{label} id {id} must not be negative")));
|
|
}
|
|
if let Some(id) = ids.iter().find(|id| i32::try_from(**id).is_err()) {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"{label} id {id} exceeds the signed 32-bit range"
|
|
)));
|
|
}
|
|
Ok(ids)
|
|
}
|
|
|
|
fn validate_metadata_id_reference(
|
|
metadata: &serde_json::Value,
|
|
reference_field: &str,
|
|
ids: &BTreeSet<i64>,
|
|
label: &str,
|
|
) -> TableCatalogStoreResult<()> {
|
|
let Some(value) = metadata.get(reference_field) else {
|
|
return Ok(());
|
|
};
|
|
let id = value
|
|
.as_i64()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{reference_field} must be an integer")))?;
|
|
if !ids.contains(&id) {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"{reference_field} targets {label} {id}, which does not exist"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn require_metadata_i64(metadata: &serde_json::Value, field: &str) -> TableCatalogStoreResult<i64> {
|
|
metadata
|
|
.get(field)
|
|
.and_then(serde_json::Value::as_i64)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("table metadata is missing integer field {field}")))
|
|
}
|
|
|
|
fn require_metadata_i32(metadata: &serde_json::Value, field: &str) -> TableCatalogStoreResult<i32> {
|
|
let value = require_metadata_i64(metadata, field)?;
|
|
i32::try_from(value)
|
|
.map_err(|_| TableCatalogStoreError::Invalid(format!("table metadata field {field} exceeds the signed 32-bit range")))
|
|
}
|
|
|
|
fn require_metadata_array<'a>(
|
|
metadata: &'a serde_json::Value,
|
|
field: &str,
|
|
) -> TableCatalogStoreResult<&'a Vec<serde_json::Value>> {
|
|
metadata
|
|
.get(field)
|
|
.and_then(serde_json::Value::as_array)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("table metadata is missing array field {field}")))
|
|
}
|
|
|
|
fn max_partition_field_id(value: &serde_json::Value) -> i64 {
|
|
let mut max_id = 999;
|
|
let Some(fields) = value.get("fields").and_then(serde_json::Value::as_array) else {
|
|
return max_id;
|
|
};
|
|
for field in fields {
|
|
if let Some(field_id) = field.get("field-id").and_then(serde_json::Value::as_i64) {
|
|
max_id = max_id.max(field_id);
|
|
}
|
|
}
|
|
max_id
|
|
}
|
|
|
|
pub(crate) struct TableSnapshotGraphValidationContext<'a, B> {
|
|
backend: &'a B,
|
|
table_bucket: &'a str,
|
|
entry: &'a TableEntry,
|
|
}
|
|
|
|
impl<'a, B> TableSnapshotGraphValidationContext<'a, B> {
|
|
pub(crate) fn new(backend: &'a B, table_bucket: &'a str, entry: &'a TableEntry) -> Self {
|
|
Self {
|
|
backend,
|
|
table_bucket,
|
|
entry,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct SnapshotGraphReadBudget {
|
|
manifest_count: usize,
|
|
manifest_traversal_count: usize,
|
|
avro_bytes: usize,
|
|
decoded_avro_bytes: usize,
|
|
file_reference_count: usize,
|
|
manifest_lists: BTreeMap<String, Arc<Vec<SnapshotGraphManifestLocation>>>,
|
|
manifests: BTreeMap<String, CachedSnapshotGraphManifest>,
|
|
validated_live_objects: BTreeSet<String>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct CachedSnapshotGraphManifest {
|
|
object_size: usize,
|
|
partition_spec_id: Option<i32>,
|
|
references: Arc<Vec<ManifestDataFileReference>>,
|
|
}
|
|
|
|
impl SnapshotGraphReadBudget {
|
|
fn charge_manifests(&mut self, count: usize) -> TableCatalogStoreResult<()> {
|
|
self.manifest_count = self
|
|
.manifest_count
|
|
.checked_add(count)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("snapshot manifest count exceeds the commit limit".to_string()))?;
|
|
if self.manifest_count > TABLE_COMMIT_MAX_MANIFESTS {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"snapshot manifest count exceeds the commit limit".to_string(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn charge_manifest_traversals(&mut self, count: usize) -> TableCatalogStoreResult<()> {
|
|
self.manifest_traversal_count = self.manifest_traversal_count.checked_add(count).ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid("snapshot manifest traversal count exceeds the commit limit".to_string())
|
|
})?;
|
|
if self.manifest_traversal_count > TABLE_COMMIT_MAX_MANIFEST_TRAVERSALS {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"snapshot manifest traversal count exceeds the commit limit".to_string(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn charge_avro_bytes(&mut self, count: usize) -> TableCatalogStoreResult<()> {
|
|
self.avro_bytes = self
|
|
.avro_bytes
|
|
.checked_add(count)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("snapshot Avro bytes exceed the commit limit".to_string()))?;
|
|
if self.avro_bytes > TABLE_COMMIT_MAX_AVRO_BYTES {
|
|
return Err(TableCatalogStoreError::Invalid("snapshot Avro bytes exceed the commit limit".to_string()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn charge_decoded_avro_bytes(&mut self, count: usize) -> TableCatalogStoreResult<()> {
|
|
self.decoded_avro_bytes = self
|
|
.decoded_avro_bytes
|
|
.checked_add(count)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("snapshot decoded Avro bytes exceed the commit limit".to_string()))?;
|
|
if self.decoded_avro_bytes > TABLE_COMMIT_MAX_AVRO_BYTES {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"snapshot decoded Avro bytes exceed the commit limit".to_string(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn charge_file_references(&mut self, count: usize) -> TableCatalogStoreResult<()> {
|
|
self.file_reference_count = self
|
|
.file_reference_count
|
|
.checked_add(count)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("snapshot file references exceed the commit limit".to_string()))?;
|
|
if self.file_reference_count > TABLE_COMMIT_MAX_FILE_REFERENCES {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"snapshot file references exceed the commit limit".to_string(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
struct SnapshotGraphManifestLocation {
|
|
manifest_path: String,
|
|
format_version: u16,
|
|
manifest_length: Option<u64>,
|
|
partition_spec_id: Option<i32>,
|
|
content: Option<i32>,
|
|
sequence_number: Option<i64>,
|
|
min_sequence_number: Option<i64>,
|
|
added_snapshot_id: Option<i64>,
|
|
added_files_count: Option<u64>,
|
|
existing_files_count: Option<u64>,
|
|
deleted_files_count: Option<u64>,
|
|
added_rows_count: Option<u64>,
|
|
existing_rows_count: Option<u64>,
|
|
deleted_rows_count: Option<u64>,
|
|
from_manifest_list: bool,
|
|
}
|
|
|
|
pub(crate) async fn validate_table_snapshot_changes<B>(
|
|
context: &TableSnapshotGraphValidationContext<'_, B>,
|
|
current_metadata: Option<&serde_json::Value>,
|
|
metadata: &serde_json::Value,
|
|
) -> TableCatalogStoreResult<()>
|
|
where
|
|
B: TableCatalogObjectBackend,
|
|
{
|
|
validate_supported_table_metadata_fields(metadata)?;
|
|
let snapshot_ids = metadata_array_ids(metadata, "snapshots", "snapshot-id", "snapshot")?;
|
|
validate_table_statistics_references(metadata, &snapshot_ids)?;
|
|
let mut budget = SnapshotGraphReadBudget::default();
|
|
validate_table_statistics_objects(context, metadata).await?;
|
|
let format_version = table_metadata_format_version(metadata)?;
|
|
let snapshots = snapshots_requiring_graph_validation(current_metadata, metadata)?;
|
|
for snapshot in snapshots {
|
|
snapshot
|
|
.get("snapshot-id")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("snapshot-id must be an integer".to_string()))?;
|
|
if format_version == 2
|
|
&& snapshot.get("manifests").is_some()
|
|
&& !snapshot_is_retained_v1_history(current_metadata, snapshot)
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"new Iceberg v2 snapshots require manifest-list".to_string(),
|
|
));
|
|
}
|
|
let snapshot_sequence_number = snapshot
|
|
.get("sequence-number")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.unwrap_or(0);
|
|
let manifests = snapshot_graph_manifest_references(
|
|
context,
|
|
metadata,
|
|
snapshot,
|
|
format_version,
|
|
snapshot_sequence_number,
|
|
&mut budget,
|
|
)
|
|
.await?;
|
|
let mut seen_files = BTreeSet::new();
|
|
for references in &manifests {
|
|
for reference in references.iter() {
|
|
if !seen_files.insert(reference.location.as_str()) {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"snapshot contains a duplicate file reference".to_string(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn snapshot_is_retained_v1_history(current_metadata: Option<&serde_json::Value>, target_snapshot: &serde_json::Value) -> bool {
|
|
let Some(current_metadata) = current_metadata else {
|
|
return false;
|
|
};
|
|
if table_metadata_format_version(current_metadata).ok() != Some(1) {
|
|
return false;
|
|
}
|
|
let Some(snapshot_id) = target_snapshot.get("snapshot-id").and_then(serde_json::Value::as_i64) else {
|
|
return false;
|
|
};
|
|
let Some(current_snapshot) = current_metadata
|
|
.get("snapshots")
|
|
.and_then(serde_json::Value::as_array)
|
|
.and_then(|snapshots| {
|
|
snapshots
|
|
.iter()
|
|
.find(|snapshot| snapshot.get("snapshot-id").and_then(serde_json::Value::as_i64) == Some(snapshot_id))
|
|
})
|
|
else {
|
|
return false;
|
|
};
|
|
let mut current_snapshot = current_snapshot.clone();
|
|
let mut target_snapshot = target_snapshot.clone();
|
|
for snapshot in [&mut current_snapshot, &mut target_snapshot] {
|
|
if snapshot.get("sequence-number").and_then(serde_json::Value::as_i64) == Some(0)
|
|
&& let Some(object) = snapshot.as_object_mut()
|
|
{
|
|
object.remove("sequence-number");
|
|
}
|
|
}
|
|
current_snapshot == target_snapshot
|
|
}
|
|
|
|
async fn validate_table_statistics_objects<B>(
|
|
context: &TableSnapshotGraphValidationContext<'_, B>,
|
|
metadata: &serde_json::Value,
|
|
) -> TableCatalogStoreResult<()>
|
|
where
|
|
B: TableCatalogObjectBackend,
|
|
{
|
|
if context.entry.table_bucket != context.table_bucket {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"statistics object is outside the table bucket".to_string(),
|
|
));
|
|
}
|
|
let warehouse_object_prefix = table_warehouse_object_prefix(context.entry)?;
|
|
let mut objects = BTreeMap::new();
|
|
let mut total_size = 0usize;
|
|
for (field, kind) in [
|
|
("statistics", IcebergStatisticsFileKind::Table),
|
|
("partition-statistics", IcebergStatisticsFileKind::Partition),
|
|
] {
|
|
let Some(values) = metadata.get(field).and_then(serde_json::Value::as_array) else {
|
|
continue;
|
|
};
|
|
for value in values {
|
|
let location = value
|
|
.get("statistics-path")
|
|
.and_then(serde_json::Value::as_str)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{field}.statistics-path must be a string")))?;
|
|
let object_key = table_catalog_object_key_from_location(context.table_bucket, location)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid(format!("{field} object location is invalid")))?;
|
|
if !object_key.starts_with(&warehouse_object_prefix) {
|
|
return Err(TableCatalogStoreError::Invalid(format!("{field} object is outside the table warehouse")));
|
|
}
|
|
let file_size = value
|
|
.get("file-size-in-bytes")
|
|
.and_then(serde_json::Value::as_u64)
|
|
.and_then(|size| usize::try_from(size).ok())
|
|
.filter(|size| *size <= TABLE_STATISTICS_FILE_MAX_SIZE)
|
|
.ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid(format!("{field} file-size-in-bytes exceeds the validation limit"))
|
|
})?;
|
|
if let Some(previous) = objects.get(&object_key) {
|
|
if *previous != (file_size, kind) {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"statistics object is declared with inconsistent metadata".to_string(),
|
|
));
|
|
}
|
|
continue;
|
|
}
|
|
total_size = total_size
|
|
.checked_add(file_size)
|
|
.filter(|size| *size <= TABLE_COMMIT_MAX_STATISTICS_BYTES)
|
|
.ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid("statistics bytes exceed the commit validation limit".to_string())
|
|
})?;
|
|
objects.insert(object_key, (file_size, kind));
|
|
}
|
|
}
|
|
|
|
if objects.len() > TABLE_COMMIT_MAX_STATISTICS_OBJECTS {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"statistics object count exceeds the commit limit".to_string(),
|
|
));
|
|
}
|
|
let backend = context.backend.clone();
|
|
let bucket = context.table_bucket.to_string();
|
|
stream::iter(objects)
|
|
.map(move |(object_key, (expected_size, kind))| {
|
|
let backend = backend.clone();
|
|
let bucket = bucket.clone();
|
|
async move {
|
|
let object = backend
|
|
.read_object_limited(&bucket, &object_key, expected_size)
|
|
.await?
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("statistics object is missing".to_string()))?;
|
|
if object.data.len() != expected_size {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"statistics file-size-in-bytes does not match the object".to_string(),
|
|
));
|
|
}
|
|
let valid_magic = match kind {
|
|
IcebergStatisticsFileKind::Table => object.data.starts_with(b"PFA1") && object.data.ends_with(b"PFA1"),
|
|
IcebergStatisticsFileKind::Partition => object.data.starts_with(b"PAR1") && object.data.ends_with(b"PAR1"),
|
|
};
|
|
if !valid_magic {
|
|
return Err(TableCatalogStoreError::Invalid(match kind {
|
|
IcebergStatisticsFileKind::Table => "table statistics object is not a Puffin file".to_string(),
|
|
IcebergStatisticsFileKind::Partition => "partition statistics object is not a Parquet file".to_string(),
|
|
}));
|
|
}
|
|
Ok(())
|
|
}
|
|
})
|
|
.buffered(TABLE_COMMIT_OBJECT_VALIDATION_CONCURRENCY)
|
|
.try_for_each(|()| async { Ok(()) })
|
|
.await
|
|
}
|
|
|
|
async fn validate_object_keys_exist<B>(
|
|
backend: &B,
|
|
bucket: &str,
|
|
object_keys: impl IntoIterator<Item = String>,
|
|
missing_message: &'static str,
|
|
) -> TableCatalogStoreResult<()>
|
|
where
|
|
B: TableCatalogObjectBackend,
|
|
{
|
|
let backend = backend.clone();
|
|
let bucket = bucket.to_string();
|
|
stream::iter(object_keys)
|
|
.map(move |object_key| {
|
|
let backend = backend.clone();
|
|
let bucket = bucket.clone();
|
|
async move {
|
|
if !backend.object_exists(&bucket, &object_key).await? {
|
|
return Err(TableCatalogStoreError::Invalid(missing_message.to_string()));
|
|
}
|
|
Ok(())
|
|
}
|
|
})
|
|
.buffered(TABLE_COMMIT_OBJECT_VALIDATION_CONCURRENCY)
|
|
.try_for_each(|()| async { Ok(()) })
|
|
.await
|
|
}
|
|
|
|
fn snapshots_requiring_graph_validation<'a>(
|
|
current_metadata: Option<&serde_json::Value>,
|
|
metadata: &'a serde_json::Value,
|
|
) -> TableCatalogStoreResult<Vec<&'a serde_json::Value>> {
|
|
let Some(snapshots) = metadata.get("snapshots") else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let snapshots = snapshots
|
|
.as_array()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("snapshots must be an array".to_string()))?;
|
|
|
|
if let Some(current_metadata) = current_metadata {
|
|
if !partition_specs_preserve_existing_definitions(current_metadata, metadata)? {
|
|
return Ok(snapshots.iter().collect());
|
|
}
|
|
let current_snapshots = current_metadata
|
|
.get("snapshots")
|
|
.and_then(serde_json::Value::as_array)
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|snapshot| {
|
|
snapshot
|
|
.get("snapshot-id")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.map(|snapshot_id| (snapshot_id, snapshot))
|
|
})
|
|
.collect::<BTreeMap<_, _>>();
|
|
return Ok(snapshots
|
|
.iter()
|
|
.filter_map(|snapshot| {
|
|
let snapshot_id = snapshot.get("snapshot-id").and_then(serde_json::Value::as_i64)?;
|
|
(current_snapshots.get(&snapshot_id).copied() != Some(snapshot)).then_some(snapshot)
|
|
})
|
|
.collect());
|
|
}
|
|
|
|
Ok(snapshots.iter().collect())
|
|
}
|
|
|
|
fn partition_specs_preserve_existing_definitions(
|
|
current_metadata: &serde_json::Value,
|
|
metadata: &serde_json::Value,
|
|
) -> TableCatalogStoreResult<bool> {
|
|
let current_specs = normalized_partition_spec_definitions(current_metadata)?;
|
|
let target_specs = normalized_partition_spec_definitions(metadata)?;
|
|
Ok(current_specs
|
|
.iter()
|
|
.all(|(spec_id, fields)| target_specs.get(spec_id) == Some(fields)))
|
|
}
|
|
|
|
type NormalizedPartitionFieldDefinition = (i32, i32, String, String);
|
|
type NormalizedPartitionSpecDefinitions = BTreeMap<i32, Vec<NormalizedPartitionFieldDefinition>>;
|
|
|
|
fn normalized_partition_spec_definitions(
|
|
metadata: &serde_json::Value,
|
|
) -> TableCatalogStoreResult<NormalizedPartitionSpecDefinitions> {
|
|
let format_version = table_metadata_format_version(metadata)?;
|
|
let specs = if format_version == 1 {
|
|
vec![(0, require_metadata_array(metadata, "partition-spec")?)]
|
|
} else {
|
|
require_metadata_array(metadata, "partition-specs")?
|
|
.iter()
|
|
.map(|spec| {
|
|
let spec = spec
|
|
.as_object()
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("partition specs must be JSON objects".to_string()))?;
|
|
let spec_id = required_i32_value(spec, "spec-id", "partition spec-id")?;
|
|
let fields = spec
|
|
.get("fields")
|
|
.and_then(serde_json::Value::as_array)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("partition spec fields must be an array".to_string()))?;
|
|
Ok((spec_id, fields))
|
|
})
|
|
.collect::<TableCatalogStoreResult<Vec<_>>>()?
|
|
};
|
|
specs
|
|
.into_iter()
|
|
.map(|(spec_id, fields)| {
|
|
let fields = fields
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, field)| {
|
|
let field = field.as_object().ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid("partition spec fields must be JSON objects".to_string())
|
|
})?;
|
|
let source_id = required_positive_i32_value(field, "source-id", "partition source-id")?;
|
|
let field_id = match field.get("field-id") {
|
|
Some(field_id) => required_positive_i32(field_id, "partition field-id")?,
|
|
None if format_version == 1 => i32::try_from(index)
|
|
.ok()
|
|
.and_then(|index| 1000_i32.checked_add(index))
|
|
.ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid("Iceberg v1 partition spec has too many fields".to_string())
|
|
})?,
|
|
None => {
|
|
return Err(TableCatalogStoreError::Invalid("partition field-id is required".to_string()));
|
|
}
|
|
};
|
|
let transform = field.get("transform").and_then(serde_json::Value::as_str).ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid("partition field transform must be a string".to_string())
|
|
})?;
|
|
let name = field
|
|
.get("name")
|
|
.and_then(serde_json::Value::as_str)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("partition field name must be a string".to_string()))?;
|
|
Ok((source_id, field_id, name.to_string(), transform.to_string()))
|
|
})
|
|
.collect::<TableCatalogStoreResult<Vec<_>>>()?;
|
|
Ok((spec_id, fields))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
async fn snapshot_graph_manifest_references<B>(
|
|
context: &TableSnapshotGraphValidationContext<'_, B>,
|
|
metadata: &serde_json::Value,
|
|
snapshot: &serde_json::Value,
|
|
format_version: u16,
|
|
snapshot_sequence_number: i64,
|
|
budget: &mut SnapshotGraphReadBudget,
|
|
) -> TableCatalogStoreResult<Vec<Arc<Vec<ManifestDataFileReference>>>>
|
|
where
|
|
B: TableCatalogObjectBackend,
|
|
{
|
|
let manifest_locations =
|
|
snapshot_graph_manifest_locations(context, snapshot, format_version, snapshot_sequence_number, budget).await?;
|
|
let partition_spec_ids = table_metadata_partition_spec_ids(metadata)?;
|
|
let mut manifests = Vec::with_capacity(manifest_locations.len());
|
|
let mut seen_manifest_paths = BTreeSet::new();
|
|
for manifest_location in manifest_locations.iter() {
|
|
budget.charge_manifest_traversals(1)?;
|
|
validate_snapshot_graph_manifest_location(manifest_location, format_version, snapshot_sequence_number)?;
|
|
match manifest_location.partition_spec_id {
|
|
Some(partition_spec_id) if !partition_spec_ids.contains(&partition_spec_id) => {
|
|
return Err(TableCatalogStoreError::Invalid(format!(
|
|
"snapshot manifest references missing partition spec {partition_spec_id}"
|
|
)));
|
|
}
|
|
None if manifest_location.from_manifest_list => {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"manifest-list entry is missing partition_spec_id".to_string(),
|
|
));
|
|
}
|
|
_ => {}
|
|
}
|
|
if !seen_manifest_paths.insert(manifest_location.manifest_path.as_str()) {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"snapshot contains a duplicate manifest reference".to_string(),
|
|
));
|
|
}
|
|
let manifest_key = snapshot_graph_object_key(
|
|
context,
|
|
&manifest_location.manifest_path,
|
|
TableMetadataMaintenanceObjectKind::ManifestFile,
|
|
)?;
|
|
let cached_manifest = if let Some(manifest) = budget.manifests.get(&manifest_key) {
|
|
manifest.clone()
|
|
} else {
|
|
budget.charge_manifests(1)?;
|
|
let manifest_object = context
|
|
.backend
|
|
.read_object_limited(context.table_bucket, &manifest_key, TABLE_MANIFEST_AVRO_MAX_SIZE)
|
|
.await?
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("snapshot manifest object is missing".to_string()))?;
|
|
let manifest_size = manifest_object.data.len();
|
|
budget.charge_avro_bytes(manifest_size)?;
|
|
let decoded_manifest = decode_manifest_avro_async(manifest_object.data).await?;
|
|
budget.charge_decoded_avro_bytes(decoded_manifest.decoded_size)?;
|
|
let manifest = CachedSnapshotGraphManifest {
|
|
object_size: manifest_size,
|
|
partition_spec_id: decoded_manifest.partition_spec_id,
|
|
references: Arc::new(decoded_manifest.references),
|
|
};
|
|
budget.manifests.insert(manifest_key, manifest.clone());
|
|
manifest
|
|
};
|
|
if manifest_location
|
|
.manifest_length
|
|
.is_some_and(|declared| u64::try_from(cached_manifest.object_size).ok() != Some(declared))
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"manifest-list manifest_length does not match the manifest object".to_string(),
|
|
));
|
|
}
|
|
if manifest_location.from_manifest_list
|
|
&& cached_manifest
|
|
.partition_spec_id
|
|
.is_some_and(|manifest_spec_id| Some(manifest_spec_id) != manifest_location.partition_spec_id)
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"manifest partition-spec-id does not match its manifest-list entry".to_string(),
|
|
));
|
|
}
|
|
let references = cached_manifest.references;
|
|
validate_snapshot_graph_manifest_content(manifest_location, references.as_ref())?;
|
|
budget.charge_file_references(references.len())?;
|
|
validate_snapshot_graph_data_files(
|
|
context,
|
|
references.as_ref(),
|
|
budget,
|
|
format_version,
|
|
if manifest_location.from_manifest_list && manifest_location.format_version == 1 {
|
|
0
|
|
} else {
|
|
manifest_location.sequence_number.unwrap_or(snapshot_sequence_number)
|
|
},
|
|
)
|
|
.await?;
|
|
manifests.push(references);
|
|
}
|
|
Ok(manifests)
|
|
}
|
|
|
|
fn validate_snapshot_graph_manifest_content(
|
|
manifest: &SnapshotGraphManifestLocation,
|
|
references: &[ManifestDataFileReference],
|
|
) -> TableCatalogStoreResult<()> {
|
|
let content_matches = match manifest.content {
|
|
Some(0) => references
|
|
.iter()
|
|
.all(|reference| reference.content == ManifestDataFileContent::Data),
|
|
Some(1) => references
|
|
.iter()
|
|
.all(|reference| reference.content != ManifestDataFileContent::Data),
|
|
None => true,
|
|
Some(_) => false,
|
|
};
|
|
if !content_matches {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"manifest-list content does not match manifest file content".to_string(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn snapshot_graph_manifest_locations<B>(
|
|
context: &TableSnapshotGraphValidationContext<'_, B>,
|
|
snapshot: &serde_json::Value,
|
|
format_version: u16,
|
|
snapshot_sequence_number: i64,
|
|
budget: &mut SnapshotGraphReadBudget,
|
|
) -> TableCatalogStoreResult<Arc<Vec<SnapshotGraphManifestLocation>>>
|
|
where
|
|
B: TableCatalogObjectBackend,
|
|
{
|
|
if let Some(manifest_list_location) = snapshot.get("manifest-list").and_then(serde_json::Value::as_str) {
|
|
let manifest_list_key =
|
|
snapshot_graph_object_key(context, manifest_list_location, TableMetadataMaintenanceObjectKind::ManifestList)?;
|
|
let references = if let Some(references) = budget.manifest_lists.get(&manifest_list_key) {
|
|
return Ok(Arc::clone(references));
|
|
} else {
|
|
let manifest_list_object = context
|
|
.backend
|
|
.read_object_limited(context.table_bucket, &manifest_list_key, TABLE_MANIFEST_AVRO_MAX_SIZE)
|
|
.await?
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("snapshot manifest-list object is missing".to_string()))?;
|
|
budget.charge_avro_bytes(manifest_list_object.data.len())?;
|
|
let decoded_manifest_list = decode_manifest_list_avro_async(manifest_list_object.data).await?;
|
|
budget.charge_decoded_avro_bytes(decoded_manifest_list.decoded_size)?;
|
|
decoded_manifest_list.references
|
|
};
|
|
let references = Arc::new(
|
|
references
|
|
.into_iter()
|
|
.map(|reference| SnapshotGraphManifestLocation {
|
|
manifest_path: reference.manifest_path,
|
|
format_version: reference.format_version,
|
|
manifest_length: reference.manifest_length,
|
|
partition_spec_id: reference.partition_spec_id,
|
|
content: reference.content,
|
|
sequence_number: reference.sequence_number,
|
|
min_sequence_number: reference.min_sequence_number,
|
|
added_snapshot_id: reference.added_snapshot_id,
|
|
added_files_count: reference.added_files_count,
|
|
existing_files_count: reference.existing_files_count,
|
|
deleted_files_count: reference.deleted_files_count,
|
|
added_rows_count: reference.added_rows_count,
|
|
existing_rows_count: reference.existing_rows_count,
|
|
deleted_rows_count: reference.deleted_rows_count,
|
|
from_manifest_list: true,
|
|
})
|
|
.collect(),
|
|
);
|
|
budget.manifest_lists.insert(manifest_list_key, Arc::clone(&references));
|
|
return Ok(references);
|
|
}
|
|
|
|
let Some(manifests) = snapshot.get("manifests").and_then(serde_json::Value::as_array) else {
|
|
return Err(TableCatalogStoreError::Invalid("snapshot manifest-list is required".to_string()));
|
|
};
|
|
Ok(Arc::new(
|
|
manifests
|
|
.iter()
|
|
.map(|manifest| {
|
|
manifest
|
|
.as_str()
|
|
.filter(|manifest| !manifest.is_empty())
|
|
.map(|manifest| SnapshotGraphManifestLocation {
|
|
manifest_path: manifest.to_string(),
|
|
format_version,
|
|
manifest_length: None,
|
|
partition_spec_id: None,
|
|
content: None,
|
|
sequence_number: Some(snapshot_sequence_number),
|
|
min_sequence_number: None,
|
|
added_snapshot_id: None,
|
|
added_files_count: None,
|
|
existing_files_count: None,
|
|
deleted_files_count: None,
|
|
added_rows_count: None,
|
|
existing_rows_count: None,
|
|
deleted_rows_count: None,
|
|
from_manifest_list: false,
|
|
})
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("snapshot manifest location must be a string".to_string()))
|
|
})
|
|
.collect::<TableCatalogStoreResult<Vec<_>>>()?,
|
|
))
|
|
}
|
|
|
|
fn validate_snapshot_graph_manifest_location(
|
|
manifest: &SnapshotGraphManifestLocation,
|
|
table_format_version: u16,
|
|
snapshot_sequence_number: i64,
|
|
) -> TableCatalogStoreResult<()> {
|
|
if !manifest.from_manifest_list {
|
|
return Ok(());
|
|
}
|
|
if !manifest.manifest_length.is_some_and(|length| length > 0) {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"manifest-list entry is missing a positive manifest_length".to_string(),
|
|
));
|
|
}
|
|
manifest
|
|
.added_snapshot_id
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("manifest-list entry is missing added_snapshot_id".to_string()))?;
|
|
|
|
if manifest.format_version > table_format_version {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"manifest-list format version exceeds the table format version".to_string(),
|
|
));
|
|
}
|
|
|
|
match manifest.format_version {
|
|
1 => {
|
|
if manifest.content.is_some_and(|content| content != 0)
|
|
|| manifest.sequence_number.is_some_and(|sequence_number| sequence_number != 0)
|
|
|| manifest
|
|
.min_sequence_number
|
|
.is_some_and(|min_sequence_number| min_sequence_number != 0)
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"Iceberg v1 manifest-list compatibility fields must use data content and sequence zero".to_string(),
|
|
));
|
|
}
|
|
}
|
|
2 => {
|
|
if !matches!(manifest.content, Some(0 | 1)) {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"Iceberg v2 manifest-list content must be data or deletes".to_string(),
|
|
));
|
|
}
|
|
let sequence_number = manifest.sequence_number.ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid("Iceberg v2 manifest-list entry is missing sequence_number".to_string())
|
|
})?;
|
|
let min_sequence_number = manifest.min_sequence_number.ok_or_else(|| {
|
|
TableCatalogStoreError::Invalid("Iceberg v2 manifest-list entry is missing min_sequence_number".to_string())
|
|
})?;
|
|
if min_sequence_number < 0 || sequence_number < min_sequence_number || sequence_number > snapshot_sequence_number {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"Iceberg v2 manifest-list sequence numbers are inconsistent with the snapshot".to_string(),
|
|
));
|
|
}
|
|
}
|
|
_ => {
|
|
return Err(TableCatalogStoreError::Internal(format!(
|
|
"decoded Iceberg manifest-list format version {} is unsupported",
|
|
manifest.format_version
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_snapshot_graph_data_file_reference(
|
|
reference: &ManifestDataFileReference,
|
|
table_format_version: u16,
|
|
manifest_sequence_number: i64,
|
|
) -> TableCatalogStoreResult<()> {
|
|
if reference.record_count.is_none() {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"manifest data file is missing a non-negative record_count".to_string(),
|
|
));
|
|
}
|
|
if reference.file_size_bytes.is_none() {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"manifest data file is missing a non-negative file_size_in_bytes".to_string(),
|
|
));
|
|
}
|
|
|
|
if reference.format_version > table_format_version {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"manifest format version exceeds the table format version".to_string(),
|
|
));
|
|
}
|
|
|
|
match reference.format_version {
|
|
1 => {
|
|
if reference.snapshot_id.is_none() {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"Iceberg v1 manifest entry is missing snapshot_id".to_string(),
|
|
));
|
|
}
|
|
if reference.content_id.is_some_and(|content| content != 0) {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"Iceberg v1 manifest data file content must be data".to_string(),
|
|
));
|
|
}
|
|
if reference.sequence_number.is_some_and(|sequence_number| sequence_number != 0)
|
|
|| reference
|
|
.file_sequence_number
|
|
.is_some_and(|sequence_number| sequence_number != 0)
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"Iceberg v1 manifest sequence numbers must be zero when present".to_string(),
|
|
));
|
|
}
|
|
}
|
|
2 => {
|
|
if reference.content_id.is_none() {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"Iceberg v2 manifest data file is missing content".to_string(),
|
|
));
|
|
}
|
|
for sequence_number in [reference.sequence_number, reference.file_sequence_number]
|
|
.into_iter()
|
|
.flatten()
|
|
{
|
|
if sequence_number < 0 || sequence_number > manifest_sequence_number {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"Iceberg v2 manifest entry sequence number exceeds its manifest sequence".to_string(),
|
|
));
|
|
}
|
|
}
|
|
if !matches!(reference.entry_status, Some(1))
|
|
&& (reference.sequence_number.is_none() || reference.file_sequence_number.is_none())
|
|
{
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"Iceberg v2 existing and deleted manifest entries require sequence numbers".to_string(),
|
|
));
|
|
}
|
|
}
|
|
_ => {
|
|
return Err(TableCatalogStoreError::Internal(format!(
|
|
"decoded Iceberg manifest format version {} is unsupported",
|
|
reference.format_version
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn validate_snapshot_graph_data_files<B>(
|
|
context: &TableSnapshotGraphValidationContext<'_, B>,
|
|
references: &[ManifestDataFileReference],
|
|
budget: &mut SnapshotGraphReadBudget,
|
|
format_version: u16,
|
|
manifest_sequence_number: i64,
|
|
) -> TableCatalogStoreResult<()>
|
|
where
|
|
B: TableCatalogObjectBackend,
|
|
{
|
|
let mut live_object_keys = Vec::with_capacity(references.len());
|
|
for reference in references {
|
|
validate_snapshot_graph_data_file_reference(reference, format_version, manifest_sequence_number)?;
|
|
let object_key = snapshot_graph_object_key(context, &reference.location, reference.object_kind.clone())?;
|
|
match reference.entry_status {
|
|
Some(0 | 1) if budget.validated_live_objects.insert(object_key.clone()) => {
|
|
live_object_keys.push(object_key);
|
|
}
|
|
Some(0 | 1) => {}
|
|
Some(2) => {}
|
|
Some(_) => {
|
|
return Err(TableCatalogStoreError::Invalid("manifest entry status is unsupported".to_string()));
|
|
}
|
|
None => {
|
|
return Err(TableCatalogStoreError::Invalid("manifest entry status is required".to_string()));
|
|
}
|
|
}
|
|
}
|
|
|
|
validate_object_keys_exist(
|
|
context.backend,
|
|
context.table_bucket,
|
|
live_object_keys,
|
|
"manifest referenced data file is missing",
|
|
)
|
|
.await
|
|
}
|
|
|
|
fn snapshot_graph_object_key<B>(
|
|
context: &TableSnapshotGraphValidationContext<'_, B>,
|
|
location: &str,
|
|
expected_kind: TableMetadataMaintenanceObjectKind,
|
|
) -> TableCatalogStoreResult<String> {
|
|
if context.entry.table_bucket != context.table_bucket {
|
|
return Err(TableCatalogStoreError::Invalid("snapshot object is outside the table bucket".to_string()));
|
|
}
|
|
let object_key = table_catalog_object_key_from_location(context.table_bucket, location)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("snapshot object location is invalid".to_string()))?;
|
|
let warehouse_object_prefix = table_warehouse_object_prefix(context.entry)?;
|
|
let object_kind = table_maintenance_object_kind_for_entry(context.entry, Some(&warehouse_object_prefix), &object_key)
|
|
.ok_or_else(|| TableCatalogStoreError::Invalid("snapshot object is outside the table warehouse".to_string()))?;
|
|
if !table_maintenance_object_kind_matches_reference(&object_kind, &expected_kind) {
|
|
return Err(TableCatalogStoreError::Invalid(
|
|
"snapshot object kind does not match manifest metadata".to_string(),
|
|
));
|
|
}
|
|
Ok(object_key)
|
|
}
|