1use itertools::Itertools;
2
3use unitycatalog_common::ResourceIdent;
4use unitycatalog_common::metric_view::{MetricView, dependencies as metric_view_dependencies};
5use unitycatalog_common::models::ObjectLabel;
6use unitycatalog_common::models::ResourceName;
7use unitycatalog_common::models::tables::v1::*;
8
9use super::{RequestContext, SecuredAction};
10pub use crate::codegen::tables::TableHandler;
11use crate::policy::{Permission, Policy, process_resources};
12use crate::services::ProvidesLocalStoragePolicy;
13use crate::services::location::StorageLocationUrl;
14use crate::services::object_store::validate_external_storage_location;
15use crate::store::ResourceStore;
16use crate::{Error, Result};
17
18const MAX_RESULTS_TABLES: usize = 50;
19
20impl SecuredAction for CreateTableRequest {
21 fn resource(&self) -> ResourceIdent {
22 ResourceIdent::table(ResourceName::new([
23 self.catalog_name.as_str(),
24 self.schema_name.as_str(),
25 self.name.as_str(),
26 ]))
27 }
28
29 fn permission(&self) -> &'static Permission {
30 &Permission::Create
31 }
32}
33
34impl SecuredAction for ListTableSummariesRequest {
35 fn resource(&self) -> ResourceIdent {
36 ResourceIdent::table(ResourceName::new([self.catalog_name.as_str()]))
37 }
38
39 fn permission(&self) -> &'static Permission {
40 &Permission::Read
41 }
42}
43
44impl SecuredAction for ListTablesRequest {
45 fn resource(&self) -> ResourceIdent {
46 ResourceIdent::table(ResourceName::new([
47 self.catalog_name.as_str(),
48 self.schema_name.as_str(),
49 ]))
50 }
51
52 fn permission(&self) -> &'static Permission {
53 &Permission::Read
54 }
55}
56
57impl SecuredAction for GetTableRequest {
58 fn resource(&self) -> ResourceIdent {
59 ResourceIdent::table(ResourceName::from_naive_str_split(self.full_name.as_str()))
60 }
61
62 fn permission(&self) -> &'static Permission {
63 &Permission::Read
64 }
65}
66
67impl SecuredAction for GetTableExistsRequest {
68 fn resource(&self) -> ResourceIdent {
69 ResourceIdent::table(ResourceName::from_naive_str_split(self.full_name.as_str()))
70 }
71
72 fn permission(&self) -> &'static Permission {
73 &Permission::Read
74 }
75}
76
77impl SecuredAction for DeleteTableRequest {
78 fn resource(&self) -> ResourceIdent {
79 ResourceIdent::table(ResourceName::from_naive_str_split(self.full_name.as_str()))
80 }
81
82 fn permission(&self) -> &'static Permission {
83 &Permission::Manage
84 }
85}
86
87#[async_trait::async_trait]
88impl<T: ResourceStore + Policy<RequestContext> + ProvidesLocalStoragePolicy>
89 TableHandler<RequestContext> for T
90{
91 #[tracing::instrument(skip(self, context))]
92 async fn list_table_summaries(
93 &self,
94 request: ListTableSummariesRequest,
95 context: RequestContext,
96 ) -> Result<ListTableSummariesResponse> {
97 self.check_required(&request, &context).await?;
98 let (mut resources, next_page_token) = self
100 .list(
101 &ObjectLabel::Table,
102 Some(&ResourceName::new([&request.catalog_name])),
103 super::page_size(request.max_results)?,
104 request.page_token,
105 )
106 .await?;
107 process_resources(self, &context, &Permission::Read, &mut resources).await?;
108 let infos: Vec<Table> = resources.into_iter().map(|r| r.try_into()).try_collect()?;
109 Ok(ListTableSummariesResponse {
110 tables: infos.into_iter().map(|r| r.into()).collect(),
111 next_page_token,
112 ..Default::default()
113 })
114 }
115
116 #[tracing::instrument(skip(self, context))]
117 async fn list_tables(
118 &self,
119 request: ListTablesRequest,
120 context: RequestContext,
121 ) -> Result<ListTablesResponse> {
122 self.check_required(&request, &context).await?;
124 let (mut resources, next_page_token) = self
126 .list(
127 &ObjectLabel::Table,
128 Some(&ResourceName::new([
129 &request.catalog_name,
130 &request.schema_name,
131 ])),
132 super::page_size(request.max_results)?.map(|v| usize::min(v, MAX_RESULTS_TABLES)),
133 request.page_token,
134 )
135 .await?;
136 process_resources(self, &context, &Permission::Read, &mut resources).await?;
137 Ok(ListTablesResponse {
138 tables: resources.into_iter().map(|r| r.try_into()).try_collect()?,
139 next_page_token,
140 ..Default::default()
141 })
142 }
143
144 #[tracing::instrument(skip(self, context), fields(resource_name))]
145 async fn create_table(
146 &self,
147 request: CreateTableRequest,
148 context: RequestContext,
149 ) -> Result<Table> {
150 tracing::Span::current().record("resource_name", &request.name);
151 self.check_required(&request, &context).await?;
152 let info = if request.table_type == TableType::External {
153 let Some(location) = request.storage_location.as_ref() else {
159 return Err(Error::invalid_argument("missing storage location"));
160 };
161 let location = StorageLocationUrl::parse(location)?;
162 validate_external_storage_location(self, &location).await?;
167 Table {
168 name: request.name,
169 catalog_name: request.catalog_name,
170 schema_name: request.schema_name,
171 table_type: request.table_type,
172 data_source_format: request.data_source_format,
173 properties: request.properties,
174 storage_location: request.storage_location,
175 comment: request.comment,
176 columns: request.columns,
177 ..Default::default()
178 }
179 } else if request.table_type == TableType::Managed {
180 return Err(Error::invalid_argument(
187 "managed tables cannot be created through this endpoint; use the \
188 /delta/v1 staging flow (createStagingTable, write the initial Delta \
189 commit, then createTable)",
190 ));
191 } else if request.table_type == TableType::MetricView {
192 let Some(view_definition) = request.view_definition.as_ref() else {
196 return Err(Error::invalid_argument(
197 "metric views require view_definition (the YAML definition)",
198 ));
199 };
200
201 let view = MetricView::from_yaml(view_definition)
205 .map_err(|e| Error::invalid_argument(format!("invalid metric-view YAML: {e}")))?;
206 let view_dependencies = metric_view_dependencies(&view).map_err(|e| {
207 Error::invalid_argument(format!("cannot derive metric-view dependencies: {e}"))
208 })?;
209 if let Some(supplied) = request.view_dependencies.as_option()
210 && supplied != &view_dependencies
211 {
212 return Err(Error::invalid_argument(
213 "supplied view_dependencies diverges from the definition; \
214 omit it (the server derives dependencies from view_definition)",
215 ));
216 }
217
218 Table {
219 name: request.name,
220 catalog_name: request.catalog_name,
221 schema_name: request.schema_name,
222 table_type: request.table_type,
223 data_source_format: request.data_source_format,
224 properties: request.properties,
225 comment: request.comment,
226 view_definition: Some(view_definition.clone()),
227 view_dependencies: Some(view_dependencies).into(),
228 ..Default::default()
229 }
230 } else {
231 return Err(Error::invalid_argument(format!(
232 "unsupported table type: {:?}",
233 request.table_type
234 )));
235 };
236 Ok(self.create(info.into()).await?.0.try_into()?)
239 }
240
241 #[tracing::instrument(skip(self, context), fields(resource_name))]
242 async fn get_table(&self, request: GetTableRequest, context: RequestContext) -> Result<Table> {
243 tracing::Span::current().record("resource_name", &request.full_name);
244 self.check_required(&request, &context).await?;
245 Ok(self.get(&request.resource()).await?.0.try_into()?)
247 }
248
249 #[tracing::instrument(skip(self, context), fields(resource_name))]
250 async fn get_table_exists(
251 &self,
252 request: GetTableExistsRequest,
253 context: RequestContext,
254 ) -> Result<GetTableExistsResponse> {
255 tracing::Span::current().record("resource_name", &request.full_name);
256 self.check_required(&request, &context).await?;
257 match self.get(&request.resource()).await {
258 Ok(_) => Ok(GetTableExistsResponse {
259 table_exists: true,
260 ..Default::default()
261 }),
262 Err(unitycatalog_common::Error::NotFound) => Ok(GetTableExistsResponse {
263 table_exists: false,
264 ..Default::default()
265 }),
266 Err(e) => Err(e.into()),
267 }
268 }
269
270 #[tracing::instrument(skip(self, context), fields(resource_name))]
271 async fn delete_table(
272 &self,
273 request: DeleteTableRequest,
274 context: RequestContext,
275 ) -> Result<()> {
276 tracing::Span::current().record("resource_name", &request.full_name);
277 self.check_required(&request, &context).await?;
278 Ok(self.delete(&request.resource()).await?)
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use std::sync::Arc;
285
286 use unitycatalog_common::models::credentials::v1::{
287 AwsIamRoleConfig, CreateCredentialRequest, Purpose,
288 };
289 use unitycatalog_common::models::external_locations::v1::CreateExternalLocationRequest;
290 use unitycatalog_common::services::encryption::{EnvelopeEncryptor, LocalKeyProvider};
291
292 use super::*;
293 use crate::api::{CredentialHandler, ExternalLocationHandler};
294 use crate::memory::InMemoryResourceStore;
295 use crate::policy::ConstantPolicy;
296 use crate::services::ServerHandler;
297
298 fn handler() -> ServerHandler<RequestContext> {
299 let encryptor =
300 EnvelopeEncryptor::local(LocalKeyProvider::single("test", vec![0x42; 32]).unwrap());
301 let store = Arc::new(InMemoryResourceStore::new(encryptor));
302 let policy: Arc<dyn Policy<RequestContext>> = Arc::new(ConstantPolicy::default());
303 ServerHandler::try_new_tokio(policy, store).unwrap()
304 }
305
306 fn ctx() -> RequestContext {
307 RequestContext {
308 recipient: crate::policy::Principal::anonymous(),
309 }
310 }
311
312 #[tokio::test]
315 async fn external_table_outside_external_location_is_rejected() {
316 let h = handler();
317 h.create_credential(
318 CreateCredentialRequest {
319 name: "cred".to_string(),
320 purpose: Purpose::Storage.into(),
321 aws_iam_role: Some(AwsIamRoleConfig {
322 role_arn: "arn:aws:iam::123456789012:role/test".to_string(),
323 ..Default::default()
324 })
325 .into(),
326 ..Default::default()
327 },
328 ctx(),
329 )
330 .await
331 .unwrap();
332 h.create_external_location(
333 CreateExternalLocationRequest {
334 name: "ext".to_string(),
335 url: "s3://bucket/ext".to_string(),
336 credential_name: "cred".to_string(),
337 ..Default::default()
338 },
339 ctx(),
340 )
341 .await
342 .unwrap();
343
344 let res = h
345 .create_table(
346 CreateTableRequest {
347 name: "t".to_string(),
348 schema_name: "sch".to_string(),
349 catalog_name: "cat".to_string(),
350 table_type: TableType::External.into(),
351 data_source_format: DataSourceFormat::Delta.into(),
352 storage_location: Some("s3://bucket/other/tbl".to_string()),
353 ..Default::default()
354 },
355 ctx(),
356 )
357 .await;
358 assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
359 }
360
361 async fn with_external_location(h: &ServerHandler<RequestContext>) {
364 h.create_credential(
365 CreateCredentialRequest {
366 name: "cred".to_string(),
367 purpose: Purpose::Storage.into(),
368 aws_iam_role: Some(AwsIamRoleConfig {
369 role_arn: "arn:aws:iam::123456789012:role/test".to_string(),
370 ..Default::default()
371 })
372 .into(),
373 ..Default::default()
374 },
375 ctx(),
376 )
377 .await
378 .unwrap();
379 h.create_external_location(
380 CreateExternalLocationRequest {
381 name: "ext".to_string(),
382 url: "s3://bucket/ext".to_string(),
383 credential_name: "cred".to_string(),
384 ..Default::default()
385 },
386 ctx(),
387 )
388 .await
389 .unwrap();
390 }
391
392 #[tokio::test]
396 async fn external_table_persists_client_columns() {
397 let h = handler();
398 with_external_location(&h).await;
399
400 let columns = vec![
401 Column {
402 name: "id".to_string(),
403 type_text: "bigint".to_string(),
404 type_json: "\"long\"".to_string(),
405 type_name: ColumnTypeName::Long.into(),
406 position: Some(0),
407 nullable: Some(false),
408 ..Default::default()
409 },
410 Column {
411 name: "name".to_string(),
412 type_text: "string".to_string(),
413 type_json: "\"string\"".to_string(),
414 type_name: ColumnTypeName::String.into(),
415 position: Some(1),
416 nullable: Some(true),
417 ..Default::default()
418 },
419 ];
420
421 let created = h
422 .create_table(
423 CreateTableRequest {
424 name: "tbl".to_string(),
425 schema_name: "sch".to_string(),
426 catalog_name: "cat".to_string(),
427 table_type: TableType::External.into(),
428 data_source_format: DataSourceFormat::Delta.into(),
429 storage_location: Some("s3://bucket/ext/tbl".to_string()),
430 columns: columns.clone(),
431 ..Default::default()
432 },
433 ctx(),
434 )
435 .await
436 .expect("create external table");
437 assert_eq!(created.table_type, TableType::External);
438 let created_names: Vec<_> = created.columns.iter().map(|c| c.name.as_str()).collect();
439 assert_eq!(created_names, vec!["id", "name"]);
440
441 let fetched = h
442 .get_table(
443 GetTableRequest {
444 full_name: "cat.sch.tbl".to_string(),
445 ..Default::default()
446 },
447 ctx(),
448 )
449 .await
450 .expect("get external table");
451 let fetched_names: Vec<_> = fetched.columns.iter().map(|c| c.name.as_str()).collect();
452 assert_eq!(fetched_names, vec!["id", "name"]);
453 }
454
455 #[tokio::test]
460 async fn managed_table_via_bare_create_is_rejected() {
461 let h = handler();
462 let res = h
463 .create_table(
464 CreateTableRequest {
465 name: "t".to_string(),
466 schema_name: "sch".to_string(),
467 catalog_name: "cat".to_string(),
468 table_type: TableType::Managed.into(),
469 data_source_format: DataSourceFormat::Delta.into(),
470 storage_location: Some("s3://bucket/cat/__unitystorage/tables/x".to_string()),
471 ..Default::default()
472 },
473 ctx(),
474 )
475 .await;
476 assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
477 }
478
479 const METRIC_VIEW_YAML: &str = "version: \"1.1\"\nsource: cat.sch.orders\n\
480 measures:\n - name: revenue\n expr: SUM(price)\n";
481
482 #[tokio::test]
486 async fn metric_view_create_get_round_trip() {
487 let h = handler();
488 let created = h
489 .create_table(
490 CreateTableRequest {
491 name: "orders_metrics".to_string(),
492 schema_name: "sch".to_string(),
493 catalog_name: "cat".to_string(),
494 table_type: TableType::MetricView.into(),
495 view_definition: Some(METRIC_VIEW_YAML.to_string()),
496 ..Default::default()
497 },
498 ctx(),
499 )
500 .await
501 .expect("create metric view");
502 assert_eq!(created.table_type, TableType::MetricView);
503 assert_eq!(created.view_definition.as_deref(), Some(METRIC_VIEW_YAML));
504 assert_eq!(
506 dep_names(created.view_dependencies.as_option()),
507 vec!["cat.sch.orders"]
508 );
509
510 let fetched = h
511 .get_table(
512 GetTableRequest {
513 full_name: "cat.sch.orders_metrics".to_string(),
514 ..Default::default()
515 },
516 ctx(),
517 )
518 .await
519 .expect("get metric view");
520 assert_eq!(fetched.table_type, TableType::MetricView);
521 assert_eq!(fetched.view_definition.as_deref(), Some(METRIC_VIEW_YAML));
522 assert_eq!(
524 dep_names(fetched.view_dependencies.as_option()),
525 vec!["cat.sch.orders"]
526 );
527 }
528
529 fn dep_names(deps: Option<&DependencyList>) -> Vec<String> {
531 deps.map(|d| {
532 d.dependencies
533 .iter()
534 .filter_map(|dep| match &dep.dependency {
535 Some(dependency::Dependency::Table(t)) => Some(t.table_full_name.clone()),
536 _ => None,
537 })
538 .collect()
539 })
540 .unwrap_or_default()
541 }
542
543 fn metric_view_request() -> CreateTableRequest {
544 CreateTableRequest {
545 name: "orders_metrics".to_string(),
546 schema_name: "sch".to_string(),
547 catalog_name: "cat".to_string(),
548 table_type: TableType::MetricView.into(),
549 view_definition: Some(METRIC_VIEW_YAML.to_string()),
550 ..Default::default()
551 }
552 }
553
554 fn table_dep(full_name: &str) -> Dependency {
555 Dependency {
556 dependency: Some(dependency::Dependency::Table(Box::new(TableDependency {
557 table_full_name: full_name.to_string(),
558 ..Default::default()
559 }))),
560 ..Default::default()
561 }
562 }
563
564 #[tokio::test]
567 async fn metric_view_matching_dependencies_accepted() {
568 let h = handler();
569 let created = h
570 .create_table(
571 CreateTableRequest {
572 view_dependencies: Some(DependencyList {
573 dependencies: vec![table_dep("cat.sch.orders")],
574 ..Default::default()
575 })
576 .into(),
577 ..metric_view_request()
578 },
579 ctx(),
580 )
581 .await
582 .expect("create metric view with matching deps");
583 assert_eq!(
584 dep_names(created.view_dependencies.as_option()),
585 vec!["cat.sch.orders"]
586 );
587 }
588
589 #[tokio::test]
592 async fn metric_view_diverging_dependencies_rejected() {
593 let h = handler();
594 let res = h
595 .create_table(
596 CreateTableRequest {
597 view_dependencies: Some(DependencyList {
598 dependencies: vec![table_dep("cat.sch.something_else")],
599 ..Default::default()
600 })
601 .into(),
602 ..metric_view_request()
603 },
604 ctx(),
605 )
606 .await;
607 assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
608 }
609
610 #[tokio::test]
613 async fn metric_view_unresolvable_source_rejected() {
614 let h = handler();
615 let yaml = "version: \"1.1\"\nsource: orders\n\
616 measures:\n - name: revenue\n expr: SUM(price)\n";
617 let res = h
618 .create_table(
619 CreateTableRequest {
620 view_definition: Some(yaml.to_string()),
621 ..metric_view_request()
622 },
623 ctx(),
624 )
625 .await;
626 assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
627 }
628
629 #[tokio::test]
631 async fn metric_view_without_definition_is_rejected() {
632 let h = handler();
633 let res = h
634 .create_table(
635 CreateTableRequest {
636 name: "orders_metrics".to_string(),
637 schema_name: "sch".to_string(),
638 catalog_name: "cat".to_string(),
639 table_type: TableType::MetricView.into(),
640 view_definition: None,
641 ..Default::default()
642 },
643 ctx(),
644 )
645 .await;
646 assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
647 }
648}