use ytsaurus_yson::YsonValue;
use crate::yson_build::{boolean, list, map, string, with_attributes};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColumnType {
Int8,
Int16,
Int32,
Int64,
Uint8,
Uint16,
Uint32,
Uint64,
Float,
Double,
Boolean,
String,
Utf8,
Any,
Date,
Datetime,
Timestamp,
Interval,
Date32,
Datetime64,
Timestamp64,
Interval64,
Json,
Uuid,
Void,
Null,
}
impl ColumnType {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
ColumnType::Int8 => "int8",
ColumnType::Int16 => "int16",
ColumnType::Int32 => "int32",
ColumnType::Int64 => "int64",
ColumnType::Uint8 => "uint8",
ColumnType::Uint16 => "uint16",
ColumnType::Uint32 => "uint32",
ColumnType::Uint64 => "uint64",
ColumnType::Float => "float",
ColumnType::Double => "double",
ColumnType::Boolean => "boolean",
ColumnType::String => "string",
ColumnType::Utf8 => "utf8",
ColumnType::Any => "any",
ColumnType::Date => "date",
ColumnType::Datetime => "datetime",
ColumnType::Timestamp => "timestamp",
ColumnType::Interval => "interval",
ColumnType::Date32 => "date32",
ColumnType::Datetime64 => "datetime64",
ColumnType::Timestamp64 => "timestamp64",
ColumnType::Interval64 => "interval64",
ColumnType::Json => "json",
ColumnType::Uuid => "uuid",
ColumnType::Void => "void",
ColumnType::Null => "null",
}
}
#[must_use]
pub fn can_be_required(self) -> bool {
!matches!(self, ColumnType::Any | ColumnType::Null | ColumnType::Void)
}
#[must_use]
pub fn parse(name: &str) -> Option<Self> {
Some(match name {
"int8" => ColumnType::Int8,
"int16" => ColumnType::Int16,
"int32" => ColumnType::Int32,
"int64" => ColumnType::Int64,
"uint8" => ColumnType::Uint8,
"uint16" => ColumnType::Uint16,
"uint32" => ColumnType::Uint32,
"uint64" => ColumnType::Uint64,
"float" => ColumnType::Float,
"double" => ColumnType::Double,
"boolean" | "bool" => ColumnType::Boolean,
"string" => ColumnType::String,
"utf8" => ColumnType::Utf8,
"any" | "yson" => ColumnType::Any,
"date" => ColumnType::Date,
"datetime" => ColumnType::Datetime,
"timestamp" => ColumnType::Timestamp,
"interval" => ColumnType::Interval,
"date32" => ColumnType::Date32,
"datetime64" => ColumnType::Datetime64,
"timestamp64" => ColumnType::Timestamp64,
"interval64" => ColumnType::Interval64,
"json" => ColumnType::Json,
"uuid" => ColumnType::Uuid,
"void" => ColumnType::Void,
"null" => ColumnType::Null,
_ => return None,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortOrder {
Ascending,
Descending,
}
impl SortOrder {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
SortOrder::Ascending => "ascending",
SortOrder::Descending => "descending",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Column {
name: String,
column_type: ColumnType,
required: bool,
sort_order: Option<SortOrder>,
}
impl Column {
#[must_use]
pub fn new(name: impl Into<String>, column_type: ColumnType) -> Self {
Self {
name: name.into(),
column_type,
required: false,
sort_order: None,
}
}
#[must_use]
pub fn required(mut self) -> Self {
self.required = true;
self
}
#[must_use]
pub fn key(self) -> Self {
self.sorted(SortOrder::Ascending)
}
#[must_use]
pub fn sorted(mut self, order: SortOrder) -> Self {
self.sort_order = Some(order);
self
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn column_type(&self) -> ColumnType {
self.column_type
}
#[must_use]
pub fn is_required(&self) -> bool {
self.required
}
#[must_use]
pub fn sort_order(&self) -> Option<SortOrder> {
self.sort_order
}
fn to_yson(&self) -> YsonValue {
let mut column = map([
("name", string(&self.name)),
("type", string(self.column_type.as_str())),
("required", boolean(self.required)),
]);
if let Some(order) = self.sort_order {
crate::yson_build::insert(&mut column, "sort_order", string(order.as_str()));
}
column
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableSchema {
columns: Vec<Column>,
strict: bool,
unique_keys: bool,
}
impl TableSchema {
#[must_use]
pub fn new(columns: impl IntoIterator<Item = Column>) -> Self {
Self {
columns: columns.into_iter().collect(),
strict: true,
unique_keys: false,
}
}
#[must_use]
pub fn non_strict(mut self) -> Self {
self.strict = false;
self
}
#[must_use]
pub fn with_unique_keys(mut self, unique: bool) -> Self {
self.unique_keys = unique;
self
}
#[must_use]
pub fn columns(&self) -> &[Column] {
&self.columns
}
pub fn validate(&self) -> std::result::Result<(), String> {
const MAX_COLUMNS: usize = 32_000;
const MAX_NAME: usize = 256;
if self.columns.len() > MAX_COLUMNS {
return Err(format!(
"a table may have at most {MAX_COLUMNS} columns; this schema has {}",
self.columns.len()
));
}
let mut seen = std::collections::BTreeSet::new();
for column in &self.columns {
let name = column.name();
if name.is_empty() {
return Err("a column name cannot be empty".to_owned());
}
if name.len() > MAX_NAME {
return Err(format!(
"column {name:?} is {} bytes long; the limit is {MAX_NAME}",
name.len()
));
}
if name.starts_with('@') {
return Err(format!(
"column {name:?} starts with '@', which YTsaurus reserves for attributes"
));
}
if !seen.insert(name) {
return Err(format!("column {name:?} appears twice"));
}
if column.is_required() && !column.column_type().can_be_required() {
return Err(format!(
"column {name:?} is of type {}, which cannot be required",
column.column_type().as_str()
));
}
}
let keys = self
.columns
.iter()
.take_while(|c| c.sort_order().is_some())
.count();
if let Some(stray) = self.columns[keys..]
.iter()
.find(|c| c.sort_order().is_some())
{
return Err(format!(
"key columns must be the first columns of the schema, and {:?} is not; \
move it before {:?}",
stray.name(),
self.columns[keys].name()
));
}
if self.unique_keys && keys == 0 {
return Err(
"unique_keys promises no two rows share a key, but this schema has no key columns"
.to_owned(),
);
}
Ok(())
}
#[must_use]
pub fn to_yson(&self) -> YsonValue {
with_attributes(
list(self.columns.iter().map(Column::to_yson)),
[
("strict", boolean(self.strict)),
("unique_keys", boolean(self.unique_keys)),
],
)
}
}
pub trait TableRow {
fn table_schema() -> TableSchema;
}
#[cfg(test)]
mod tests {
use super::*;
use ytsaurus_yson::{YsonFormat, to_string};
fn render(schema: &TableSchema) -> String {
to_string(&schema.to_yson(), YsonFormat::Text).expect("encodes")
}
#[test]
fn a_schema_renders_as_an_attributed_list_of_columns() {
let schema = TableSchema::new([
Column::new("key", ColumnType::String).required(),
Column::new("count", ColumnType::Int64),
]);
assert_eq!(
render(&schema),
r#"<strict=%true;unique_keys=%false>[{name=key;required=%true;type=string};{name=count;required=%false;type=int64}]"#
);
}
#[test]
fn a_key_column_carries_its_sort_order() {
let schema = TableSchema::new([Column::new("k", ColumnType::String)
.required()
.sorted(SortOrder::Ascending)])
.with_unique_keys(true);
let out = render(&schema);
assert!(out.contains("sort_order=ascending"), "{out}");
assert!(out.contains("unique_keys=%true"), "{out}");
}
#[test]
fn strictness_is_on_unless_turned_off() {
assert!(render(&TableSchema::new([])).contains("strict=%true"));
assert!(
render(&TableSchema::new([]).non_strict()).contains("strict=%false"),
"a non-strict table accepts columns the schema never mentioned"
);
}
#[test]
fn every_type_has_a_wire_name_and_parses_back() {
for ty in [
ColumnType::Date,
ColumnType::Datetime,
ColumnType::Timestamp,
ColumnType::Interval,
ColumnType::Date32,
ColumnType::Datetime64,
ColumnType::Timestamp64,
ColumnType::Interval64,
ColumnType::Json,
ColumnType::Uuid,
ColumnType::Void,
ColumnType::Null,
ColumnType::Int8,
ColumnType::Int16,
ColumnType::Int32,
ColumnType::Int64,
ColumnType::Uint8,
ColumnType::Uint16,
ColumnType::Uint32,
ColumnType::Uint64,
ColumnType::Float,
ColumnType::Double,
ColumnType::Boolean,
ColumnType::String,
ColumnType::Utf8,
ColumnType::Any,
] {
assert_eq!(ColumnType::parse(ty.as_str()), Some(ty), "{ty:?}");
}
assert_eq!(ColumnType::parse("bool"), Some(ColumnType::Boolean));
assert_eq!(ColumnType::parse("int128"), None);
}
}