1use serde::{Deserialize, Serialize};
6
7use super::column_def::ColumnDef;
8use crate::columnar::ColumnType;
9
10pub trait SchemaOps {
12 fn columns(&self) -> &[ColumnDef];
13
14 fn column_index(&self, name: &str) -> Option<usize> {
15 self.columns().iter().position(|c| c.name == name)
16 }
17
18 fn column(&self, name: &str) -> Option<&ColumnDef> {
19 self.columns().iter().find(|c| c.name == name)
20 }
21
22 fn primary_key_columns(&self) -> Vec<&ColumnDef> {
23 self.columns().iter().filter(|c| c.primary_key).collect()
24 }
25
26 fn len(&self) -> usize {
27 self.columns().len()
28 }
29
30 fn is_empty(&self) -> bool {
31 self.columns().is_empty()
32 }
33}
34
35#[derive(
37 Debug,
38 Clone,
39 PartialEq,
40 Eq,
41 Serialize,
42 Deserialize,
43 zerompk::ToMessagePack,
44 zerompk::FromMessagePack,
45)]
46#[msgpack(map)]
47pub struct StrictSchema {
48 pub columns: Vec<ColumnDef>,
49 pub version: u32,
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
54 pub dropped_columns: Vec<DroppedColumn>,
55 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
61 #[msgpack(default)]
62 pub bitemporal: bool,
63}
64
65#[derive(
67 Debug,
68 Clone,
69 PartialEq,
70 Eq,
71 Serialize,
72 Deserialize,
73 zerompk::ToMessagePack,
74 zerompk::FromMessagePack,
75)]
76pub struct DroppedColumn {
77 pub def: ColumnDef,
79 pub position: usize,
81 pub dropped_at_version: u32,
83}
84
85#[derive(
87 Debug,
88 Clone,
89 PartialEq,
90 Eq,
91 Serialize,
92 Deserialize,
93 zerompk::ToMessagePack,
94 zerompk::FromMessagePack,
95)]
96pub struct ColumnarSchema {
97 pub columns: Vec<ColumnDef>,
98 pub version: u32,
99}
100
101pub const BITEMPORAL_SYSTEM_FROM: &str = "__system_from_ms";
105pub const BITEMPORAL_VALID_FROM: &str = "__valid_from_ms";
106pub const BITEMPORAL_VALID_UNTIL: &str = "__valid_until_ms";
107
108pub const BITEMPORAL_RESERVED_COLUMNS: [&str; 3] = [
110 BITEMPORAL_SYSTEM_FROM,
111 BITEMPORAL_VALID_FROM,
112 BITEMPORAL_VALID_UNTIL,
113];
114
115pub const TS_SYSTEM: &str = "_ts_system";
123pub const TS_VALID_FROM: &str = "_ts_valid_from";
124pub const TS_VALID_UNTIL: &str = "_ts_valid_until";
125
126pub fn is_reserved_bitemporal_column(name: &str) -> bool {
136 BITEMPORAL_RESERVED_COLUMNS.contains(&name)
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
141#[non_exhaustive]
142pub enum SchemaError {
143 #[error("schema must have at least one column")]
144 Empty,
145 #[error("duplicate column name: '{0}'")]
146 DuplicateColumn(String),
147 #[error("VECTOR dimension must be positive, got 0 for column '{0}'")]
148 ZeroVectorDim(String),
149 #[error("primary key column '{0}' must be NOT NULL")]
150 NullablePrimaryKey(String),
151 #[error("column name '{0}' is reserved for bitemporal collections")]
152 ReservedColumnName(String),
153}
154
155fn validate_columns(columns: &[ColumnDef]) -> Result<(), SchemaError> {
156 if columns.is_empty() {
157 return Err(SchemaError::Empty);
158 }
159 let mut seen = std::collections::HashSet::with_capacity(columns.len());
160 for col in columns {
161 if !seen.insert(&col.name) {
162 return Err(SchemaError::DuplicateColumn(col.name.clone()));
163 }
164 if col.primary_key && col.nullable {
165 return Err(SchemaError::NullablePrimaryKey(col.name.clone()));
166 }
167 if let ColumnType::Vector(0) = col.column_type {
168 return Err(SchemaError::ZeroVectorDim(col.name.clone()));
169 }
170 }
171 Ok(())
172}
173
174impl SchemaOps for StrictSchema {
175 fn columns(&self) -> &[ColumnDef] {
176 &self.columns
177 }
178}
179
180impl SchemaOps for ColumnarSchema {
181 fn columns(&self) -> &[ColumnDef] {
182 &self.columns
183 }
184}
185
186impl StrictSchema {
187 pub fn new(columns: Vec<ColumnDef>) -> Result<Self, SchemaError> {
188 for col in &columns {
189 if BITEMPORAL_RESERVED_COLUMNS.contains(&col.name.as_str()) {
190 return Err(SchemaError::ReservedColumnName(col.name.clone()));
191 }
192 }
193 validate_columns(&columns)?;
194 Ok(Self {
195 columns,
196 version: 1,
197 dropped_columns: Vec::new(),
198 bitemporal: false,
199 })
200 }
201
202 pub fn new_bitemporal(user_columns: Vec<ColumnDef>) -> Result<Self, SchemaError> {
208 for col in &user_columns {
209 if BITEMPORAL_RESERVED_COLUMNS.contains(&col.name.as_str()) {
210 return Err(SchemaError::ReservedColumnName(col.name.clone()));
211 }
212 }
213 let mut columns = Vec::with_capacity(3 + user_columns.len());
214 columns.push(ColumnDef::required(
215 BITEMPORAL_SYSTEM_FROM,
216 ColumnType::Int64,
217 ));
218 columns.push(ColumnDef::required(
219 BITEMPORAL_VALID_FROM,
220 ColumnType::Int64,
221 ));
222 columns.push(ColumnDef::required(
223 BITEMPORAL_VALID_UNTIL,
224 ColumnType::Int64,
225 ));
226 columns.extend(user_columns);
227 validate_columns(&columns)?;
228 Ok(Self {
229 columns,
230 version: 1,
231 dropped_columns: Vec::new(),
232 bitemporal: true,
233 })
234 }
235
236 pub fn variable_column_count(&self) -> usize {
238 self.columns
239 .iter()
240 .filter(|c| c.column_type.is_variable_length())
241 .count()
242 }
243
244 pub fn fixed_fields_size(&self) -> usize {
246 self.columns
247 .iter()
248 .filter_map(|c| c.column_type.fixed_size())
249 .sum()
250 }
251
252 pub fn null_bitmap_size(&self) -> usize {
254 self.columns.len().div_ceil(8)
255 }
256
257 pub fn schema_for_version(&self, version: u32) -> StrictSchema {
262 let mut cols: Vec<ColumnDef> = self
264 .columns
265 .iter()
266 .filter(|c| c.added_at_version <= version)
267 .cloned()
268 .collect();
269
270 let mut to_reinsert: Vec<&DroppedColumn> = self
273 .dropped_columns
274 .iter()
275 .filter(|dc| dc.def.added_at_version <= version && dc.dropped_at_version > version)
276 .collect();
277 to_reinsert.sort_by_key(|dc| dc.position);
278 for dc in to_reinsert {
279 let pos = dc.position.min(cols.len());
280 cols.insert(pos, dc.def.clone());
281 }
282
283 StrictSchema {
284 version,
285 columns: cols,
286 dropped_columns: Vec::new(),
287 bitemporal: self.bitemporal,
288 }
289 }
290
291 pub fn parse_default_literal(expr: &str) -> crate::value::Value {
297 use crate::value::Value;
298
299 let trimmed = expr.trim();
300
301 if trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2 {
303 return Value::String(trimmed[1..trimmed.len() - 1].replace("''", "'"));
304 }
305
306 match trimmed.to_uppercase().as_str() {
308 "TRUE" => return Value::Bool(true),
309 "FALSE" => return Value::Bool(false),
310 "NULL" => return Value::Null,
311 _ => {}
312 }
313
314 if let Ok(i) = trimmed.parse::<i64>() {
316 return Value::Integer(i);
317 }
318
319 if let Ok(f) = trimmed.parse::<f64>() {
321 return Value::Float(f);
322 }
323
324 Value::Null
325 }
326}
327
328impl ColumnarSchema {
329 pub fn new(columns: Vec<ColumnDef>) -> Result<Self, SchemaError> {
330 validate_columns(&columns)?;
331 Ok(Self {
332 columns,
333 version: 1,
334 })
335 }
336
337 pub fn is_bitemporal(&self) -> bool {
344 self.columns.iter().any(|c| c.name == "_ts_system")
345 }
346
347 pub fn ts_system_idx(&self) -> Option<usize> {
349 self.columns.iter().position(|c| c.name == "_ts_system")
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356 use crate::columnar::ColumnType;
357
358 #[test]
359 fn strict_schema_validation() {
360 let schema = StrictSchema::new(vec![
361 ColumnDef::required("id", ColumnType::Int64).with_primary_key(),
362 ColumnDef::nullable("name", ColumnType::String),
363 ]);
364 assert!(schema.is_ok());
365 assert!(StrictSchema::new(vec![]).is_err());
366 }
367
368 #[test]
369 fn schema_ops_trait() {
370 let schema = StrictSchema::new(vec![
371 ColumnDef::required("id", ColumnType::Int64).with_primary_key(),
372 ColumnDef::nullable("name", ColumnType::String),
373 ColumnDef::nullable(
374 "balance",
375 ColumnType::Decimal {
376 precision: 18,
377 scale: 4,
378 },
379 ),
380 ])
381 .unwrap();
382 assert_eq!(schema.len(), 3);
383 assert_eq!(schema.column_index("balance"), Some(2));
384 assert!(schema.column("nonexistent").is_none());
385 assert_eq!(schema.primary_key_columns().len(), 1);
386 }
387
388 #[test]
389 fn strict_layout_helpers() {
390 let schema = StrictSchema::new(vec![
391 ColumnDef::required("id", ColumnType::Int64).with_primary_key(),
392 ColumnDef::nullable("name", ColumnType::String),
393 ColumnDef::nullable(
394 "balance",
395 ColumnType::Decimal {
396 precision: 18,
397 scale: 4,
398 },
399 ),
400 ColumnDef::nullable("bio", ColumnType::String),
401 ])
402 .unwrap();
403 assert_eq!(schema.null_bitmap_size(), 1);
404 assert_eq!(schema.fixed_fields_size(), 8 + 16);
405 assert_eq!(schema.variable_column_count(), 2);
406 }
407
408 #[test]
409 fn columnar_schema_validation() {
410 let schema = ColumnarSchema::new(vec![
411 ColumnDef::required("time", ColumnType::Timestamp),
412 ColumnDef::nullable("cpu", ColumnType::Float64),
413 ]);
414 assert!(schema.is_ok());
415 assert_eq!(schema.unwrap().len(), 2);
416 }
417
418 #[test]
419 fn nullable_pk_rejected() {
420 let cols = vec![ColumnDef {
421 name: "id".into(),
422 column_type: ColumnType::Int64,
423 nullable: true,
424 default: None,
425 primary_key: true,
426 modifiers: Vec::new(),
427 generated_expr: None,
428 generated_deps: Vec::new(),
429 added_at_version: 1,
430 }];
431 assert!(matches!(
432 StrictSchema::new(cols),
433 Err(SchemaError::NullablePrimaryKey(_))
434 ));
435 }
436
437 #[test]
438 fn zero_vector_dim_rejected() {
439 let cols = vec![ColumnDef::required("emb", ColumnType::Vector(0))];
440 assert!(matches!(
441 StrictSchema::new(cols),
442 Err(SchemaError::ZeroVectorDim(_))
443 ));
444 }
445}