Skip to main content

ytsaurus_client/
schema.rs

1//! Table schemas: what a table promises about its columns.
2//!
3//! A schematised table is worth the trouble because the cluster then checks
4//! every write against it, stores columns in their own type rather than as
5//! YSON, and can sort and merge them. An unschematised one accepts anything and
6//! finds out later.
7//!
8//! The wire form is a YSON list of column dicts, carrying attributes:
9//!
10//! ```text
11//! <strict=%true;unique_keys=%false>[{name="key";type="string";required=%true};…]
12//! ```
13//!
14//! Build one by hand with [`TableSchema::new`], or derive it from the struct
15//! the rows already have — see [`TableRow`].
16//!
17//! Reference:
18//! <https://ytsaurus.tech/docs/en/user-guide/storage/static-schema>
19
20use ytsaurus_yson::YsonValue;
21
22use crate::yson_build::{boolean, list, map, string, with_attributes};
23
24/// A column's type, in the `type` spelling.
25///
26/// The primitives a job's row can hold. Composite types — lists, structs,
27/// tuples — are out of scope here; a column holding one is described as
28/// [`ColumnType::Any`], which is what YTsaurus stores an arbitrary YSON value
29/// as.
30///
31/// These are the **`type`** names. YTsaurus has a second, newer spelling,
32/// `type_v3`, and exactly two names differ between them: `boolean` is `bool`
33/// there, and `any` is `yson`. Sending a `type_v3` name in a `type` field is
34/// refused — `Error parsing ESimpleLogicalValueType value "bool"` — so the two
35/// vocabularies must not be mixed. Every other name is the same string in both.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ColumnType {
38    /// 8-bit signed integer.
39    Int8,
40    /// 16-bit signed integer.
41    Int16,
42    /// 32-bit signed integer.
43    Int32,
44    /// 64-bit signed integer.
45    Int64,
46    /// 8-bit unsigned integer.
47    Uint8,
48    /// 16-bit unsigned integer.
49    Uint16,
50    /// 32-bit unsigned integer.
51    Uint32,
52    /// 64-bit unsigned integer.
53    Uint64,
54    /// Single-precision float.
55    Float,
56    /// Double-precision float.
57    Double,
58    /// Boolean.
59    Boolean,
60    /// A byte string. YTsaurus strings are arbitrary bytes, not text.
61    String,
62    /// A string the cluster checks is valid UTF-8.
63    Utf8,
64    /// Any YSON value, stored as-is.
65    ///
66    /// Never required: the cluster answers `Column of type "any" cannot be
67    /// "required"`.
68    Any,
69
70    // The temporal and tagged types. Nothing maps to them automatically — a
71    // Rust `i64` is an `int64`, and turning it into an `interval` because it
72    // looks like one is how a schema comes to lie about the data. Ask for them
73    // by name.
74    /// Days since the Unix epoch, unsigned.
75    Date,
76    /// Seconds since the Unix epoch, unsigned.
77    Datetime,
78    /// Microseconds since the Unix epoch, unsigned.
79    Timestamp,
80    /// A signed count of microseconds.
81    Interval,
82    /// Signed days since the Unix epoch.
83    Date32,
84    /// Signed seconds since the Unix epoch.
85    Datetime64,
86    /// Signed microseconds since the Unix epoch.
87    Timestamp64,
88    /// A signed count of microseconds, over the wider range.
89    Interval64,
90    /// UTF-8 text the cluster checks is valid JSON.
91    Json,
92    /// A 16-byte UUID.
93    Uuid,
94    /// A column that holds nothing.
95    ///
96    /// Reads back as `required=%false` without an `optional` wrapper, unlike
97    /// every other type.
98    Void,
99    /// The type with no values at all.
100    Null,
101}
102
103impl ColumnType {
104    /// The wire name.
105    #[must_use]
106    pub fn as_str(self) -> &'static str {
107        match self {
108            ColumnType::Int8 => "int8",
109            ColumnType::Int16 => "int16",
110            ColumnType::Int32 => "int32",
111            ColumnType::Int64 => "int64",
112            ColumnType::Uint8 => "uint8",
113            ColumnType::Uint16 => "uint16",
114            ColumnType::Uint32 => "uint32",
115            ColumnType::Uint64 => "uint64",
116            ColumnType::Float => "float",
117            ColumnType::Double => "double",
118            ColumnType::Boolean => "boolean",
119            ColumnType::String => "string",
120            ColumnType::Utf8 => "utf8",
121            ColumnType::Any => "any",
122            ColumnType::Date => "date",
123            ColumnType::Datetime => "datetime",
124            ColumnType::Timestamp => "timestamp",
125            ColumnType::Interval => "interval",
126            ColumnType::Date32 => "date32",
127            ColumnType::Datetime64 => "datetime64",
128            ColumnType::Timestamp64 => "timestamp64",
129            ColumnType::Interval64 => "interval64",
130            ColumnType::Json => "json",
131            ColumnType::Uuid => "uuid",
132            ColumnType::Void => "void",
133            ColumnType::Null => "null",
134        }
135    }
136
137    /// Whether a column of this type may be declared required.
138    ///
139    /// Three types may not, and the cluster says so in as many words —
140    /// `Column of type "any" cannot be "required"`, `Null type cannot be
141    /// required`, and the same for `void`. Each of them already means "there
142    /// may be nothing here", so promising a value would contradict the type.
143    #[must_use]
144    pub fn can_be_required(self) -> bool {
145        !matches!(self, ColumnType::Any | ColumnType::Null | ColumnType::Void)
146    }
147
148    /// Parses a wire name, for the derive's `#[yt(column_type = "…")]` escape
149    /// hatch and for anyone building a schema from configuration.
150    ///
151    /// Accepts either vocabulary — `bool` and `yson` are understood as the
152    /// `type_v3` spellings of `boolean` and `any` — but what comes back is
153    /// always the `type` spelling, which is the one this crate sends.
154    ///
155    /// Not `FromStr`: an unknown type name is not an error worth a type of its
156    /// own, and every caller here wants the `Option`.
157    #[must_use]
158    pub fn parse(name: &str) -> Option<Self> {
159        Some(match name {
160            "int8" => ColumnType::Int8,
161            "int16" => ColumnType::Int16,
162            "int32" => ColumnType::Int32,
163            "int64" => ColumnType::Int64,
164            "uint8" => ColumnType::Uint8,
165            "uint16" => ColumnType::Uint16,
166            "uint32" => ColumnType::Uint32,
167            "uint64" => ColumnType::Uint64,
168            "float" => ColumnType::Float,
169            "double" => ColumnType::Double,
170            // "bool" is the type_v3 spelling of the same type.
171            "boolean" | "bool" => ColumnType::Boolean,
172            "string" => ColumnType::String,
173            "utf8" => ColumnType::Utf8,
174            // "yson" is the type_v3 spelling of the same type.
175            "any" | "yson" => ColumnType::Any,
176            "date" => ColumnType::Date,
177            "datetime" => ColumnType::Datetime,
178            "timestamp" => ColumnType::Timestamp,
179            "interval" => ColumnType::Interval,
180            "date32" => ColumnType::Date32,
181            "datetime64" => ColumnType::Datetime64,
182            "timestamp64" => ColumnType::Timestamp64,
183            "interval64" => ColumnType::Interval64,
184            "json" => ColumnType::Json,
185            "uuid" => ColumnType::Uuid,
186            "void" => ColumnType::Void,
187            "null" => ColumnType::Null,
188            _ => return None,
189        })
190    }
191}
192
193/// Which way a key column is sorted.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum SortOrder {
196    /// Smallest first. The only order a cluster accepts today.
197    Ascending,
198    /// Largest first.
199    ///
200    /// **A cluster is likely to refuse this.** The order exists in the
201    /// protocol, but creating a table with it was answered with
202    /// `Descending sort order is not available in this context yet`; it is
203    /// gated behind `//sys/@config/enable_descending_sort_order`, off by
204    /// default. It is here because the protocol has it, not because it works.
205    Descending,
206}
207
208impl SortOrder {
209    /// The wire name.
210    #[must_use]
211    pub fn as_str(self) -> &'static str {
212        match self {
213            SortOrder::Ascending => "ascending",
214            SortOrder::Descending => "descending",
215        }
216    }
217}
218
219/// One column of a table.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct Column {
222    name: String,
223    column_type: ColumnType,
224    required: bool,
225    sort_order: Option<SortOrder>,
226}
227
228impl Column {
229    /// A column that may be missing or `#`.
230    #[must_use]
231    pub fn new(name: impl Into<String>, column_type: ColumnType) -> Self {
232        Self {
233            name: name.into(),
234            column_type,
235            required: false,
236            sort_order: None,
237        }
238    }
239
240    /// Marks the column as one every row must have.
241    ///
242    /// A required column is what `i64` means and an optional one is what
243    /// `Option<i64>` means: the cluster rejects a row that leaves a required
244    /// column out.
245    #[must_use]
246    pub fn required(mut self) -> Self {
247        self.required = true;
248        self
249    }
250
251    /// Makes this an ascending key column.
252    ///
253    /// Key columns must be the *first* columns of the schema, in order: the
254    /// cluster refuses a schema whose keys are not a prefix with
255    /// `Key columns must form a prefix of schema`.
256    #[must_use]
257    pub fn key(self) -> Self {
258        self.sorted(SortOrder::Ascending)
259    }
260
261    /// Makes this a key column, sorted the given way.
262    ///
263    /// See [`SortOrder::Descending`] before reaching for anything but
264    /// ascending.
265    #[must_use]
266    pub fn sorted(mut self, order: SortOrder) -> Self {
267        self.sort_order = Some(order);
268        self
269    }
270
271    /// The column's name.
272    #[must_use]
273    pub fn name(&self) -> &str {
274        &self.name
275    }
276
277    /// The column's type.
278    #[must_use]
279    pub fn column_type(&self) -> ColumnType {
280        self.column_type
281    }
282
283    /// Whether every row must carry it.
284    #[must_use]
285    pub fn is_required(&self) -> bool {
286        self.required
287    }
288
289    /// Its sort order, if it is a key column.
290    #[must_use]
291    pub fn sort_order(&self) -> Option<SortOrder> {
292        self.sort_order
293    }
294
295    fn to_yson(&self) -> YsonValue {
296        let mut column = map([
297            ("name", string(&self.name)),
298            ("type", string(self.column_type.as_str())),
299            ("required", boolean(self.required)),
300        ]);
301        if let Some(order) = self.sort_order {
302            crate::yson_build::insert(&mut column, "sort_order", string(order.as_str()));
303        }
304        column
305    }
306}
307
308/// What a table promises about its rows.
309#[derive(Debug, Clone, PartialEq, Eq)]
310pub struct TableSchema {
311    columns: Vec<Column>,
312    strict: bool,
313    unique_keys: bool,
314}
315
316impl TableSchema {
317    /// A strict schema: the listed columns and nothing else.
318    ///
319    /// Strict is the default because it is the one that catches mistakes — a
320    /// non-strict table quietly accepts a misspelled column name and stores it
321    /// as an unschematised extra.
322    #[must_use]
323    pub fn new(columns: impl IntoIterator<Item = Column>) -> Self {
324        Self {
325            columns: columns.into_iter().collect(),
326            strict: true,
327            unique_keys: false,
328        }
329    }
330
331    /// Allows rows to carry columns the schema does not mention.
332    #[must_use]
333    pub fn non_strict(mut self) -> Self {
334        self.strict = false;
335        self
336    }
337
338    /// Promises that no two rows share a key.
339    ///
340    /// Only meaningful when the schema has key columns; the cluster enforces it
341    /// on write.
342    #[must_use]
343    pub fn with_unique_keys(mut self, unique: bool) -> Self {
344        self.unique_keys = unique;
345        self
346    }
347
348    /// The columns, in order.
349    #[must_use]
350    pub fn columns(&self) -> &[Column] {
351        &self.columns
352    }
353
354    /// Checks what the cluster would otherwise reject with error 314.
355    ///
356    /// Every rule here was watched being enforced by a cluster; catching them
357    /// locally turns a round trip and a nested error document into one
358    /// sentence naming the column.
359    ///
360    /// # Errors
361    ///
362    /// Returns the reason the schema is invalid.
363    pub fn validate(&self) -> std::result::Result<(), String> {
364        /// The cluster's own ceiling.
365        const MAX_COLUMNS: usize = 32_000;
366        /// Longest column name a cluster accepts.
367        const MAX_NAME: usize = 256;
368
369        if self.columns.len() > MAX_COLUMNS {
370            return Err(format!(
371                "a table may have at most {MAX_COLUMNS} columns; this schema has {}",
372                self.columns.len()
373            ));
374        }
375
376        let mut seen = std::collections::BTreeSet::new();
377        for column in &self.columns {
378            let name = column.name();
379
380            if name.is_empty() {
381                return Err("a column name cannot be empty".to_owned());
382            }
383            if name.len() > MAX_NAME {
384                return Err(format!(
385                    "column {name:?} is {} bytes long; the limit is {MAX_NAME}",
386                    name.len()
387                ));
388            }
389            if name.starts_with('@') {
390                return Err(format!(
391                    "column {name:?} starts with '@', which YTsaurus reserves for attributes"
392                ));
393            }
394            if !seen.insert(name) {
395                return Err(format!("column {name:?} appears twice"));
396            }
397
398            if column.is_required() && !column.column_type().can_be_required() {
399                return Err(format!(
400                    "column {name:?} is of type {}, which cannot be required",
401                    column.column_type().as_str()
402                ));
403            }
404        }
405
406        // Key columns must be a prefix: the first non-key column ends the key,
407        // and nothing after it may be sorted.
408        let keys = self
409            .columns
410            .iter()
411            .take_while(|c| c.sort_order().is_some())
412            .count();
413        if let Some(stray) = self.columns[keys..]
414            .iter()
415            .find(|c| c.sort_order().is_some())
416        {
417            return Err(format!(
418                "key columns must be the first columns of the schema, and {:?} is not; \
419                 move it before {:?}",
420                stray.name(),
421                self.columns[keys].name()
422            ));
423        }
424
425        if self.unique_keys && keys == 0 {
426            return Err(
427                "unique_keys promises no two rows share a key, but this schema has no key columns"
428                    .to_owned(),
429            );
430        }
431
432        Ok(())
433    }
434
435    /// Renders the schema as the cluster expects it.
436    #[must_use]
437    pub fn to_yson(&self) -> YsonValue {
438        with_attributes(
439            list(self.columns.iter().map(Column::to_yson)),
440            [
441                ("strict", boolean(self.strict)),
442                ("unique_keys", boolean(self.unique_keys)),
443            ],
444        )
445    }
446}
447
448/// A Rust type that describes a table's rows.
449///
450/// Implement it by hand, or derive it — the derive reads the struct's fields
451/// and their types, which is the same information a schema carries:
452///
453/// ```ignore
454/// use ytsaurus_client::TableRow;
455///
456/// #[derive(TableRow)]
457/// struct Visit<'a> {
458///     #[yt(key)]
459///     host: &'a str,
460///     size: i64,
461///     referrer: Option<&'a str>,   // optional, because the Rust type says so
462/// }
463///
464/// client.create_table("//tmp/visits", &Visit::table_schema())?;
465/// ```
466pub trait TableRow {
467    /// The schema of a table holding these rows.
468    fn table_schema() -> TableSchema;
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use ytsaurus_yson::{YsonFormat, to_string};
475
476    fn render(schema: &TableSchema) -> String {
477        to_string(&schema.to_yson(), YsonFormat::Text).expect("encodes")
478    }
479
480    #[test]
481    fn a_schema_renders_as_an_attributed_list_of_columns() {
482        let schema = TableSchema::new([
483            Column::new("key", ColumnType::String).required(),
484            Column::new("count", ColumnType::Int64),
485        ]);
486
487        assert_eq!(
488            render(&schema),
489            r#"<strict=%true;unique_keys=%false>[{name=key;required=%true;type=string};{name=count;required=%false;type=int64}]"#
490        );
491    }
492
493    #[test]
494    fn a_key_column_carries_its_sort_order() {
495        let schema = TableSchema::new([Column::new("k", ColumnType::String)
496            .required()
497            .sorted(SortOrder::Ascending)])
498        .with_unique_keys(true);
499
500        let out = render(&schema);
501        assert!(out.contains("sort_order=ascending"), "{out}");
502        assert!(out.contains("unique_keys=%true"), "{out}");
503    }
504
505    #[test]
506    fn strictness_is_on_unless_turned_off() {
507        assert!(render(&TableSchema::new([])).contains("strict=%true"));
508        assert!(
509            render(&TableSchema::new([]).non_strict()).contains("strict=%false"),
510            "a non-strict table accepts columns the schema never mentioned"
511        );
512    }
513
514    #[test]
515    fn every_type_has_a_wire_name_and_parses_back() {
516        for ty in [
517            ColumnType::Date,
518            ColumnType::Datetime,
519            ColumnType::Timestamp,
520            ColumnType::Interval,
521            ColumnType::Date32,
522            ColumnType::Datetime64,
523            ColumnType::Timestamp64,
524            ColumnType::Interval64,
525            ColumnType::Json,
526            ColumnType::Uuid,
527            ColumnType::Void,
528            ColumnType::Null,
529            ColumnType::Int8,
530            ColumnType::Int16,
531            ColumnType::Int32,
532            ColumnType::Int64,
533            ColumnType::Uint8,
534            ColumnType::Uint16,
535            ColumnType::Uint32,
536            ColumnType::Uint64,
537            ColumnType::Float,
538            ColumnType::Double,
539            ColumnType::Boolean,
540            ColumnType::String,
541            ColumnType::Utf8,
542            ColumnType::Any,
543        ] {
544            assert_eq!(ColumnType::parse(ty.as_str()), Some(ty), "{ty:?}");
545        }
546
547        assert_eq!(ColumnType::parse("bool"), Some(ColumnType::Boolean));
548        assert_eq!(ColumnType::parse("int128"), None);
549    }
550}