diff --git a/crates/iceberg/src/catalog/mod.rs b/crates/iceberg/src/catalog/mod.rs index f3a521379e..d8ddb4415f 100644 --- a/crates/iceberg/src/catalog/mod.rs +++ b/crates/iceberg/src/catalog/mod.rs @@ -41,9 +41,11 @@ use uuid::Uuid; use crate::spec::{ EncryptedKey, FormatVersion, PartitionStatisticsFile, Schema, SchemaId, Snapshot, SnapshotReference, SortOrder, StatisticsFile, TableMetadata, TableMetadataBuilder, - UnboundPartitionSpec, ViewFormatVersion, ViewRepresentations, ViewVersion, + UnboundPartitionSpec, ViewFormatVersion, ViewMetadata, ViewMetadataBuilder, + ViewRepresentations, ViewVersion, }; use crate::table::Table; +use crate::view::View; use crate::{Error, ErrorKind, Result}; /// The catalog API for Iceberg Rust. @@ -966,6 +968,125 @@ pub enum ViewUpdate { }, } +impl ViewUpdate { + /// Applies the update to the view metadata builder. + pub fn apply(self, builder: ViewMetadataBuilder) -> Result { + match self { + ViewUpdate::AssignUuid { uuid } => Ok(builder.assign_uuid(uuid)), + ViewUpdate::UpgradeFormatVersion { format_version } => { + builder.upgrade_format_version(format_version) + } + ViewUpdate::AddSchema { schema, .. } => Ok(builder.add_schema(schema)), + ViewUpdate::SetLocation { location } => Ok(builder.set_location(location)), + ViewUpdate::SetProperties { updates } => builder.set_properties(updates), + ViewUpdate::RemoveProperties { removals } => Ok(builder.remove_properties(&removals)), + ViewUpdate::AddViewVersion { view_version } => builder.add_version(view_version), + ViewUpdate::SetCurrentViewVersion { view_version_id } => { + builder.set_current_version_id(view_version_id) + } + } + } +} + +/// The builder is marked as private since it's dangerous and error-prone to construct +/// [`ViewCommit`] directly. +#[derive(Debug, TypedBuilder)] +#[builder(build_method(vis = "pub(crate)"))] +pub struct ViewCommit { + /// The view ident. + ident: TableIdent, + /// The requirements of the view. + /// + /// Commit will fail if the requirements are not met. + requirements: Vec, + /// The updates of the view. + updates: Vec, +} + +impl ViewCommit { + /// Return the view identifier. + pub fn identifier(&self) -> &TableIdent { + &self.ident + } + + /// Take all requirements. + pub fn take_requirements(&mut self) -> Vec { + take(&mut self.requirements) + } + + /// Take all updates. + pub fn take_updates(&mut self) -> Vec { + take(&mut self.updates) + } + + /// Applies this [`ViewCommit`] to the given [`View`] as part of a catalog update. + /// Typically used by catalog implementations to validate requirements and apply + /// metadata updates. + /// + /// Returns a new [`View`] with updated metadata, + /// or an error if validation or application fails. + pub fn apply(self, view: View) -> Result { + for requirement in &self.requirements { + requirement.check(Some(view.metadata()))?; + } + + let mut metadata_builder = view.metadata().clone().into_builder(); + for update in self.updates { + metadata_builder = update.apply(metadata_builder)?; + } + + let new_metadata = metadata_builder.build()?; + + Ok(view.with_metadata(Arc::new(new_metadata.metadata))) + } +} + +/// Requirements are used as preconditions for view commits to ensure +/// optimistic concurrency control. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type")] +pub enum ViewRequirement { + /// The view UUID must match the requirement. + #[serde(rename = "assert-view-uuid")] + AssertViewUuid { + /// Expected UUID of the view. + uuid: Uuid, + }, +} + +impl ViewRequirement { + /// Check that the requirement is met by the view metadata. + /// If the requirement is not met, an appropriate error is returned. + /// Provide metadata as `None` if the view does not exist. + pub fn check(&self, metadata: Option<&ViewMetadata>) -> Result<()> { + if let Some(metadata) = metadata { + match self { + ViewRequirement::AssertViewUuid { uuid } => { + if metadata.uuid() != *uuid { + return Err(Error::new( + ErrorKind::DataInvalid, + "Requirement failed: View UUID does not match", + ) + .with_context("expected", *uuid) + .with_context("found", metadata.uuid())); + } + } + } + } else { + match self { + ViewRequirement::AssertViewUuid { .. } => { + return Err(Error::new( + ErrorKind::DataInvalid, + "Requirement failed: View does not exist", + )); + } + } + } + + Ok(()) + } +} + mod _serde_set_statistics { // The rest spec requires an additional field `snapshot-id` // that is redundant with the `snapshot_id` field in the statistics file. @@ -1025,17 +1146,18 @@ mod tests { use serde::de::DeserializeOwned; use uuid::uuid; - use super::ViewUpdate; + use super::{ViewCommit, ViewRequirement, ViewUpdate}; use crate::io::FileIOBuilder; use crate::spec::{ BlobMetadata, EncryptedKey, FormatVersion, MAIN_BRANCH, NestedField, NullOrder, Operation, PartitionStatisticsFile, PrimitiveType, Schema, Snapshot, SnapshotReference, SnapshotRetention, SortDirection, SortField, SortOrder, SqlViewRepresentation, StatisticsFile, Summary, TableMetadata, TableMetadataBuilder, Transform, Type, - UnboundPartitionSpec, ViewFormatVersion, ViewRepresentation, ViewRepresentations, - ViewVersion, + UnboundPartitionSpec, ViewFormatVersion, ViewMetadata, ViewMetadataBuilder, + ViewRepresentation, ViewRepresentations, ViewVersion, ViewVersionLog, }; use crate::table::Table; + use crate::view::View; use crate::{ NamespaceIdent, TableCommit, TableCreation, TableIdent, TableRequirement, TableUpdate, }; @@ -2405,4 +2527,223 @@ mod tests { "s3://bucket/test/new_location/data", ); } + + fn test_view_metadata() -> ViewMetadata { + let schema = Schema::builder() + .with_schema_id(1) + .with_fields(vec![std::sync::Arc::new( + NestedField::optional(1, "event_count", Type::Primitive(PrimitiveType::Int)) + .with_doc("Count of events"), + )]) + .build() + .unwrap(); + + let version = ViewVersion::builder() + .with_version_id(1) + .with_timestamp_ms(1573518431292) + .with_schema_id(1) + .with_default_catalog("prod".to_string().into()) + .with_default_namespace(NamespaceIdent::from_vec(vec!["default".to_string()]).unwrap()) + .with_summary(HashMap::from_iter(vec![( + "engine-name".to_string(), + "Spark".to_string(), + )])) + .with_representations(ViewRepresentations(vec![ + SqlViewRepresentation { + sql: "SELECT COUNT(1) FROM events".to_string(), + dialect: "spark".to_string(), + } + .into(), + ])) + .build(); + + ViewMetadata { + format_version: ViewFormatVersion::V1, + view_uuid: uuid!("fa6506c3-7681-40c8-86dc-e36561f83385"), + location: "s3://bucket/warehouse/default.db/event_agg".to_string(), + current_version_id: 1, + versions: HashMap::from_iter(vec![(1, std::sync::Arc::new(version))]), + version_log: vec![ViewVersionLog::new(1, 1573518431292)], + schemas: HashMap::from_iter(vec![(1, std::sync::Arc::new(schema))]), + properties: HashMap::from_iter(vec![( + "comment".to_string(), + "Daily event counts".to_string(), + )]), + } + } + + fn test_view() -> View { + let metadata = test_view_metadata(); + View::builder() + .metadata(metadata) + .identifier(TableIdent::from_strs(["ns", "my_view"]).unwrap()) + .metadata_location("s3://bucket/metadata/v1.metadata.json") + .build() + .unwrap() + } + + #[test] + fn test_view_requirement_uuid_match() { + let metadata = test_view_metadata(); + + let requirement = ViewRequirement::AssertViewUuid { + uuid: uuid!("fa6506c3-7681-40c8-86dc-e36561f83385"), + }; + assert!(requirement.check(Some(&metadata)).is_ok()); + + let requirement = ViewRequirement::AssertViewUuid { + uuid: uuid::Uuid::now_v7(), + }; + assert!(requirement.check(Some(&metadata)).is_err()); + } + + #[test] + fn test_view_requirement_view_not_exists() { + let requirement = ViewRequirement::AssertViewUuid { + uuid: uuid!("fa6506c3-7681-40c8-86dc-e36561f83385"), + }; + assert!(requirement.check(None).is_err()); + } + + #[test] + fn test_view_requirement_serde() { + test_serde_json( + r#" +{ + "type": "assert-view-uuid", + "uuid": "fa6506c3-7681-40c8-86dc-e36561f83385" +} + "#, + ViewRequirement::AssertViewUuid { + uuid: uuid!("fa6506c3-7681-40c8-86dc-e36561f83385"), + }, + ); + } + + #[test] + fn test_view_update_apply_set_location() { + let metadata = test_view_metadata(); + let builder = ViewMetadataBuilder::new_from_metadata(metadata); + + let updated = ViewUpdate::SetLocation { + location: "s3://new/location".to_string(), + } + .apply(builder) + .unwrap() + .build() + .unwrap() + .metadata; + + assert_eq!(updated.location(), "s3://new/location"); + } + + #[test] + fn test_view_update_apply_set_properties() { + let metadata = test_view_metadata(); + let builder = ViewMetadataBuilder::new_from_metadata(metadata); + + let updated = ViewUpdate::SetProperties { + updates: HashMap::from_iter(vec![("key1".to_string(), "value1".to_string())]), + } + .apply(builder) + .unwrap() + .build() + .unwrap() + .metadata; + + assert_eq!( + updated.properties().get("key1"), + Some(&"value1".to_string()) + ); + // Original property should still exist + assert_eq!( + updated.properties().get("comment"), + Some(&"Daily event counts".to_string()) + ); + } + + #[test] + fn test_view_update_apply_remove_properties() { + let metadata = test_view_metadata(); + let builder = ViewMetadataBuilder::new_from_metadata(metadata); + + let updated = ViewUpdate::RemoveProperties { + removals: vec!["comment".to_string()], + } + .apply(builder) + .unwrap() + .build() + .unwrap() + .metadata; + + assert!(updated.properties().get("comment").is_none()); + } + + #[test] + fn test_view_update_apply_assign_uuid() { + let metadata = test_view_metadata(); + let builder = ViewMetadataBuilder::new_from_metadata(metadata); + let new_uuid = uuid::Uuid::now_v7(); + + let updated = ViewUpdate::AssignUuid { uuid: new_uuid } + .apply(builder) + .unwrap() + .build() + .unwrap() + .metadata; + + assert_eq!(updated.uuid(), new_uuid); + } + + #[test] + fn test_view_commit_apply() { + let view = test_view(); + let original_uuid = view.metadata().uuid(); + + let commit = ViewCommit::builder() + .ident(view.identifier().clone()) + .requirements(vec![ViewRequirement::AssertViewUuid { + uuid: original_uuid, + }]) + .updates(vec![ + ViewUpdate::SetLocation { + location: "s3://bucket/new_location".to_string(), + }, + ViewUpdate::SetProperties { + updates: HashMap::from_iter(vec![("updated".to_string(), "true".to_string())]), + }, + ]) + .build(); + + let updated_view = commit.apply(view).unwrap(); + assert_eq!(updated_view.location(), "s3://bucket/new_location"); + assert_eq!( + updated_view.properties().get("updated"), + Some(&"true".to_string()) + ); + } + + #[test] + fn test_view_commit_apply_fails_on_uuid_mismatch() { + let view = test_view(); + + let commit = ViewCommit::builder() + .ident(view.identifier().clone()) + .requirements(vec![ViewRequirement::AssertViewUuid { + uuid: uuid::Uuid::now_v7(), + }]) + .updates(vec![ViewUpdate::SetLocation { + location: "s3://bucket/new_location".to_string(), + }]) + .build(); + + let result = commit.apply(view); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("View UUID does not match") + ); + } } diff --git a/crates/iceberg/src/lib.rs b/crates/iceberg/src/lib.rs index 8b345deb6e..0e8c048b81 100644 --- a/crates/iceberg/src/lib.rs +++ b/crates/iceberg/src/lib.rs @@ -74,6 +74,7 @@ mod catalog; pub use catalog::*; pub mod table; +pub mod view; mod avro; pub mod cache; diff --git a/crates/iceberg/src/view.rs b/crates/iceberg/src/view.rs new file mode 100644 index 0000000000..7bf230a3f8 --- /dev/null +++ b/crates/iceberg/src/view.rs @@ -0,0 +1,430 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! View API for Apache Iceberg + +use std::collections::HashMap; + +use crate::spec::{ + SchemaRef, SqlViewRepresentation, ViewMetadata, ViewMetadataRef, ViewRepresentation, + ViewVersionRef, +}; +use crate::{Error, ErrorKind, Result, TableIdent}; + +/// Builder to create a [`View`]. +pub struct ViewBuilder { + metadata_location: Option, + metadata: Option, + identifier: Option, +} + +impl ViewBuilder { + pub(crate) fn new() -> Self { + Self { + metadata_location: None, + metadata: None, + identifier: None, + } + } + + /// required - sets the view's metadata location + pub fn metadata_location>(mut self, metadata_location: T) -> Self { + self.metadata_location = Some(metadata_location.into()); + self + } + + /// required - passes in the ViewMetadata to use for the View + pub fn metadata>(mut self, metadata: T) -> Self { + self.metadata = Some(metadata.into()); + self + } + + /// required - passes in the identifier to use for the View + pub fn identifier(mut self, identifier: TableIdent) -> Self { + self.identifier = Some(identifier); + self + } + + /// Build the View + pub fn build(self) -> Result { + let Self { + metadata_location, + metadata, + identifier, + } = self; + + let Some(metadata) = metadata else { + return Err(Error::new( + ErrorKind::DataInvalid, + "ViewMetadata must be provided with ViewBuilder.metadata()", + )); + }; + + let Some(identifier) = identifier else { + return Err(Error::new( + ErrorKind::DataInvalid, + "Identifier must be provided with ViewBuilder.identifier()", + )); + }; + + Ok(View { + metadata_location, + metadata, + identifier, + }) + } +} + +/// See the [Iceberg View Spec](https://iceberg.apache.org/view-spec/) for details. +#[derive(Debug, Clone)] +pub struct View { + metadata_location: Option, + metadata: ViewMetadataRef, + identifier: TableIdent, +} + +impl View { + /// Sets the [`View`] metadata and returns an updated instance with the new metadata applied. + pub(crate) fn with_metadata(mut self, metadata: ViewMetadataRef) -> Self { + self.metadata = metadata; + self + } + + /// Sets the [`View`] metadata location and returns an updated instance. + /// This will be used by catalog implementations when committing view updates. + #[allow(dead_code)] + pub(crate) fn with_metadata_location(mut self, metadata_location: String) -> Self { + self.metadata_location = Some(metadata_location); + self + } + + /// Returns a ViewBuilder to build a view. + pub fn builder() -> ViewBuilder { + ViewBuilder::new() + } + + /// Returns the view identifier. + pub fn identifier(&self) -> &TableIdent { + &self.identifier + } + + /// Returns current metadata. + pub fn metadata(&self) -> &ViewMetadata { + &self.metadata + } + + /// Returns current metadata ref. + pub fn metadata_ref(&self) -> ViewMetadataRef { + self.metadata.clone() + } + + /// Returns current metadata location. + pub fn metadata_location(&self) -> Option<&str> { + self.metadata_location.as_deref() + } + + /// Returns current metadata location in a result. + pub fn metadata_location_result(&self) -> Result<&str> { + self.metadata_location.as_deref().ok_or(Error::new( + ErrorKind::DataInvalid, + format!( + "Metadata location does not exist for view: {}", + self.identifier + ), + )) + } + + /// Returns the current schema of the view. + pub fn current_schema(&self) -> SchemaRef { + self.metadata.current_schema().clone() + } + + /// Returns the current version of the view. + pub fn current_version(&self) -> &ViewVersionRef { + self.metadata.current_version() + } + + /// Returns view properties. + pub fn properties(&self) -> &HashMap { + self.metadata.properties() + } + + /// Returns the view's location. + pub fn location(&self) -> &str { + self.metadata.location() + } + + /// Resolves the SQL representation for the given dialect. + /// Returns the first SQL representation matching the dialect (case-insensitive), + /// or `None` if no matching representation exists. + pub fn sql_for(&self, dialect: &str) -> Option<&SqlViewRepresentation> { + let dialect_lower = dialect.to_lowercase(); + self.metadata + .current_version() + .representations() + .iter() + .find_map(|repr| match repr { + ViewRepresentation::Sql(sql) if sql.dialect.to_lowercase() == dialect_lower => { + Some(sql) + } + _ => None, + }) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use uuid::Uuid; + + use super::*; + use crate::spec::{ + NestedField, PrimitiveType, Schema, SqlViewRepresentation, Type, ViewFormatVersion, + ViewMetadata, ViewRepresentation, ViewRepresentations, ViewVersion, ViewVersionLog, + }; + use crate::{NamespaceIdent, TableIdent}; + + fn test_view_metadata() -> ViewMetadata { + let schema = Schema::builder() + .with_schema_id(1) + .with_fields(vec![Arc::new( + NestedField::optional(1, "event_count", Type::Primitive(PrimitiveType::Int)) + .with_doc("Count of events"), + )]) + .build() + .unwrap(); + + let version = ViewVersion::builder() + .with_version_id(1) + .with_timestamp_ms(1573518431292) + .with_schema_id(1) + .with_default_catalog("prod".to_string().into()) + .with_default_namespace(NamespaceIdent::from_vec(vec!["default".to_string()]).unwrap()) + .with_summary(HashMap::from_iter(vec![ + ("engine-name".to_string(), "Spark".to_string()), + ("engineVersion".to_string(), "3.3.2".to_string()), + ])) + .with_representations(ViewRepresentations(vec![ + SqlViewRepresentation { + sql: "SELECT\n COUNT(1), CAST(event_ts AS DATE)\nFROM events\nGROUP BY 2" + .to_string(), + dialect: "spark".to_string(), + } + .into(), + ])) + .build(); + + ViewMetadata { + format_version: ViewFormatVersion::V1, + view_uuid: Uuid::parse_str("fa6506c3-7681-40c8-86dc-e36561f83385").unwrap(), + location: "s3://bucket/warehouse/default.db/event_agg".to_string(), + current_version_id: 1, + versions: HashMap::from_iter(vec![(1, Arc::new(version))]), + version_log: vec![ViewVersionLog::new(1, 1573518431292)], + schemas: HashMap::from_iter(vec![(1, Arc::new(schema))]), + properties: HashMap::from_iter(vec![( + "comment".to_string(), + "Daily event counts".to_string(), + )]), + } + } + + #[test] + fn test_view_builder() { + let metadata = test_view_metadata(); + let identifier = TableIdent::from_strs(["ns", "my_view"]).unwrap(); + let view = View::builder() + .metadata(metadata.clone()) + .identifier(identifier.clone()) + .metadata_location("s3://bucket/metadata/v1.metadata.json") + .build() + .unwrap(); + + assert_eq!(view.identifier(), &identifier); + assert_eq!( + view.metadata_location(), + Some("s3://bucket/metadata/v1.metadata.json") + ); + assert_eq!( + view.location(), + "s3://bucket/warehouse/default.db/event_agg" + ); + assert_eq!(view.current_version().version_id(), 1); + assert_eq!(view.current_schema().schema_id(), 1); + assert_eq!( + view.properties().get("comment"), + Some(&"Daily event counts".to_string()) + ); + } + + #[test] + fn test_view_builder_missing_metadata() { + let identifier = TableIdent::from_strs(["ns", "my_view"]).unwrap(); + let result = View::builder().identifier(identifier).build(); + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("ViewMetadata must be provided") + ); + } + + #[test] + fn test_view_builder_missing_identifier() { + let metadata = test_view_metadata(); + let result = View::builder().metadata(metadata).build(); + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Identifier must be provided") + ); + } + + #[test] + fn test_view_builder_without_metadata_location() { + let metadata = test_view_metadata(); + let identifier = TableIdent::from_strs(["ns", "my_view"]).unwrap(); + let view = View::builder() + .metadata(metadata) + .identifier(identifier) + .build() + .unwrap(); + + assert!(view.metadata_location().is_none()); + assert!(view.metadata_location_result().is_err()); + } + + #[test] + fn test_view_sql_for() { + let metadata = test_view_metadata(); + let identifier = TableIdent::from_strs(["ns", "my_view"]).unwrap(); + let view = View::builder() + .metadata(metadata) + .identifier(identifier) + .build() + .unwrap(); + + let spark_sql = view.sql_for("spark"); + assert!(spark_sql.is_some()); + assert!(spark_sql.unwrap().sql.contains("COUNT(1)")); + + // Case-insensitive match + let spark_sql_upper = view.sql_for("SPARK"); + assert!(spark_sql_upper.is_some()); + assert_eq!(spark_sql_upper.unwrap().sql, spark_sql.unwrap().sql); + + // Non-existent dialect + assert!(view.sql_for("trino").is_none()); + } + + #[test] + fn test_view_sql_for_multiple_dialects() { + let schema = Schema::builder() + .with_schema_id(1) + .with_fields(vec![Arc::new(NestedField::optional( + 1, + "count", + Type::Primitive(PrimitiveType::Long), + ))]) + .build() + .unwrap(); + + let version = ViewVersion::builder() + .with_version_id(1) + .with_timestamp_ms(1573518431292) + .with_schema_id(1) + .with_default_namespace(NamespaceIdent::from_vec(vec!["default".to_string()]).unwrap()) + .with_representations(ViewRepresentations(vec![ + ViewRepresentation::Sql(SqlViewRepresentation { + sql: "SELECT COUNT(*) FROM events".to_string(), + dialect: "spark".to_string(), + }), + ViewRepresentation::Sql(SqlViewRepresentation { + sql: "SELECT count(*) FROM events".to_string(), + dialect: "trino".to_string(), + }), + ])) + .build(); + + let metadata = ViewMetadata { + format_version: ViewFormatVersion::V1, + view_uuid: Uuid::now_v7(), + location: "s3://bucket/warehouse/default.db/event_agg".to_string(), + current_version_id: 1, + versions: HashMap::from_iter(vec![(1, Arc::new(version))]), + version_log: vec![ViewVersionLog::new(1, 1573518431292)], + schemas: HashMap::from_iter(vec![(1, Arc::new(schema))]), + properties: HashMap::new(), + }; + + let identifier = TableIdent::from_strs(["ns", "my_view"]).unwrap(); + let view = View::builder() + .metadata(metadata) + .identifier(identifier) + .build() + .unwrap(); + + let spark = view.sql_for("spark").unwrap(); + assert_eq!(spark.sql, "SELECT COUNT(*) FROM events"); + + let trino = view.sql_for("trino").unwrap(); + assert_eq!(trino.sql, "SELECT count(*) FROM events"); + } + + #[test] + fn test_view_with_metadata() { + let metadata = test_view_metadata(); + let identifier = TableIdent::from_strs(["ns", "my_view"]).unwrap(); + let view = View::builder() + .metadata(metadata.clone()) + .identifier(identifier) + .build() + .unwrap(); + + // Build a new metadata with different location + let new_metadata = ViewMetadata { + location: "s3://bucket/new_location".to_string(), + ..metadata + }; + + let updated_view = view.with_metadata(Arc::new(new_metadata)); + assert_eq!(updated_view.location(), "s3://bucket/new_location"); + } + + #[test] + fn test_view_with_metadata_location() { + let metadata = test_view_metadata(); + let identifier = TableIdent::from_strs(["ns", "my_view"]).unwrap(); + let view = View::builder() + .metadata(metadata) + .identifier(identifier) + .build() + .unwrap(); + + let updated_view = view.with_metadata_location("s3://new/location.json".to_string()); + assert_eq!( + updated_view.metadata_location(), + Some("s3://new/location.json") + ); + } +}