Skip to main content

unitycatalog_delta_api/
handler.rs

1//! The `DeltaApiHandler` trait and its generic implementation over a
2//! [`DeltaBackend`].
3//!
4//! One trait method per `delta.yaml` operation. The blanket
5//! `impl<B: DeltaBackend<Cx>, Cx> DeltaApiHandler<Cx> for B` contains all the
6//! Delta business logic — the managed-table contract, the `updateTable` action
7//! dispatcher (applied in the reference's canonical order), `loadTable` commit-list
8//! construction, and the credential→config mapping — expressed purely in terms of
9//! the [`DeltaBackend`] port. A server that implements the port gets the handler,
10//! and thus the router, without writing any of that logic itself.
11
12use std::collections::BTreeMap;
13
14use async_trait::async_trait;
15
16use crate::authz::DeltaAction;
17use crate::backend::{
18    CreateTableSpec, CredentialAccess, DeltaBackend, ResolvedTable, SchemaRef, TableRef,
19    UpdateTableSpec, VendedCredential, VendedCredentialKind, etag_of,
20};
21use crate::column::Column;
22use crate::contract;
23use crate::coordinator::CommitInfo;
24use crate::error::{DeltaApiError, DeltaApiResult as Result, DeltaBackendError};
25use crate::models::*;
26
27/// Query parameters for `getConfig`.
28#[derive(Debug, Clone)]
29pub struct GetConfigQuery {
30    pub catalog: String,
31    /// Comma-separated list of highest protocol versions the client supports.
32    pub protocol_versions: String,
33}
34
35/// Handler for the Delta REST API. One method per `delta.yaml` operation.
36///
37/// Method names match the spec `operationId`s. Path/query parameters are passed
38/// as typed structs; request bodies use the wire model types.
39#[async_trait]
40pub trait DeltaApiHandler<Cx>: Send + Sync + 'static {
41    /// `GET /delta/v1/config`
42    async fn get_config(&self, query: GetConfigQuery, context: Cx) -> Result<DeltaCatalogConfig>;
43
44    /// `POST /delta/v1/catalogs/{catalog}/schemas/{schema}/staging-tables`
45    async fn create_staging_table(
46        &self,
47        path: SchemaRef,
48        request: DeltaCreateStagingTableRequest,
49        context: Cx,
50    ) -> Result<DeltaStagingTableResponse>;
51
52    /// `POST /delta/v1/catalogs/{catalog}/schemas/{schema}/tables`
53    async fn create_table(
54        &self,
55        path: SchemaRef,
56        request: DeltaCreateTableRequest,
57        context: Cx,
58    ) -> Result<DeltaLoadTableResponse>;
59
60    /// `GET /delta/v1/catalogs/{catalog}/schemas/{schema}/tables/{table}`
61    async fn load_table(&self, path: TableRef, context: Cx) -> Result<DeltaLoadTableResponse>;
62
63    /// `POST /delta/v1/catalogs/{catalog}/schemas/{schema}/tables/{table}`
64    async fn update_table(
65        &self,
66        path: TableRef,
67        request: DeltaUpdateTableRequest,
68        context: Cx,
69    ) -> Result<DeltaLoadTableResponse>;
70
71    /// `DELETE /delta/v1/catalogs/{catalog}/schemas/{schema}/tables/{table}`
72    async fn delete_table(&self, path: TableRef, context: Cx) -> Result<()>;
73
74    /// `HEAD /delta/v1/catalogs/{catalog}/schemas/{schema}/tables/{table}`
75    async fn table_exists(&self, path: TableRef, context: Cx) -> Result<()>;
76
77    /// `POST /delta/v1/catalogs/{catalog}/schemas/{schema}/tables/{table}/rename`
78    async fn rename_table(
79        &self,
80        path: TableRef,
81        request: DeltaRenameTableRequest,
82        context: Cx,
83    ) -> Result<()>;
84
85    /// `GET /delta/v1/catalogs/{catalog}/schemas/{schema}/tables/{table}/credentials`
86    async fn get_table_credentials(
87        &self,
88        path: TableRef,
89        operation: DeltaCredentialOperation,
90        context: Cx,
91    ) -> Result<DeltaCredentialsResponse>;
92
93    /// `POST /delta/v1/catalogs/{catalog}/schemas/{schema}/tables/{table}/metrics`
94    async fn report_metrics(
95        &self,
96        path: TableRef,
97        request: DeltaReportMetricsRequest,
98        context: Cx,
99    ) -> Result<()>;
100
101    /// `GET /delta/v1/staging-tables/{table_id}/credentials`
102    async fn get_staging_table_credentials(
103        &self,
104        table_id: String,
105        context: Cx,
106    ) -> Result<DeltaCredentialsResponse>;
107
108    /// `GET /delta/v1/temporary-path-credentials`
109    async fn get_temporary_path_credentials(
110        &self,
111        location: String,
112        operation: DeltaCredentialOperation,
113        context: Cx,
114    ) -> Result<DeltaCredentialsResponse>;
115}
116
117#[async_trait]
118impl<B, Cx> DeltaApiHandler<Cx> for B
119where
120    B: DeltaBackend<Cx>,
121    Cx: Send + 'static,
122{
123    async fn get_config(&self, query: GetConfigQuery, context: Cx) -> Result<DeltaCatalogConfig> {
124        if query.catalog.is_empty() {
125            return Err(DeltaApiError::invalid_argument(
126                "catalog query parameter is required",
127            ));
128        }
129        // Negotiate before the catalog lookup so an unsupported client fails fast
130        // with 400 without incurring a catalog resolve.
131        let protocol_version = crate::config::negotiate_version(&query.protocol_versions)?;
132        self.catalog_exists(&query.catalog, &context).await?;
133        Ok(DeltaCatalogConfig {
134            endpoints: crate::config::endpoints_for(self.capabilities()),
135            protocol_version,
136        })
137    }
138
139    async fn create_staging_table(
140        &self,
141        path: SchemaRef,
142        request: DeltaCreateStagingTableRequest,
143        context: Cx,
144    ) -> Result<DeltaStagingTableResponse> {
145        self.authorize(
146            DeltaAction::CreateStaging {
147                at: &path,
148                name: &request.name,
149            },
150            &context,
151        )
152        .await?;
153
154        let staging = self
155            .allocate_staging(&path, &request.name, &context)
156            .await?;
157
158        let creds = self
159            .vend_path_credential(&staging.location, CredentialAccess::ReadWrite, &context)
160            .await?;
161
162        Ok(DeltaStagingTableResponse {
163            table_id: staging.table_id.clone(),
164            table_type: DeltaTableType::Managed,
165            location: staging.location.clone(),
166            storage_credentials: vec![to_storage_credential(
167                &staging.location,
168                &creds,
169                DeltaCredentialOperation::ReadWrite,
170            )],
171            required_protocol: DeltaProtocol {
172                min_reader_version: contract::REQUIRED_MIN_READER_VERSION,
173                min_writer_version: contract::REQUIRED_MIN_WRITER_VERSION,
174                reader_features: Some(feature_vec(contract::REQUIRED_READER_FEATURES)),
175                writer_features: Some(feature_vec(contract::REQUIRED_WRITER_FEATURES)),
176            },
177            suggested_protocol: Some(DeltaSuggestedProtocol {
178                reader_features: Some(feature_vec(contract::SUGGESTED_READER_FEATURES)),
179                writer_features: Some(feature_vec(contract::SUGGESTED_WRITER_FEATURES)),
180            }),
181            required_properties: contract::required_properties(&staging.table_id),
182            suggested_properties: Some(contract::suggested_properties()),
183        })
184    }
185
186    async fn create_table(
187        &self,
188        path: SchemaRef,
189        request: DeltaCreateTableRequest,
190        context: Cx,
191    ) -> Result<DeltaLoadTableResponse> {
192        if request.name.is_empty() {
193            return Err(DeltaApiError::invalid_argument("Table name is required."));
194        }
195        if request.location.is_empty() {
196            return Err(DeltaApiError::invalid_argument(
197                "Table location is required.",
198            ));
199        }
200
201        self.authorize(
202            DeltaAction::CreateTable {
203                at: &path,
204                name: &request.name,
205                table_type: request.table_type,
206            },
207            &context,
208        )
209        .await?;
210
211        // MANAGED-only: validate the full catalog-managed contract.
212        if request.table_type == DeltaTableType::Managed {
213            contract::validate(
214                &request.protocol,
215                request.domain_metadata.as_ref(),
216                &request.properties,
217            )?;
218        }
219
220        let columns =
221            contract::delta_columns_to_uc(&request.columns, request.partition_columns.as_deref())?;
222        let stored_properties = contract::build_stored_properties(&request);
223
224        // For MANAGED, resolve + authorize the staging reservation. It is not
225        // consumed here: the reservation is handed to `create_table_row`, which
226        // consumes it and creates the table atomically (see `CreateTableSpec`).
227        let (table_id, adopt_staging) = if request.table_type == DeltaTableType::Managed {
228            let staging = self
229                .resolve_staging_by_location(&request.location, &context)
230                .await?;
231            // The creator-match: the backend decides, in its own identity terms,
232            // whether this caller may adopt the reservation.
233            self.authorize(
234                DeltaAction::AdoptStaging {
235                    reservation: &staging,
236                },
237                &context,
238            )
239            .await?;
240            if staging.stage_committed {
241                return Err(DeltaApiError::invalid_argument(format!(
242                    "staging table at '{}' has already been committed",
243                    request.location
244                )));
245            }
246            contract::validate_table_id_property(&request.properties, &staging.table_id)?;
247            (Some(staging.table_id.clone()), Some(staging))
248        } else {
249            // EXTERNAL: the location must live inside a registered external location.
250            self.validate_external_location(&request.location, &context)
251                .await?;
252            (None, None)
253        };
254
255        let full_name = format!("{}.{}.{}", path.catalog, path.schema, request.name);
256        let stored = self
257            .create_table_row(
258                CreateTableSpec {
259                    at: path,
260                    name: request.name,
261                    table_type: request.table_type,
262                    location: request.location,
263                    comment: request.comment,
264                    columns,
265                    properties: stored_properties,
266                    table_id,
267                    adopt_staging,
268                },
269                &context,
270            )
271            .await?;
272
273        build_load_table_response(self, &full_name, stored).await
274    }
275
276    async fn load_table(&self, path: TableRef, context: Cx) -> Result<DeltaLoadTableResponse> {
277        self.authorize(DeltaAction::ReadTable { table: &path }, &context)
278            .await?;
279        let table = self.resolve_table(&path, &context).await?;
280        build_load_table_response(self, &path.full_name(), table).await
281    }
282
283    async fn update_table(
284        &self,
285        path: TableRef,
286        request: DeltaUpdateTableRequest,
287        context: Cx,
288    ) -> Result<DeltaLoadTableResponse> {
289        update_table_impl(self, path, request, context).await
290    }
291
292    async fn delete_table(&self, path: TableRef, context: Cx) -> Result<()> {
293        self.authorize(DeltaAction::DeleteTable { table: &path }, &context)
294            .await?;
295        self.delete_table(&path, &context).await?;
296        Ok(())
297    }
298
299    async fn table_exists(&self, path: TableRef, context: Cx) -> Result<()> {
300        self.authorize(DeltaAction::ReadTable { table: &path }, &context)
301            .await?;
302        self.resolve_table(&path, &context).await?;
303        Ok(())
304    }
305
306    async fn rename_table(
307        &self,
308        path: TableRef,
309        request: DeltaRenameTableRequest,
310        context: Cx,
311    ) -> Result<()> {
312        self.authorize(
313            DeltaAction::RenameTable {
314                from: &path,
315                to: &request.new_name,
316            },
317            &context,
318        )
319        .await?;
320        self.rename_table(&path, &request.new_name, &context)
321            .await?;
322        Ok(())
323    }
324
325    async fn get_table_credentials(
326        &self,
327        path: TableRef,
328        operation: DeltaCredentialOperation,
329        context: Cx,
330    ) -> Result<DeltaCredentialsResponse> {
331        let table = self.resolve_table(&path, &context).await?;
332        let table_id = table
333            .table_id
334            .ok_or_else(|| DeltaApiError::invalid_argument("table has no id"))?;
335        let access = to_access(operation);
336        self.authorize(
337            DeltaAction::VendTableCredential {
338                table_id: &table_id,
339                access,
340            },
341            &context,
342        )
343        .await?;
344        let creds = self
345            .vend_table_credential(&table_id, access, &context)
346            .await?;
347        Ok(DeltaCredentialsResponse {
348            storage_credentials: vec![to_storage_credential(&creds.url, &creds, operation)],
349        })
350    }
351
352    async fn report_metrics(
353        &self,
354        path: TableRef,
355        request: DeltaReportMetricsRequest,
356        context: Cx,
357    ) -> Result<()> {
358        let table = self.resolve_table(&path, &context).await?;
359        let table_id = table
360            .table_id
361            .clone()
362            .ok_or_else(|| DeltaApiError::invalid_argument("table has no id"))?;
363        // Metrics reports feed table maintenance (compaction/checkpointing) and are
364        // an assertion by a writer about commits it has made, so we gate them on
365        // WRITE. This is deliberately stricter than the pre-crate behavior, which
366        // required no permission on this endpoint at all.
367        self.authorize(
368            DeltaAction::WriteTable {
369                table: &path,
370                table_id: &table_id,
371            },
372            &context,
373        )
374        .await?;
375        if table.table_id.as_deref() != Some(request.table_id.as_str()) {
376            return Err(DeltaApiError::invalid_argument(
377                "report table-id does not match the table identified by the path",
378            ));
379        }
380        if let Some(cv) = request
381            .report
382            .as_ref()
383            .and_then(|r| r.commit_report.as_ref())
384            .and_then(|c| c.file_size_histogram.as_ref())
385            .and_then(|h| h.commit_version)
386            && cv < 0
387        {
388            return Err(DeltaApiError::invalid_argument(
389                "commit-version must be non-negative",
390            ));
391        }
392        // Accept-and-ack: no maintenance scheduler yet.
393        Ok(())
394    }
395
396    async fn get_staging_table_credentials(
397        &self,
398        table_id: String,
399        context: Cx,
400    ) -> Result<DeltaCredentialsResponse> {
401        let staging = self.resolve_staging_by_id(&table_id, &context).await?;
402        self.authorize(
403            DeltaAction::VendPathCredential {
404                location: &staging.location,
405                access: CredentialAccess::ReadWrite,
406            },
407            &context,
408        )
409        .await?;
410        let creds = self
411            .vend_path_credential(&staging.location, CredentialAccess::ReadWrite, &context)
412            .await?;
413        Ok(DeltaCredentialsResponse {
414            storage_credentials: vec![to_storage_credential(
415                &staging.location,
416                &creds,
417                DeltaCredentialOperation::ReadWrite,
418            )],
419        })
420    }
421
422    async fn get_temporary_path_credentials(
423        &self,
424        location: String,
425        operation: DeltaCredentialOperation,
426        context: Cx,
427    ) -> Result<DeltaCredentialsResponse> {
428        let access = to_access(operation);
429        self.authorize(
430            DeltaAction::VendPathCredential {
431                location: &location,
432                access,
433            },
434            &context,
435        )
436        .await?;
437        let creds = self
438            .vend_path_credential(&location, access, &context)
439            .await?;
440        Ok(DeltaCredentialsResponse {
441            storage_credentials: vec![to_storage_credential(&creds.url, &creds, operation)],
442        })
443    }
444}
445
446// ===================================================================
447// updateTable action dispatcher (DeltaUpdateTableMapper)
448// ===================================================================
449
450/// Apply an `updateTable` request: check requirements, apply the action list in
451/// the reference's canonical order, route commit/backfill through the coordinator,
452/// persist metadata, and return the refreshed table.
453async fn update_table_impl<B, Cx>(
454    backend: &B,
455    path: TableRef,
456    request: DeltaUpdateTableRequest,
457    context: Cx,
458) -> Result<DeltaLoadTableResponse>
459where
460    B: DeltaBackend<Cx> + ?Sized,
461    Cx: Send + 'static,
462{
463    let mut table = backend.resolve_table(&path, &context).await?;
464    let table_uuid = table
465        .table_id
466        .clone()
467        .ok_or_else(|| DeltaApiError::invalid_argument("table has no id"))?;
468    backend
469        .authorize(
470            DeltaAction::WriteTable {
471                table: &path,
472                table_id: &table_uuid,
473            },
474            &context,
475        )
476        .await?;
477
478    // --- Requirements (assert-table-uuid mandatory; assert-etag optional) ---
479    //
480    // `assert-table-uuid` is validated against the resolved uuid (identity never
481    // changes under an update, so no race). Each `assert-etag` is fast-failed here
482    // against the resolved snapshot so a stale etag rejects the *whole* request
483    // before the add-commit coordinator call or any write lands — and every
484    // requirement is enforced, not just the last one. The asserted etag is *also*
485    // threaded into `UpdateTableSpec::expected_etag`, where the backend re-checks
486    // it as a compare-and-swap at write time to close the read-modify-write race
487    // the snapshot check alone leaves open.
488    let has_uuid_assert = request
489        .requirements
490        .iter()
491        .any(|r| matches!(r, DeltaTableRequirement::AssertTableUuid { .. }));
492    if !has_uuid_assert {
493        return Err(DeltaApiError::invalid_argument(
494            "assert-table-uuid requirement is required.",
495        ));
496    }
497    let current_etag = etag_of(&table);
498    let mut expected_etag: Option<String> = None;
499    for req in &request.requirements {
500        match req {
501            DeltaTableRequirement::AssertTableUuid { uuid } => {
502                if uuid != &table_uuid {
503                    return Err(DeltaApiError(DeltaBackendError::UpdateRequirementConflict(
504                        format!(
505                            "assert-table-uuid failed: expected {uuid} but table has {table_uuid}"
506                        ),
507                    )));
508                }
509            }
510            DeltaTableRequirement::AssertEtag { etag } => {
511                if etag != &current_etag {
512                    return Err(DeltaApiError(DeltaBackendError::UpdateRequirementConflict(
513                        "assert-etag failed: table has been modified".to_string(),
514                    )));
515                }
516                expected_etag = Some(etag.clone());
517            }
518        }
519    }
520
521    // --- Overlap checks (set/remove on the same key) ---
522    let set_prop_keys: Vec<&String> = request
523        .updates
524        .iter()
525        .filter_map(|u| match u {
526            DeltaTableUpdate::SetProperties { updates } => Some(updates.keys()),
527            _ => None,
528        })
529        .flatten()
530        .collect();
531    if let Some(removals) = request.updates.iter().find_map(|u| match u {
532        DeltaTableUpdate::RemoveProperties { removals } => Some(removals),
533        _ => None,
534    }) {
535        for r in removals {
536            if set_prop_keys.contains(&r) {
537                return Err(DeltaApiError::invalid_argument(format!(
538                    "set-properties and remove-properties overlap on key: {r}"
539                )));
540            }
541        }
542    }
543
544    // Work on the fields directly (taken, not cloned); the untouched `table` is
545    // reassembled below when no metadata change needs persisting.
546    let mut properties: BTreeMap<String, String> = std::mem::take(&mut table.properties);
547    let mut columns: Vec<Column> = std::mem::take(&mut table.columns);
548    let mut comment: Option<String> = None;
549    let is_managed = table.table_type == Some(DeltaTableType::Managed);
550    let mut metadata_changed = false;
551
552    // Apply in canonical order (not request order).
553    // 1. set-columns / set-partition-columns
554    if apply_schema_and_partitions(&mut columns, &request.updates)? {
555        metadata_changed = true;
556    }
557
558    // 2. set-protocol (re-derive delta.feature.* and re-validate for MANAGED)
559    if let Some(protocol) = request.updates.iter().find_map(|u| match u {
560        DeltaTableUpdate::SetProtocol { protocol } => Some(protocol),
561        _ => None,
562    }) {
563        properties.retain(|k, _| !k.starts_with("delta.feature."));
564        contract::derive_from_protocol(&mut properties, protocol);
565        if is_managed {
566            contract::validate(protocol, None, &properties)?;
567        }
568        metadata_changed = true;
569    }
570
571    // 3. set-properties / 4. remove-properties
572    for update in &request.updates {
573        match update {
574            DeltaTableUpdate::SetProperties { updates } => {
575                properties.extend(updates.clone());
576                metadata_changed = true;
577            }
578            DeltaTableUpdate::RemoveProperties { removals } => {
579                for k in removals {
580                    properties.remove(k);
581                }
582                metadata_changed = true;
583            }
584            _ => {}
585        }
586    }
587
588    // 5. set-domain-metadata / 6. remove-domain-metadata
589    for update in &request.updates {
590        match update {
591            DeltaTableUpdate::SetDomainMetadata { updates } => {
592                contract::derive_from_domain_metadata(&mut properties, updates);
593                metadata_changed = true;
594            }
595            DeltaTableUpdate::RemoveDomainMetadata { domains } => {
596                for d in domains {
597                    match d.as_str() {
598                        "delta.clustering" => {
599                            properties.remove("delta.clusteringColumns");
600                        }
601                        "delta.rowTracking" => {
602                            properties.remove("delta.rowTracking.rowIdHighWaterMark");
603                        }
604                        other => {
605                            return Err(DeltaApiError::invalid_argument(format!(
606                                "Unknown domain in remove-domain-metadata: {other}"
607                            )));
608                        }
609                    }
610                }
611                metadata_changed = true;
612            }
613            _ => {}
614        }
615    }
616
617    // 7. set-table-comment
618    if let Some(c) = request.updates.iter().find_map(|u| match u {
619        DeltaTableUpdate::SetTableComment { comment } => Some(comment.clone()),
620        _ => None,
621    }) {
622        comment = Some(c);
623        metadata_changed = true;
624    }
625
626    // 8. update-metadata-snapshot-version (EXTERNAL only)
627    if let Some((v, ts)) = request.updates.iter().find_map(|u| match u {
628        DeltaTableUpdate::UpdateMetadataSnapshotVersion {
629            last_commit_version,
630            last_commit_timestamp_ms,
631        } => Some((*last_commit_version, *last_commit_timestamp_ms)),
632        _ => None,
633    }) {
634        if is_managed {
635            return Err(DeltaApiError::invalid_argument(
636                "update-metadata-snapshot-version is only valid for EXTERNAL tables",
637            ));
638        }
639        properties.insert(
640            contract::PROP_LAST_UPDATE_VERSION.to_string(),
641            v.to_string(),
642        );
643        properties.insert(
644            contract::PROP_LAST_COMMIT_TIMESTAMP.to_string(),
645            ts.to_string(),
646        );
647        metadata_changed = true;
648    }
649
650    // 9. add-commit + set-latest-backfilled-version → commit coordinator
651    let add_commit = request.updates.iter().find_map(|u| match u {
652        DeltaTableUpdate::AddCommit { commit, .. } => Some(commit.clone()),
653        _ => None,
654    });
655    let backfill = request.updates.iter().find_map(|u| match u {
656        DeltaTableUpdate::SetLatestBackfilledVersion {
657            latest_published_version,
658        } => Some(*latest_published_version),
659        _ => None,
660    });
661    if add_commit.is_some() || backfill.is_some() {
662        if !is_managed {
663            return Err(DeltaApiError::invalid_argument(
664                "add-commit / set-latest-backfilled-version require a MANAGED table",
665            ));
666        }
667        let commit_info = add_commit.map(|c| CommitInfo {
668            version: c.version,
669            timestamp: c.timestamp,
670            file_name: c.file_name,
671            file_size: c.file_size,
672            file_modification_timestamp: c.file_modification_timestamp,
673        });
674        backend
675            .commit_coordinator()
676            .commit(&table_uuid, commit_info, backfill)
677            .await
678            .map_err(commit_error)?;
679    }
680
681    // Persist metadata changes; `update_table_row` returns the refreshed table
682    // (and enforces the assert-etag CAS at write time). The pure add-commit path
683    // never touches the table row, so the in-hand table is still current there.
684    let refreshed = if metadata_changed {
685        backend
686            .update_table_row(
687                UpdateTableSpec {
688                    table_id: table_uuid,
689                    columns,
690                    properties,
691                    comment,
692                    expected_etag,
693                },
694                &context,
695            )
696            .await?
697    } else {
698        // No metadata write to delegate the CAS to; any asserted etag was already
699        // fast-failed against this snapshot in the requirements loop above.
700        table.properties = properties;
701        table.columns = columns;
702        table
703    };
704    build_load_table_response(backend, &path.full_name(), refreshed).await
705}
706
707/// Apply `set-columns` / `set-partition-columns` to `columns` in place, returning
708/// whether any change was made. Mirrors the reference `applySchemaAndPartitionColumns`.
709fn apply_schema_and_partitions(
710    columns: &mut Vec<Column>,
711    updates: &[DeltaTableUpdate],
712) -> Result<bool> {
713    let new_columns = updates.iter().find_map(|u| match u {
714        DeltaTableUpdate::SetColumns { columns } => Some(columns),
715        _ => None,
716    });
717    let new_partitions = updates.iter().find_map(|u| match u {
718        DeltaTableUpdate::SetPartitionColumns { partition_columns } => Some(partition_columns),
719        _ => None,
720    });
721    if new_columns.is_none() && new_partitions.is_none() {
722        return Ok(false);
723    }
724
725    let result: Vec<Column> = match new_columns {
726        Some(struct_type) => {
727            // Preserve existing partitioning unless new partitions are supplied.
728            let existing_partitions = partition_names(columns);
729            let partitions = new_partitions.cloned().unwrap_or(existing_partitions);
730            contract::delta_columns_to_uc(struct_type, Some(&partitions))?
731        }
732        None => {
733            let partitions = new_partitions.expect("checked above");
734            let mut cols = columns.clone();
735            for col in &mut cols {
736                col.partition_index = partitions
737                    .iter()
738                    .position(|p| p.eq_ignore_ascii_case(&col.name))
739                    .map(|i| i as i32);
740            }
741            for p in partitions {
742                if !cols.iter().any(|c| c.name.eq_ignore_ascii_case(p)) {
743                    return Err(DeltaApiError::invalid_argument(format!(
744                        "partition column '{p}' is not present in the table schema"
745                    )));
746                }
747            }
748            cols
749        }
750    };
751    *columns = result;
752    Ok(true)
753}
754
755// ===================================================================
756// loadTable response construction
757// ===================================================================
758
759/// Build a `DeltaLoadTableResponse` from a resolved table, appending unbackfilled
760/// commits + `latest_table_version` for catalog-managed Delta tables.
761///
762/// Rejects table types the Delta API cannot serve (views, metric views, …) with
763/// 400; `full_name` is only used for that error message.
764async fn build_load_table_response<B, Cx>(
765    backend: &B,
766    full_name: &str,
767    table: ResolvedTable,
768) -> Result<DeltaLoadTableResponse>
769where
770    B: DeltaBackend<Cx> + ?Sized,
771{
772    let Some(table_type) = table.table_type else {
773        return Err(DeltaApiError::invalid_argument(format!(
774            "table '{full_name}' is not a Delta table and cannot be loaded via the Delta API"
775        )));
776    };
777
778    // Commit-coordinator state only exists for catalog-managed *Delta* tables;
779    // a MANAGED table of another format gets no commits/version fields.
780    let (commits, latest_table_version) = if table_type == DeltaTableType::Managed
781        && table.data_source_format == Some(DeltaDataSourceFormat::Delta)
782        && let Some(id) = table.table_id.as_deref()
783    {
784        let (commits, latest) = backend
785            .commit_coordinator()
786            .get_commits(id, 0, None)
787            .await
788            .map_err(commit_error)?;
789        (
790            Some(commits.into_iter().map(to_delta_commit).collect()),
791            Some(latest),
792        )
793    } else {
794        (None, None)
795    };
796
797    Ok(DeltaLoadTableResponse {
798        metadata: build_table_metadata(table, table_type),
799        commits,
800        uniform: None,
801        latest_table_version,
802    })
803}
804
805fn build_table_metadata(table: ResolvedTable, table_type: DeltaTableType) -> DeltaTableMetadata {
806    let etag = etag_of(&table);
807    let partition_columns = partition_names(&table.columns);
808    let columns = contract::uc_columns_to_delta(&table.columns);
809    let last_commit_version = table
810        .properties
811        .get(contract::PROP_LAST_UPDATE_VERSION)
812        .and_then(|v| v.parse().ok());
813    let last_commit_timestamp_ms = table
814        .properties
815        .get(contract::PROP_LAST_COMMIT_TIMESTAMP)
816        .and_then(|v| v.parse().ok());
817
818    DeltaTableMetadata {
819        etag,
820        table_type,
821        table_uuid: table.table_id.unwrap_or_default(),
822        location: table.location,
823        created_time: table.created_at_ms.unwrap_or_default(),
824        updated_time: table
825            .updated_at_ms
826            .or(table.created_at_ms)
827            .unwrap_or_default(),
828        columns,
829        partition_columns: (!partition_columns.is_empty()).then_some(partition_columns),
830        properties: table.properties,
831        last_commit_version,
832        last_commit_timestamp_ms,
833    }
834}
835
836// ===================================================================
837// Helpers
838// ===================================================================
839
840fn feature_vec(features: &[&str]) -> Vec<String> {
841    features.iter().map(|s| s.to_string()).collect()
842}
843
844fn to_access(op: DeltaCredentialOperation) -> CredentialAccess {
845    match op {
846        DeltaCredentialOperation::Read => CredentialAccess::Read,
847        DeltaCredentialOperation::ReadWrite => CredentialAccess::ReadWrite,
848    }
849}
850
851/// The partition-column names of a column set, ordered by partition index.
852fn partition_names(columns: &[Column]) -> Vec<String> {
853    let mut p: Vec<(&i32, &Column)> = columns
854        .iter()
855        .filter_map(|c| c.partition_index.as_ref().map(|idx| (idx, c)))
856        .collect();
857    p.sort_by_key(|(idx, _)| **idx);
858    p.into_iter().map(|(_, c)| c.name.clone()).collect()
859}
860
861fn to_delta_commit(c: CommitInfo) -> DeltaCommit {
862    DeltaCommit {
863        version: c.version,
864        timestamp: c.timestamp,
865        file_name: c.file_name,
866        file_size: c.file_size,
867        file_modification_timestamp: c.file_modification_timestamp,
868    }
869}
870
871/// Map a coordinator [`CommitError`](crate::coordinator::CommitError) into a
872/// [`DeltaApiError`].
873fn commit_error(err: crate::coordinator::CommitError) -> DeltaApiError {
874    use crate::coordinator::CommitError;
875    DeltaApiError(match err {
876        CommitError::VersionConflict(m) => DeltaBackendError::CommitVersionConflict(m),
877        CommitError::InvalidArgument(m) => DeltaBackendError::InvalidArgument(m),
878        CommitError::ResourceExhausted(m) => DeltaBackendError::ResourceExhausted(m),
879        CommitError::Backend(m) => DeltaBackendError::Internal(m),
880    })
881}
882
883/// Map a vended [`VendedCredential`] onto the wire `DeltaStorageCredential`.
884fn to_storage_credential(
885    prefix: &str,
886    creds: &VendedCredential,
887    operation: DeltaCredentialOperation,
888) -> DeltaStorageCredential {
889    let mut config = DeltaStorageCredentialConfig::default();
890    match &creds.kind {
891        VendedCredentialKind::S3 {
892            access_key_id,
893            secret_access_key,
894            session_token,
895        } => {
896            config.s3_access_key_id = Some(access_key_id.clone());
897            config.s3_secret_access_key = Some(secret_access_key.clone());
898            config.s3_session_token = session_token.clone();
899        }
900        VendedCredentialKind::AzureSas { sas_token } => {
901            config.azure_sas_token = Some(sas_token.clone());
902        }
903        VendedCredentialKind::GcsOauth { oauth_token } => {
904            config.gcs_oauth_token = Some(oauth_token.clone());
905        }
906        VendedCredentialKind::None => {}
907    }
908    DeltaStorageCredential {
909        prefix: prefix.to_string(),
910        operation,
911        config,
912        expiration_time_ms: Some(creds.expiration_time_ms),
913    }
914}