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 request.max_results.map(|v| v as usize),
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 request
133 .max_results
134 .map(|v| usize::min(v as usize, MAX_RESULTS_TABLES)),
135 request.page_token,
136 )
137 .await?;
138 process_resources(self, &context, &Permission::Read, &mut resources).await?;
139 Ok(ListTablesResponse {
140 tables: resources.into_iter().map(|r| r.try_into()).try_collect()?,
141 next_page_token,
142 ..Default::default()
143 })
144 }
145
146 #[tracing::instrument(skip(self, context), fields(resource_name))]
147 async fn create_table(
148 &self,
149 request: CreateTableRequest,
150 context: RequestContext,
151 ) -> Result<Table> {
152 tracing::Span::current().record("resource_name", &request.name);
153 self.check_required(&request, &context).await?;
154 let info = if request.table_type == TableType::External {
155 let Some(location) = request.storage_location.as_ref() else {
161 return Err(Error::invalid_argument("missing storage location"));
162 };
163 let location = StorageLocationUrl::parse(location)?;
164 validate_external_storage_location(self, &location).await?;
169 Table {
170 name: request.name,
171 catalog_name: request.catalog_name,
172 schema_name: request.schema_name,
173 table_type: request.table_type,
174 data_source_format: request.data_source_format,
175 properties: request.properties,
176 storage_location: request.storage_location,
177 comment: request.comment,
178 columns: request.columns,
179 ..Default::default()
180 }
181 } else if request.table_type == TableType::Managed {
182 return Err(Error::invalid_argument(
189 "managed tables cannot be created through this endpoint; use the \
190 /delta/v1 staging flow (createStagingTable, write the initial Delta \
191 commit, then createTable)",
192 ));
193 } else if request.table_type == TableType::MetricView {
194 let Some(view_definition) = request.view_definition.as_ref() else {
198 return Err(Error::invalid_argument(
199 "metric views require view_definition (the YAML definition)",
200 ));
201 };
202
203 let view = MetricView::from_yaml(view_definition)
207 .map_err(|e| Error::invalid_argument(format!("invalid metric-view YAML: {e}")))?;
208 let view_dependencies = metric_view_dependencies(&view).map_err(|e| {
209 Error::invalid_argument(format!("cannot derive metric-view dependencies: {e}"))
210 })?;
211 if let Some(supplied) = request.view_dependencies.as_option()
212 && supplied != &view_dependencies
213 {
214 return Err(Error::invalid_argument(
215 "supplied view_dependencies diverges from the definition; \
216 omit it (the server derives dependencies from view_definition)",
217 ));
218 }
219
220 Table {
221 name: request.name,
222 catalog_name: request.catalog_name,
223 schema_name: request.schema_name,
224 table_type: request.table_type,
225 data_source_format: request.data_source_format,
226 properties: request.properties,
227 comment: request.comment,
228 view_definition: Some(view_definition.clone()),
229 view_dependencies: Some(view_dependencies).into(),
230 ..Default::default()
231 }
232 } else {
233 return Err(Error::invalid_argument(format!(
234 "unsupported table type: {:?}",
235 request.table_type
236 )));
237 };
238 Ok(self.create(info.into()).await?.0.try_into()?)
241 }
242
243 #[tracing::instrument(skip(self, context), fields(resource_name))]
244 async fn get_table(&self, request: GetTableRequest, context: RequestContext) -> Result<Table> {
245 tracing::Span::current().record("resource_name", &request.full_name);
246 self.check_required(&request, &context).await?;
247 Ok(self.get(&request.resource()).await?.0.try_into()?)
249 }
250
251 #[tracing::instrument(skip(self, context), fields(resource_name))]
252 async fn get_table_exists(
253 &self,
254 request: GetTableExistsRequest,
255 context: RequestContext,
256 ) -> Result<GetTableExistsResponse> {
257 tracing::Span::current().record("resource_name", &request.full_name);
258 self.check_required(&request, &context).await?;
259 match self.get(&request.resource()).await {
260 Ok(_) => Ok(GetTableExistsResponse {
261 table_exists: true,
262 ..Default::default()
263 }),
264 Err(unitycatalog_common::Error::NotFound) => Ok(GetTableExistsResponse {
265 table_exists: false,
266 ..Default::default()
267 }),
268 Err(e) => Err(e.into()),
269 }
270 }
271
272 #[tracing::instrument(skip(self, context), fields(resource_name))]
273 async fn delete_table(
274 &self,
275 request: DeleteTableRequest,
276 context: RequestContext,
277 ) -> Result<()> {
278 tracing::Span::current().record("resource_name", &request.full_name);
279 self.check_required(&request, &context).await?;
280 Ok(self.delete(&request.resource()).await?)
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use std::sync::Arc;
287
288 use unitycatalog_common::models::credentials::v1::{
289 AwsIamRoleConfig, CreateCredentialRequest, Purpose,
290 };
291 use unitycatalog_common::models::external_locations::v1::CreateExternalLocationRequest;
292 use unitycatalog_common::services::encryption::{EnvelopeEncryptor, LocalKeyProvider};
293
294 use super::*;
295 use crate::api::{CredentialHandler, ExternalLocationHandler};
296 use crate::memory::InMemoryResourceStore;
297 use crate::policy::ConstantPolicy;
298 use crate::services::ServerHandler;
299
300 fn handler() -> ServerHandler<RequestContext> {
301 let encryptor =
302 EnvelopeEncryptor::local(LocalKeyProvider::single("test", vec![0x42; 32]).unwrap());
303 let store = Arc::new(InMemoryResourceStore::new(encryptor));
304 let policy: Arc<dyn Policy<RequestContext>> = Arc::new(ConstantPolicy::default());
305 ServerHandler::try_new_tokio(policy, store).unwrap()
306 }
307
308 fn ctx() -> RequestContext {
309 RequestContext {
310 recipient: crate::policy::Principal::anonymous(),
311 }
312 }
313
314 #[tokio::test]
317 async fn external_table_outside_external_location_is_rejected() {
318 let h = handler();
319 h.create_credential(
320 CreateCredentialRequest {
321 name: "cred".to_string(),
322 purpose: Purpose::Storage.into(),
323 aws_iam_role: Some(AwsIamRoleConfig {
324 role_arn: "arn:aws:iam::123456789012:role/test".to_string(),
325 ..Default::default()
326 })
327 .into(),
328 ..Default::default()
329 },
330 ctx(),
331 )
332 .await
333 .unwrap();
334 h.create_external_location(
335 CreateExternalLocationRequest {
336 name: "ext".to_string(),
337 url: "s3://bucket/ext".to_string(),
338 credential_name: "cred".to_string(),
339 ..Default::default()
340 },
341 ctx(),
342 )
343 .await
344 .unwrap();
345
346 let res = h
347 .create_table(
348 CreateTableRequest {
349 name: "t".to_string(),
350 schema_name: "sch".to_string(),
351 catalog_name: "cat".to_string(),
352 table_type: TableType::External.into(),
353 data_source_format: DataSourceFormat::Delta.into(),
354 storage_location: Some("s3://bucket/other/tbl".to_string()),
355 ..Default::default()
356 },
357 ctx(),
358 )
359 .await;
360 assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
361 }
362
363 async fn with_external_location(h: &ServerHandler<RequestContext>) {
366 h.create_credential(
367 CreateCredentialRequest {
368 name: "cred".to_string(),
369 purpose: Purpose::Storage.into(),
370 aws_iam_role: Some(AwsIamRoleConfig {
371 role_arn: "arn:aws:iam::123456789012:role/test".to_string(),
372 ..Default::default()
373 })
374 .into(),
375 ..Default::default()
376 },
377 ctx(),
378 )
379 .await
380 .unwrap();
381 h.create_external_location(
382 CreateExternalLocationRequest {
383 name: "ext".to_string(),
384 url: "s3://bucket/ext".to_string(),
385 credential_name: "cred".to_string(),
386 ..Default::default()
387 },
388 ctx(),
389 )
390 .await
391 .unwrap();
392 }
393
394 #[tokio::test]
398 async fn external_table_persists_client_columns() {
399 let h = handler();
400 with_external_location(&h).await;
401
402 let columns = vec![
403 Column {
404 name: "id".to_string(),
405 type_text: "bigint".to_string(),
406 type_json: "\"long\"".to_string(),
407 type_name: ColumnTypeName::Long.into(),
408 position: Some(0),
409 nullable: Some(false),
410 ..Default::default()
411 },
412 Column {
413 name: "name".to_string(),
414 type_text: "string".to_string(),
415 type_json: "\"string\"".to_string(),
416 type_name: ColumnTypeName::String.into(),
417 position: Some(1),
418 nullable: Some(true),
419 ..Default::default()
420 },
421 ];
422
423 let created = h
424 .create_table(
425 CreateTableRequest {
426 name: "tbl".to_string(),
427 schema_name: "sch".to_string(),
428 catalog_name: "cat".to_string(),
429 table_type: TableType::External.into(),
430 data_source_format: DataSourceFormat::Delta.into(),
431 storage_location: Some("s3://bucket/ext/tbl".to_string()),
432 columns: columns.clone(),
433 ..Default::default()
434 },
435 ctx(),
436 )
437 .await
438 .expect("create external table");
439 assert_eq!(created.table_type, TableType::External);
440 let created_names: Vec<_> = created.columns.iter().map(|c| c.name.as_str()).collect();
441 assert_eq!(created_names, vec!["id", "name"]);
442
443 let fetched = h
444 .get_table(
445 GetTableRequest {
446 full_name: "cat.sch.tbl".to_string(),
447 ..Default::default()
448 },
449 ctx(),
450 )
451 .await
452 .expect("get external table");
453 let fetched_names: Vec<_> = fetched.columns.iter().map(|c| c.name.as_str()).collect();
454 assert_eq!(fetched_names, vec!["id", "name"]);
455 }
456
457 #[tokio::test]
462 async fn managed_table_via_bare_create_is_rejected() {
463 let h = handler();
464 let res = h
465 .create_table(
466 CreateTableRequest {
467 name: "t".to_string(),
468 schema_name: "sch".to_string(),
469 catalog_name: "cat".to_string(),
470 table_type: TableType::Managed.into(),
471 data_source_format: DataSourceFormat::Delta.into(),
472 storage_location: Some("s3://bucket/cat/__unitystorage/tables/x".to_string()),
473 ..Default::default()
474 },
475 ctx(),
476 )
477 .await;
478 assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
479 }
480
481 const METRIC_VIEW_YAML: &str = "version: \"1.1\"\nsource: cat.sch.orders\n\
482 measures:\n - name: revenue\n expr: SUM(price)\n";
483
484 #[tokio::test]
488 async fn metric_view_create_get_round_trip() {
489 let h = handler();
490 let created = h
491 .create_table(
492 CreateTableRequest {
493 name: "orders_metrics".to_string(),
494 schema_name: "sch".to_string(),
495 catalog_name: "cat".to_string(),
496 table_type: TableType::MetricView.into(),
497 view_definition: Some(METRIC_VIEW_YAML.to_string()),
498 ..Default::default()
499 },
500 ctx(),
501 )
502 .await
503 .expect("create metric view");
504 assert_eq!(created.table_type, TableType::MetricView);
505 assert_eq!(created.view_definition.as_deref(), Some(METRIC_VIEW_YAML));
506 assert_eq!(
508 dep_names(created.view_dependencies.as_option()),
509 vec!["cat.sch.orders"]
510 );
511
512 let fetched = h
513 .get_table(
514 GetTableRequest {
515 full_name: "cat.sch.orders_metrics".to_string(),
516 ..Default::default()
517 },
518 ctx(),
519 )
520 .await
521 .expect("get metric view");
522 assert_eq!(fetched.table_type, TableType::MetricView);
523 assert_eq!(fetched.view_definition.as_deref(), Some(METRIC_VIEW_YAML));
524 assert_eq!(
526 dep_names(fetched.view_dependencies.as_option()),
527 vec!["cat.sch.orders"]
528 );
529 }
530
531 fn dep_names(deps: Option<&DependencyList>) -> Vec<String> {
533 deps.map(|d| {
534 d.dependencies
535 .iter()
536 .filter_map(|dep| match &dep.dependency {
537 Some(dependency::Dependency::Table(t)) => Some(t.table_full_name.clone()),
538 _ => None,
539 })
540 .collect()
541 })
542 .unwrap_or_default()
543 }
544
545 fn metric_view_request() -> CreateTableRequest {
546 CreateTableRequest {
547 name: "orders_metrics".to_string(),
548 schema_name: "sch".to_string(),
549 catalog_name: "cat".to_string(),
550 table_type: TableType::MetricView.into(),
551 view_definition: Some(METRIC_VIEW_YAML.to_string()),
552 ..Default::default()
553 }
554 }
555
556 fn table_dep(full_name: &str) -> Dependency {
557 Dependency {
558 dependency: Some(dependency::Dependency::Table(Box::new(TableDependency {
559 table_full_name: full_name.to_string(),
560 ..Default::default()
561 }))),
562 ..Default::default()
563 }
564 }
565
566 #[tokio::test]
569 async fn metric_view_matching_dependencies_accepted() {
570 let h = handler();
571 let created = h
572 .create_table(
573 CreateTableRequest {
574 view_dependencies: Some(DependencyList {
575 dependencies: vec![table_dep("cat.sch.orders")],
576 ..Default::default()
577 })
578 .into(),
579 ..metric_view_request()
580 },
581 ctx(),
582 )
583 .await
584 .expect("create metric view with matching deps");
585 assert_eq!(
586 dep_names(created.view_dependencies.as_option()),
587 vec!["cat.sch.orders"]
588 );
589 }
590
591 #[tokio::test]
594 async fn metric_view_diverging_dependencies_rejected() {
595 let h = handler();
596 let res = h
597 .create_table(
598 CreateTableRequest {
599 view_dependencies: Some(DependencyList {
600 dependencies: vec![table_dep("cat.sch.something_else")],
601 ..Default::default()
602 })
603 .into(),
604 ..metric_view_request()
605 },
606 ctx(),
607 )
608 .await;
609 assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
610 }
611
612 #[tokio::test]
615 async fn metric_view_unresolvable_source_rejected() {
616 let h = handler();
617 let yaml = "version: \"1.1\"\nsource: orders\n\
618 measures:\n - name: revenue\n expr: SUM(price)\n";
619 let res = h
620 .create_table(
621 CreateTableRequest {
622 view_definition: Some(yaml.to_string()),
623 ..metric_view_request()
624 },
625 ctx(),
626 )
627 .await;
628 assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
629 }
630
631 #[tokio::test]
633 async fn metric_view_without_definition_is_rejected() {
634 let h = handler();
635 let res = h
636 .create_table(
637 CreateTableRequest {
638 name: "orders_metrics".to_string(),
639 schema_name: "sch".to_string(),
640 catalog_name: "cat".to_string(),
641 table_type: TableType::MetricView.into(),
642 view_definition: None,
643 ..Default::default()
644 },
645 ctx(),
646 )
647 .await;
648 assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
649 }
650}