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                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        // 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                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            // External table: a metadata-only registration over data that already
154            // exists at `storage_location`. Per the Unity Catalog / Databricks
155            // createTable contract, the server records the catalog row and the
156            // caller-supplied column schema; it never reads or writes the Delta
157            // log. `storage_location` is required for external tables.
158            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            // Enforce the governance rules on the location: it must live inside a
163            // registered external location and must not overlap any existing table
164            // or volume (Unity Catalog forbids overlapping governed storage
165            // regions). This is a store/metadata check — no cloud access.
166            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            // Managed tables are not provisioned through the bare createTable
181            // endpoint: the catalog must own the commit log, so a managed table is
182            // created through the `/delta/v1` staging flow (createStagingTable →
183            // write `_delta_log/0.json` → createTable), which registers the table
184            // atomically against its staging reservation. Reject here with a clear
185            // pointer rather than silently registering a table with no commit log.
186            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            // Metric view: a semantic layer with no storage of its own. The
193            // definition (YAML) lives in `view_definition`; there is no Delta
194            // snapshot to read and no columns to derive here.
195            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            // The definition is the single source of truth: parse it and derive
202            // the dependency list. A client-supplied `view_dependencies` is only
203            // accepted if it matches what we derive (the definition wins).
204            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        // TODO: update the table with the current actor as owner
237        // TODO: create updated_* relations
238        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        // TODO: get columns etc ...
246        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    /// An external table whose storage location is not within any registered
313    /// external location is rejected by the governance containment check.
314    #[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    /// Register `cred` + `ext` so an external table at `s3://bucket/ext/...` is
362    /// inside a registered external location.
363    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    /// createTable is a metadata-only registration: an external table persists the
393    /// caller-supplied columns verbatim (no Delta log is read) and they round-trip
394    /// through `get_table`.
395    #[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    /// Managed tables cannot be created through the bare createTable endpoint: the
456    /// catalog owns the commit log, so managed creation goes through the
457    /// `/delta/v1` staging flow. A bare managed create is rejected with
458    /// `InvalidArgument`.
459    #[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    /// A metric view is created with its YAML definition (no storage location,
483    /// no Delta snapshot) and round-trips through `get_table` with the
484    /// `view_definition` and `table_type` intact.
485    #[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        // Dependencies are derived from the definition's `source`.
505        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        // The derived dependencies round-trip through get.
523        assert_eq!(
524            dep_names(fetched.view_dependencies.as_option()),
525            vec!["cat.sch.orders"]
526        );
527    }
528
529    /// Extract the `table_full_name`s from a [`DependencyList`] for assertions.
530    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    /// A client-supplied `view_dependencies` that matches the derived set is
565    /// accepted.
566    #[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    /// A client-supplied `view_dependencies` that diverges from the definition
590    /// is rejected.
591    #[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    /// A metric view whose source cannot be resolved to a three-part name is
611    /// rejected (strict derivation).
612    #[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    /// A metric view without a `view_definition` is rejected.
630    #[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}