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}