Skip to main content

scopedb_client/
catalog.rs

1// Copyright 2024 ScopeDB, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::marker::PhantomData;
16
17use fastrace_reqwest::traceparent_headers;
18use reqwest::Method;
19use serde::Deserialize;
20use serde::Serialize;
21use serde::de::DeserializeOwned;
22
23use crate::Client;
24use crate::DataType;
25use crate::Error;
26use crate::ErrorKind;
27use crate::protocol::apply_response_metadata;
28use crate::protocol::decode_http_error;
29
30/// Complete tenant catalog. Apply replaces the entire selected subtree.
31/// Typed definitions reject unknown fields; use [`Catalog::raw`] for newer definitions.
32#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct CatalogDocument {
35    #[serde(default)]
36    pub databases: Vec<CatalogDatabase>,
37}
38
39#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct CatalogDatabase {
42    pub name: String,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub comment: Option<String>,
45    #[serde(default)]
46    pub schemas: Vec<CatalogSchema>,
47}
48
49#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct CatalogSchema {
52    pub name: String,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub comment: Option<String>,
55    #[serde(default)]
56    pub tables: Vec<CatalogTable>,
57    #[serde(default)]
58    pub views: Vec<CatalogView>,
59}
60
61/// A complete table definition. Omitted collections are empty and omitted
62/// optional fields are cleared. Storage is accepted only at creation.
63#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct CatalogTable {
66    pub name: String,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub comment: Option<String>,
69    #[serde(default)]
70    pub columns: Vec<CatalogColumn>,
71    #[serde(default)]
72    pub partition_by: Vec<String>,
73    #[serde(default)]
74    pub cluster_by: Vec<String>,
75    #[serde(default)]
76    pub distinct: CatalogDistinct,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub retention_days: Option<i32>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub storage: Option<serde_json::Value>,
81}
82
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct CatalogColumn {
86    pub name: String,
87    pub data_type: DataType,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub expression: Option<String>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub comment: Option<String>,
92    #[serde(default)]
93    pub indexes: Vec<CatalogColumnIndex>,
94}
95
96/// Additional column index. Supports type "search" and analyzer "log".
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct CatalogColumnIndex {
100    #[serde(rename = "type")]
101    pub index_type: String,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub analyzer: Option<String>,
104}
105
106#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct CatalogDistinct {
109    #[serde(default)]
110    pub on: Vec<String>,
111    #[serde(default)]
112    pub by: Vec<String>,
113}
114
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116#[serde(deny_unknown_fields)]
117pub struct CatalogView {
118    pub name: String,
119    pub statement: String,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub comment: Option<String>,
122}
123
124/// Preview, concurrency preconditions, and explicit data-loss authorization.
125#[derive(Debug, Clone, Default)]
126pub struct CatalogOptions {
127    pub dry_run: bool,
128    pub allow_destructive: bool,
129    /// Opaque ETag returned by Get or a dry-run result, including its quotes.
130    pub if_match: Option<String>,
131}
132
133#[derive(Debug, Clone)]
134pub struct CatalogSnapshot<T> {
135    pub definition: T,
136    pub etag: String,
137}
138
139/// Normalized states compared in one transaction; None means absent.
140#[derive(Debug, Clone, Deserialize)]
141pub struct CatalogResult<T> {
142    pub before: Option<T>,
143    pub after: Option<T>,
144    pub destructive: bool,
145    /// The base version for dry-run, or the resulting version after a mutation.
146    #[serde(skip)]
147    pub etag: String,
148}
149
150/// Reads database, schema, table, and view definitions.
151///
152/// Select the smallest resource you need, then call [`CatalogResource::get`].
153/// Each read returns the complete definition and an ETag for conditional changes.
154///
155/// ```no_run
156/// # async fn example(client: scopedb_client::Client) -> Result<(), scopedb_client::Error> {
157/// let snapshot = client.catalog().schema("scopedb", "public").get().await?;
158/// for table in snapshot.definition.tables {
159///     println!("{}: {} columns", table.name, table.columns.len());
160/// }
161/// # Ok(())
162/// # }
163/// ```
164///
165/// Use [`CatalogResource::apply`] to change a definition and
166/// [`CatalogResource::delete`] to remove a resource. Both support previews and
167/// ETag preconditions through [`CatalogOptions`].
168#[derive(Debug, Clone)]
169pub struct Catalog {
170    resource: CatalogResource<CatalogDocument>,
171}
172
173impl Catalog {
174    pub(crate) fn new(client: Client) -> Self {
175        Self {
176            resource: CatalogResource::new(client, &["catalog"]),
177        }
178    }
179
180    pub async fn get(&self) -> Result<CatalogSnapshot<CatalogDocument>, Error> {
181        self.resource.get().await
182    }
183
184    /// Replaces the complete tenant catalog. An empty document clears it.
185    pub async fn apply(
186        &self,
187        definition: &CatalogDocument,
188        options: CatalogOptions,
189    ) -> Result<CatalogResult<CatalogDocument>, Error> {
190        self.resource.apply(definition, options).await
191    }
192
193    /// Retains fields unknown to this SDK when reading and applying definitions.
194    pub fn raw(&self) -> CatalogResource<serde_json::Value> {
195        self.resource.raw()
196    }
197
198    pub fn database(&self, database: &str) -> CatalogResource<CatalogDatabase> {
199        CatalogResource::new(
200            self.resource.client.clone(),
201            &["catalog", "databases", database],
202        )
203    }
204    pub fn schema(&self, database: &str, schema: &str) -> CatalogResource<CatalogSchema> {
205        CatalogResource::new(
206            self.resource.client.clone(),
207            &["catalog", "databases", database, "schemas", schema],
208        )
209    }
210    pub fn table(
211        &self,
212        database: &str,
213        schema: &str,
214        table: &str,
215    ) -> CatalogResource<CatalogTable> {
216        CatalogResource::new(
217            self.resource.client.clone(),
218            &[
219                "catalog",
220                "databases",
221                database,
222                "schemas",
223                schema,
224                "tables",
225                table,
226            ],
227        )
228    }
229    pub fn view(&self, database: &str, schema: &str, view: &str) -> CatalogResource<CatalogView> {
230        CatalogResource::new(
231            self.resource.client.clone(),
232            &[
233                "catalog",
234                "databases",
235                database,
236                "schemas",
237                schema,
238                "views",
239                view,
240            ],
241        )
242    }
243}
244
245/// A selected subtree. Names are unescaped identifiers. Apply replaces the whole
246/// definition; it is not a patch. Mutations never retry automatically.
247///
248/// An interrupted or malformed mutation response reports
249/// `catalog_commit_outcome_unknown`. Read the current state before deciding
250/// whether to retry. Typed definitions reject unsupported fields; [`Self::raw`]
251/// preserves them, including arbitrary-precision JSON numbers.
252#[derive(Debug, Clone)]
253pub struct CatalogResource<T> {
254    client: Client,
255    path: Vec<String>,
256    definition: PhantomData<T>,
257}
258
259impl<T> CatalogResource<T> {
260    fn new(client: Client, path: &[&str]) -> Self {
261        Self {
262            client,
263            path: path.iter().map(|s| (*s).to_string()).collect(),
264            definition: PhantomData,
265        }
266    }
267
268    pub fn raw(&self) -> CatalogResource<serde_json::Value> {
269        CatalogResource {
270            client: self.client.clone(),
271            path: self.path.clone(),
272            definition: PhantomData,
273        }
274    }
275
276    async fn request<R: DeserializeOwned>(
277        &self,
278        method: Method,
279        body: Option<Vec<u8>>,
280        options: CatalogOptions,
281    ) -> Result<(R, String), Error> {
282        let mutation = method != Method::GET;
283        let writing = mutation && !options.dry_run;
284        let url = self
285            .client
286            .make_resource_url(&self.path.iter().map(String::as_str).collect::<Vec<_>>())?;
287        let mut request = self
288            .client
289            .request(method.clone(), url)
290            .headers(traceparent_headers());
291        if options.dry_run {
292            request = request.query(&[("dry_run", true)]);
293        }
294        if options.allow_destructive {
295            request = request.query(&[("allow_destructive", true)]);
296        }
297        if let Some(etag) = options.if_match {
298            let etag = reqwest::header::HeaderValue::from_str(&etag).map_err(|e| {
299                Error::new(ErrorKind::ConfigInvalid, "invalid If-Match header").set_source(e)
300            })?;
301            request = request.header(reqwest::header::IF_MATCH, etag);
302        }
303        if let Some(body) = body {
304            request = request
305                .header(reqwest::header::CONTENT_TYPE, "application/json")
306                .body(body);
307        }
308        let mut response_metadata = None;
309        let read_response = async {
310            let response = request.send().await.map_err(|e| {
311                catalog_transport_error(writing, "failed to send catalog request").set_source(e)
312            })?;
313            let status = response.status();
314            let headers = response.headers().clone();
315            response_metadata = Some((status, headers.clone()));
316            let bytes = response.bytes().await.map_err(|e| {
317                apply_response_metadata(
318                    catalog_transport_error(writing, "failed to read catalog response")
319                        .set_source(e),
320                    status,
321                    &headers,
322                )
323            })?;
324            Ok::<_, Error>((status, headers, bytes))
325        };
326        let (status, headers, bytes) = if mutation {
327            match tokio::time::timeout(self.client.write_timeout(), read_response).await {
328                Ok(result) => result?,
329                Err(source) => {
330                    let error = catalog_transport_error(writing, "catalog request timed out")
331                        .set_source(source);
332                    return Err(match response_metadata {
333                        Some((status, headers)) => apply_response_metadata(error, status, &headers),
334                        None => error,
335                    });
336                }
337            }
338        } else {
339            read_response.await?
340        };
341        if !status.is_success() {
342            let error = decode_http_error(status, &headers, &bytes, ErrorKind::CatalogFailed);
343            return Err(if writing {
344                if error.code().is_none() {
345                    error
346                        .set_api_error(Some("catalog_commit_outcome_unknown".to_string()), None)
347                        .set_persistent()
348                } else {
349                    error.set_persistent()
350                }
351            } else {
352                error
353            });
354        }
355        let etag = headers
356            .get(reqwest::header::ETAG)
357            .and_then(|h| h.to_str().ok())
358            .filter(|s| !s.is_empty())
359            .ok_or_else(|| {
360                apply_response_metadata(
361                    catalog_response_error(writing, "catalog response is missing ETag"),
362                    status,
363                    &headers,
364                )
365            })?
366            .to_string();
367        let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
368            apply_response_metadata(
369                catalog_response_error(writing, "invalid catalog response").set_source(e),
370                status,
371                &headers,
372            )
373        })?;
374        let valid_resource = |v: &serde_json::Value| v.is_object() || v.is_null();
375        if !value.is_object()
376            || (mutation
377                && !(value.get("before").is_some_and(valid_resource)
378                    && value.get("after").is_some_and(valid_resource)
379                    && value
380                        .get("destructive")
381                        .is_some_and(serde_json::Value::is_boolean)))
382        {
383            return Err(apply_response_metadata(
384                catalog_response_error(writing, "invalid catalog response"),
385                status,
386                &headers,
387            ));
388        }
389        let result = serde_json::from_value(value).map_err(|e| {
390            apply_response_metadata(
391                catalog_response_error(
392                    writing,
393                    "cannot decode catalog definition; use raw() to retain unsupported fields",
394                )
395                .set_source(e),
396                status,
397                &headers,
398            )
399        })?;
400        Ok((result, etag))
401    }
402}
403
404impl<T: Serialize + DeserializeOwned> CatalogResource<T> {
405    /// Reads the complete definition and its ETag.
406    pub async fn get(&self) -> Result<CatalogSnapshot<T>, Error> {
407        let (definition, etag) = self
408            .request(Method::GET, None, CatalogOptions::default())
409            .await?;
410        Ok(CatalogSnapshot { definition, etag })
411    }
412
413    /// Replaces the selected definition, including its children. Omitted fields
414    /// are cleared. Start from a snapshot to retain fields you are not changing.
415    ///
416    /// Preview with `dry_run` and use `if_match` to reject concurrent changes.
417    /// Set `allow_destructive` only after reviewing the potential data loss.
418    ///
419    /// ```no_run
420    /// use scopedb_client::CatalogOptions;
421    ///
422    /// # async fn example(client: scopedb_client::Client) -> Result<(), scopedb_client::Error> {
423    /// let resource = client.table("events").catalog();
424    /// let snapshot = resource.get().await?;
425    /// let mut definition = snapshot.definition;
426    /// definition.comment = Some("Application events".to_string());
427    ///
428    /// let preview = resource
429    ///     .apply(
430    ///         &definition,
431    ///         CatalogOptions {
432    ///             dry_run: true,
433    ///             if_match: Some(snapshot.etag),
434    ///             ..Default::default()
435    ///         },
436    ///     )
437    ///     .await?;
438    /// println!("destructive = {}", preview.destructive);
439    /// resource
440    ///     .apply(
441    ///         &definition,
442    ///         CatalogOptions {
443    ///             if_match: Some(preview.etag),
444    ///             ..Default::default()
445    ///         },
446    ///     )
447    ///     .await?;
448    /// # Ok(())
449    /// # }
450    /// ```
451    pub async fn apply(
452        &self,
453        definition: &T,
454        options: CatalogOptions,
455    ) -> Result<CatalogResult<T>, Error> {
456        let body = serde_json::to_vec(definition).map_err(|e| {
457            Error::new(
458                ErrorKind::ConfigInvalid,
459                "failed to serialize catalog definition",
460            )
461            .set_source(e)
462        })?;
463        let (mut result, etag): (CatalogResult<T>, _) =
464            self.request(Method::PUT, Some(body), options).await?;
465        result.etag = etag;
466        Ok(result)
467    }
468
469    /// Deletes the selected resource. Use `dry_run` to preview the change and
470    /// `if_match` to require the ETag from a prior read or preview.
471    pub async fn delete(&self, options: CatalogOptions) -> Result<CatalogResult<T>, Error> {
472        if self.path.len() == 1 {
473            return Err(Error::new(
474                ErrorKind::ConfigInvalid,
475                "the root catalog does not support DELETE; apply an empty document instead",
476            ));
477        }
478        let (mut result, etag): (CatalogResult<T>, _) =
479            self.request(Method::DELETE, None, options).await?;
480        result.etag = etag;
481        Ok(result)
482    }
483}
484
485fn catalog_transport_error(writing: bool, message: &str) -> Error {
486    let error = catalog_response_error(writing, message);
487    if writing {
488        error
489    } else {
490        error.set_temporary()
491    }
492}
493
494fn catalog_response_error(writing: bool, message: &str) -> Error {
495    let error = Error::new(ErrorKind::CatalogFailed, message);
496    if writing {
497        error
498            .set_api_error(Some("catalog_commit_outcome_unknown".to_string()), None)
499            .set_persistent()
500    } else {
501        error
502    }
503}