Skip to main content

unitycatalog_server/api/
tables.rs

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        // TODO: handle like operators for schema and table name
99        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        // TODO: assert max_results is within bounds <= 50
123        self.check_required(&request, &context).await?;
124        // TODO: handle like operators for schema and table name
125        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            // External table: a metadata-only registration over data that already
156            // exists at `storage_location`. Per the Unity Catalog / Databricks
157            // createTable contract, the server records the catalog row and the
158            // caller-supplied column schema; it never reads or writes the Delta
159            // log. `storage_location` is required for external tables.
160            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            // Enforce the governance rules on the location: it must live inside a
165            // registered external location and must not overlap any existing table
166            // or volume (Unity Catalog forbids overlapping governed storage
167            // regions). This is a store/metadata check — no cloud access.
168            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            // Managed tables are not provisioned through the bare createTable
183            // endpoint: the catalog must own the commit log, so a managed table is
184            // created through the `/delta/v1` staging flow (createStagingTable →
185            // write `_delta_log/0.json` → createTable), which registers the table
186            // atomically against its staging reservation. Reject here with a clear
187            // pointer rather than silently registering a table with no commit log.
188            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            // Metric view: a semantic layer with no storage of its own. The
195            // definition (YAML) lives in `view_definition`; there is no Delta
196            // snapshot to read and no columns to derive here.
197            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            // The definition is the single source of truth: parse it and derive
204            // the dependency list. A client-supplied `view_dependencies` is only
205            // accepted if it matches what we derive (the definition wins).
206            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        // TODO: update the table with the current actor as owner
239        // TODO: create updated_* relations
240        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        // TODO: get columns etc ...
248        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    /// An external table whose storage location is not within any registered
315    /// external location is rejected by the governance containment check.
316    #[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    /// Register `cred` + `ext` so an external table at `s3://bucket/ext/...` is
364    /// inside a registered external location.
365    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    /// createTable is a metadata-only registration: an external table persists the
395    /// caller-supplied columns verbatim (no Delta log is read) and they round-trip
396    /// through `get_table`.
397    #[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    /// Managed tables cannot be created through the bare createTable endpoint: the
458    /// catalog owns the commit log, so managed creation goes through the
459    /// `/delta/v1` staging flow. A bare managed create is rejected with
460    /// `InvalidArgument`.
461    #[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    /// A metric view is created with its YAML definition (no storage location,
485    /// no Delta snapshot) and round-trips through `get_table` with the
486    /// `view_definition` and `table_type` intact.
487    #[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        // Dependencies are derived from the definition's `source`.
507        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        // The derived dependencies round-trip through get.
525        assert_eq!(
526            dep_names(fetched.view_dependencies.as_option()),
527            vec!["cat.sch.orders"]
528        );
529    }
530
531    /// Extract the `table_full_name`s from a [`DependencyList`] for assertions.
532    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    /// A client-supplied `view_dependencies` that matches the derived set is
567    /// accepted.
568    #[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    /// A client-supplied `view_dependencies` that diverges from the definition
592    /// is rejected.
593    #[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    /// A metric view whose source cannot be resolved to a three-part name is
613    /// rejected (strict derivation).
614    #[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    /// A metric view without a `view_definition` is rejected.
632    #[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}