1use itertools::Itertools;
2
3use unitycatalog_common::models::ObjectLabel;
4use unitycatalog_common::models::schemas::v1::*;
5use unitycatalog_common::models::{ResourceIdent, ResourceName, ResourceRef};
6
7use super::{RequestContext, SecuredAction};
8pub use crate::codegen::schemas::SchemaHandler;
9use crate::policy::{Permission, Policy, process_resources};
10use crate::services::ProvidesLocalStoragePolicy;
11use crate::services::location::StorageLocationUrl;
12use crate::store::ResourceStore;
13use crate::{Error, Result};
14
15#[async_trait::async_trait]
16impl<T: ResourceStore + Policy<RequestContext> + ProvidesLocalStoragePolicy>
17 SchemaHandler<RequestContext> for T
18{
19 #[tracing::instrument(skip(self, context), fields(resource_name))]
20 async fn create_schema(
21 &self,
22 request: CreateSchemaRequest,
23 context: RequestContext,
24 ) -> Result<Schema> {
25 tracing::Span::current().record("resource_name", &request.name);
26 self.check_required(&request, &context).await?;
27
28 let schema_id = uuid::Uuid::now_v7().hyphenated().to_string();
38 let storage_root = request.storage_root.filter(|s| !s.is_empty());
39 let storage_location = match storage_root.as_deref() {
40 Some(root) => {
48 let url = StorageLocationUrl::parse(root)?;
49 crate::services::object_store::validate_managed_storage_root(self, &url).await?;
50 Some(super::staging_tables::schema_location(root, &schema_id))
51 }
52 None => None,
53 };
54
55 let resource = Schema {
56 schema_id: Some(schema_id),
57 full_name: format!("{}.{}", request.catalog_name, request.name),
58 name: request.name,
59 catalog_name: request.catalog_name,
60 comment: request.comment,
61 properties: request.properties,
62 storage_root,
63 storage_location,
64 ..Default::default()
65 };
66 Ok(self.create(resource.into()).await?.0.try_into()?)
70 }
71
72 #[tracing::instrument(skip(self, context), fields(resource_name))]
73 async fn delete_schema(
74 &self,
75 request: DeleteSchemaRequest,
76 context: RequestContext,
77 ) -> Result<()> {
78 tracing::Span::current().record("resource_name", &request.full_name);
79 self.check_required(&request, &context).await?;
80 Ok(self.delete(&request.resource()).await?)
81 }
82
83 #[tracing::instrument(skip(self, context))]
84 async fn list_schemas(
85 &self,
86 request: ListSchemasRequest,
87 context: RequestContext,
88 ) -> Result<ListSchemasResponse> {
89 self.check_required(&request, &context).await?;
90 let (mut resources, next_page_token) = self
91 .list(
92 &ObjectLabel::Schema,
93 Some(&ResourceName::new([&request.catalog_name])),
94 request.max_results.map(|v| v as usize),
95 request.page_token,
96 )
97 .await?;
98 process_resources(self, &context, &Permission::Read, &mut resources).await?;
99 Ok(ListSchemasResponse {
100 schemas: resources.into_iter().map(|r| r.try_into()).try_collect()?,
101 next_page_token,
102 ..Default::default()
103 })
104 }
105
106 #[tracing::instrument(skip(self, context), fields(resource_name))]
107 async fn get_schema(
108 &self,
109 request: GetSchemaRequest,
110 context: RequestContext,
111 ) -> Result<Schema> {
112 tracing::Span::current().record("resource_name", &request.full_name);
113 self.check_required(&request, &context).await?;
114 Ok(self.get(&request.resource()).await?.0.try_into()?)
115 }
116
117 #[tracing::instrument(skip(self, context), fields(resource_name))]
118 async fn update_schema(
119 &self,
120 request: UpdateSchemaRequest,
121 context: RequestContext,
122 ) -> Result<Schema> {
123 tracing::Span::current().record("resource_name", &request.full_name);
124 self.check_required(&request, &context).await?;
125 let ident = request.resource();
126 let name = ResourceName::from_naive_str_split(request.full_name);
127 let [catalog_name, schema_name] = name.as_ref() else {
128 return Err(Error::invalid_argument(
129 "Invalid schema name - expected <catalog_name>.<schema_name>",
130 ));
131 };
132 let new_name = request.new_name.unwrap_or(schema_name.to_owned());
133 let resource = Schema {
134 name: new_name.clone(),
135 comment: request.comment,
136 properties: request.properties,
137 catalog_name: catalog_name.to_owned(),
138 full_name: format!("{}.{}", catalog_name, new_name),
139 ..Default::default()
140 };
141 Ok(self.update(&ident, resource.into()).await?.0.try_into()?)
145 }
146}
147
148impl SecuredAction for CreateSchemaRequest {
149 fn resource(&self) -> ResourceIdent {
150 ResourceIdent::schema(ResourceName::new([
151 self.catalog_name.as_str(),
152 self.name.as_str(),
153 ]))
154 }
155
156 fn permission(&self) -> &'static Permission {
157 &Permission::Create
158 }
159}
160
161impl SecuredAction for ListSchemasRequest {
162 fn resource(&self) -> ResourceIdent {
163 ResourceIdent::schema(ResourceRef::Undefined)
164 }
165
166 fn permission(&self) -> &'static Permission {
167 &Permission::Read
168 }
169}
170
171impl SecuredAction for GetSchemaRequest {
172 fn resource(&self) -> ResourceIdent {
173 ResourceIdent::schema(ResourceName::from_naive_str_split(self.full_name.as_str()))
174 }
175
176 fn permission(&self) -> &'static Permission {
177 &Permission::Read
178 }
179}
180
181impl SecuredAction for UpdateSchemaRequest {
182 fn resource(&self) -> ResourceIdent {
183 ResourceIdent::schema(ResourceName::from_naive_str_split(self.full_name.as_str()))
184 }
185
186 fn permission(&self) -> &'static Permission {
187 &Permission::Manage
188 }
189}
190
191impl SecuredAction for DeleteSchemaRequest {
192 fn resource(&self) -> ResourceIdent {
193 ResourceIdent::schema(ResourceName::from_naive_str_split(self.full_name.as_str()))
194 }
195
196 fn permission(&self) -> &'static Permission {
197 &Permission::Manage
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use std::sync::Arc;
204
205 use unitycatalog_common::models::credentials::v1::{
206 AwsIamRoleConfig, CreateCredentialRequest, Purpose,
207 };
208 use unitycatalog_common::models::external_locations::v1::CreateExternalLocationRequest;
209 use unitycatalog_common::services::encryption::{EnvelopeEncryptor, LocalKeyProvider};
210
211 use super::*;
212 use crate::api::{CredentialHandler, ExternalLocationHandler};
213 use crate::memory::InMemoryResourceStore;
214 use crate::policy::{ConstantPolicy, Principal};
215 use crate::services::ServerHandler;
216
217 fn handler() -> ServerHandler<RequestContext> {
218 let encryptor =
219 EnvelopeEncryptor::local(LocalKeyProvider::single("test", vec![0x42; 32]).unwrap());
220 let store = Arc::new(InMemoryResourceStore::new(encryptor));
221 let policy: Arc<dyn Policy<RequestContext>> = Arc::new(ConstantPolicy::default());
222 ServerHandler::try_new_tokio(policy, store).unwrap()
223 }
224
225 fn ctx() -> RequestContext {
226 RequestContext {
227 recipient: Principal::anonymous(),
228 }
229 }
230
231 async fn make_covering_location(h: &ServerHandler<RequestContext>, name: &str, url: &str) {
235 h.create_credential(
236 CreateCredentialRequest {
237 name: format!("{name}-cred"),
238 purpose: Purpose::Storage.into(),
239 aws_iam_role: Some(AwsIamRoleConfig {
240 role_arn: "arn:aws:iam::123456789012:role/test".to_string(),
241 ..Default::default()
242 })
243 .into(),
244 ..Default::default()
245 },
246 ctx(),
247 )
248 .await
249 .unwrap();
250 h.create_external_location(
251 CreateExternalLocationRequest {
252 name: name.to_string(),
253 url: url.to_string(),
254 credential_name: format!("{name}-cred"),
255 ..Default::default()
256 },
257 ctx(),
258 )
259 .await
260 .unwrap();
261 }
262
263 fn create_req(catalog: &str, name: &str, storage_root: Option<&str>) -> CreateSchemaRequest {
264 CreateSchemaRequest {
265 name: name.to_string(),
266 catalog_name: catalog.to_string(),
267 storage_root: storage_root.map(str::to_string),
268 ..Default::default()
269 }
270 }
271
272 #[tokio::test]
273 async fn schema_without_root_has_no_storage_fields() {
274 let h = handler();
277 let schema = h
278 .create_schema(create_req("cat", "sch", None), ctx())
279 .await
280 .unwrap();
281 assert!(schema.storage_root.is_none());
282 assert!(schema.storage_location.is_none());
283 }
284
285 #[tokio::test]
286 async fn schema_with_covered_root_succeeds() {
287 let h = handler();
290 make_covering_location(&h, "el", "s3://bucket").await;
291 let schema = h
292 .create_schema(create_req("cat", "sch", Some("s3://bucket/sch")), ctx())
293 .await
294 .unwrap();
295 assert_eq!(schema.storage_root.as_deref(), Some("s3://bucket/sch"));
296 let id = schema.schema_id.as_deref().expect("schema id");
297 assert_eq!(
298 schema.storage_location.as_deref(),
299 Some(format!("s3://bucket/sch/__unitystorage/schemas/{id}").as_str())
300 );
301 }
302
303 #[tokio::test]
304 async fn schema_with_uncovered_root_is_rejected() {
305 let h = handler();
307 let res = h
308 .create_schema(create_req("cat", "sch", Some("s3://bucket/sch")), ctx())
309 .await;
310 assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
311 }
312
313 #[tokio::test]
314 async fn schema_root_under_managed_prefix_is_rejected() {
315 let h = handler();
318 make_covering_location(&h, "el", "s3://bucket").await;
319 let res = h
320 .create_schema(
321 create_req("cat", "sch", Some("s3://bucket/__unitystorage/sch")),
322 ctx(),
323 )
324 .await;
325 assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
326 }
327}