1use std::collections::HashMap;
8
9use arrow::datatypes::DataType;
10use snafu::prelude::*;
11
12use crate::{
13 coverage::{EntityIdentity, EntityValue},
14 metadata::{
15 index::{IndexKind, IndexSpec},
16 logical_schema::{LogicalDataType, LogicalField, LogicalSchema, LogicalToArrowSchemaError},
17 table::TableMeta,
18 },
19};
20
21#[derive(Debug, Snafu)]
23#[non_exhaustive]
24pub enum SchemaCompatibilityError {
25 #[snafu(display("Table has no logical_schema; v0.1 cannot append without a canonical schema"))]
31 MissingTableSchema,
32
33 #[snafu(display("Segment schema is missing required column {column}"))]
35 MissingColumn {
36 column: String,
38 },
39
40 #[snafu(display("Schema is missing registered index column {column}"))]
42 MissingIndexColumn {
43 column: String,
45 },
46
47 #[snafu(display("Schema is missing configured entity column {column}"))]
49 MissingEntityColumn {
50 column: String,
52 },
53
54 #[snafu(display(
56 "Entity column {column} has unsupported logical type {actual}; expected utf8, int32, int64, or uint64"
57 ))]
58 UnsupportedEntityColumnType {
59 column: String,
61 actual: LogicalDataType,
63 },
64
65 #[snafu(display(
67 "Entity identity has {actual} components, but the table configures {expected} entity columns"
68 ))]
69 EntityIdentityArityMismatch {
70 expected: usize,
72 actual: usize,
74 },
75
76 #[snafu(display(
78 "Entity identity component for column {column} has type {actual}; expected {expected}"
79 ))]
80 EntityIdentityTypeMismatch {
81 column: String,
83 expected: LogicalDataType,
85 actual: &'static str,
87 },
88
89 #[snafu(display("Segment schema has extra column {column} not present in table schema"))]
91 ExtraColumn {
92 column: String,
94 },
95
96 #[snafu(display("Incoming Arrow schema is missing registered column {column}"))]
98 MissingIncomingColumn {
99 column: String,
101 },
102
103 #[snafu(display("Incoming Arrow schema has unregistered column {column}"))]
105 ExtraIncomingColumn {
106 column: String,
108 },
109
110 #[snafu(display("Incoming Arrow schema has duplicate column {column}"))]
112 DuplicateIncomingColumn {
113 column: String,
115 },
116
117 #[snafu(display(
119 "Nullability mismatch for incoming column {column}: table has nullable={table_nullable}, incoming schema has nullable={incoming_nullable}"
120 ))]
121 IncomingNullabilityMismatch {
122 column: String,
124 table_nullable: bool,
126 incoming_nullable: bool,
128 },
129
130 #[snafu(display(
132 "Incompatible Arrow type for incoming column {column}: table has {table_type:?}, incoming schema has {incoming_type:?}"
133 ))]
134 IncomingTypeMismatch {
135 column: String,
137 table_type: DataType,
139 incoming_type: DataType,
141 },
142
143 #[snafu(display("Registered table schema cannot be converted to Arrow: {source}"))]
145 RegisteredSchemaConversion {
146 #[snafu(source(from(LogicalToArrowSchemaError, Box::new)), backtrace)]
148 source: Box<LogicalToArrowSchemaError>,
149 },
150
151 #[snafu(display(
153 "Type mismatch for column {column}: table has {table_type}, segment has {segment_type}"
154 ))]
155 TypeMismatch {
156 column: String,
158 table_type: LogicalDataType,
160 segment_type: LogicalDataType,
162 },
163
164 #[snafu(display(
166 "Index column {column} has incompatible type: table has {table_type}, \
167 segment has {segment_type}"
168 ))]
169 IndexColumnTypeMismatch {
170 column: String,
172 table_type: LogicalDataType,
174 segment_type: LogicalDataType,
176 },
177
178 #[snafu(display(
180 "Index column {column} has incompatible logical type: expected {expected}, found {actual}"
181 ))]
182 IndexKindMismatch {
183 column: String,
185 expected: &'static str,
187 actual: LogicalDataType,
189 },
190}
191
192pub type SchemaResult<T> = Result<T, SchemaCompatibilityError>;
194
195pub fn require_table_schema(meta: &TableMeta) -> SchemaResult<&LogicalSchema> {
197 match &meta.logical_schema {
198 Some(schema) => Ok(schema),
199 None => MissingTableSchemaSnafu.fail(),
200 }
201}
202
203fn columns_by_name(schema: &LogicalSchema) -> HashMap<&str, &LogicalField> {
204 schema
205 .columns()
206 .iter()
207 .map(|col| (col.name.as_str(), col))
208 .collect()
209}
210
211pub fn ensure_index_spec_matches_schema(
219 schema: &LogicalSchema,
220 index: &IndexSpec,
221) -> SchemaResult<()> {
222 let field = schema
223 .columns()
224 .iter()
225 .find(|field| field.name == index.column)
226 .ok_or_else(|| SchemaCompatibilityError::MissingIndexColumn {
227 column: index.column.clone(),
228 })?;
229
230 let matches = matches!(
231 (&index.kind, &field.data_type),
232 (
233 IndexKind::Timestamp { .. },
234 LogicalDataType::Timestamp { .. }
235 ) | (IndexKind::Int64 { .. }, LogicalDataType::Int64)
236 | (IndexKind::UInt64 { .. }, LogicalDataType::UInt64)
237 );
238
239 if !matches {
240 return Err(SchemaCompatibilityError::IndexKindMismatch {
241 column: index.column.clone(),
242 expected: index.kind.name(),
243 actual: field.data_type.clone(),
244 });
245 }
246
247 for column in &index.entity_columns {
248 let field = schema
249 .columns()
250 .iter()
251 .find(|field| field.name == *column)
252 .ok_or_else(|| SchemaCompatibilityError::MissingEntityColumn {
253 column: column.clone(),
254 })?;
255 if !matches!(
256 field.data_type,
257 LogicalDataType::Utf8
258 | LogicalDataType::Int32
259 | LogicalDataType::Int64
260 | LogicalDataType::UInt64
261 ) {
262 return Err(SchemaCompatibilityError::UnsupportedEntityColumnType {
263 column: column.clone(),
264 actual: field.data_type.clone(),
265 });
266 }
267 }
268
269 Ok(())
270}
271
272pub fn ensure_entity_identity_matches_schema(
278 schema: &LogicalSchema,
279 index: &IndexSpec,
280 identity: &EntityIdentity,
281) -> SchemaResult<()> {
282 if identity.components().len() != index.entity_columns.len() {
283 return Err(SchemaCompatibilityError::EntityIdentityArityMismatch {
284 expected: index.entity_columns.len(),
285 actual: identity.components().len(),
286 });
287 }
288
289 for (column, value) in index.entity_columns.iter().zip(identity.components()) {
290 let field = schema
291 .columns()
292 .iter()
293 .find(|field| field.name == *column)
294 .ok_or_else(|| SchemaCompatibilityError::MissingEntityColumn {
295 column: column.clone(),
296 })?;
297 let matches = matches!(
298 (&field.data_type, value),
299 (LogicalDataType::Utf8, EntityValue::Utf8(_))
300 | (LogicalDataType::Int32, EntityValue::Int32(_))
301 | (LogicalDataType::Int64, EntityValue::Int64(_))
302 | (LogicalDataType::UInt64, EntityValue::UInt64(_))
303 );
304 if !matches {
305 let actual = match value {
306 EntityValue::Utf8(_) => "utf8",
307 EntityValue::Int32(_) => "int32",
308 EntityValue::Int64(_) => "int64",
309 EntityValue::UInt64(_) => "uint64",
310 };
311 return Err(SchemaCompatibilityError::EntityIdentityTypeMismatch {
312 column: column.clone(),
313 expected: field.data_type.clone(),
314 actual,
315 });
316 }
317 }
318
319 Ok(())
320}
321
322pub fn ensure_schema_fields_match_by_name(
330 table_schema: &LogicalSchema,
331 segment_schema: &LogicalSchema,
332 index: &IndexSpec,
333) -> SchemaResult<()> {
334 let index_col_name = index.column.as_str();
335
336 let table_cols = columns_by_name(table_schema);
337 let seg_cols = columns_by_name(segment_schema);
338
339 for (name, table_field) in &table_cols {
340 let seg_field =
341 seg_cols
342 .get(name)
343 .ok_or_else(|| SchemaCompatibilityError::MissingColumn {
344 column: (*name).to_string(),
345 })?;
346
347 if table_field.data_type != seg_field.data_type
348 || table_field.nullable != seg_field.nullable
349 {
350 let err = if *name == index_col_name {
351 SchemaCompatibilityError::IndexColumnTypeMismatch {
352 column: (*name).to_string(),
353 table_type: table_field.data_type.clone(),
354 segment_type: seg_field.data_type.clone(),
355 }
356 } else {
357 SchemaCompatibilityError::TypeMismatch {
358 column: (*name).to_string(),
359 table_type: table_field.data_type.clone(),
360 segment_type: seg_field.data_type.clone(),
361 }
362 };
363 return Err(err);
364 }
365 }
366
367 for name in seg_cols.keys() {
368 if !table_cols.contains_key(name) {
369 return Err(SchemaCompatibilityError::ExtraColumn {
370 column: (*name).to_string(),
371 });
372 }
373 }
374
375 Ok(())
376}
377
378#[cfg(test)]
379mod tests {
380 use std::num::NonZeroU64;
381
382 use super::*;
383 use crate::metadata::{
384 index::TimeIndexGranularity,
385 logical_schema::{LogicalSchema, LogicalTimestampUnit},
386 };
387
388 fn schema(data_type: LogicalDataType) -> LogicalSchema {
389 LogicalSchema::new(vec![LogicalField {
390 name: "idx".to_string(),
391 data_type,
392 nullable: false,
393 }])
394 .unwrap()
395 }
396
397 fn index(kind: IndexKind) -> IndexSpec {
398 IndexSpec {
399 column: "idx".to_string(),
400 entity_columns: Vec::new(),
401 kind,
402 }
403 }
404
405 fn schema_with_entities(entity_types: Vec<LogicalDataType>) -> LogicalSchema {
406 let mut fields = vec![LogicalField {
407 name: "idx".to_string(),
408 data_type: LogicalDataType::Int64,
409 nullable: false,
410 }];
411 fields.extend(
412 entity_types
413 .into_iter()
414 .enumerate()
415 .map(|(position, data_type)| LogicalField {
416 name: format!("entity_{position}"),
417 data_type,
418 nullable: false,
419 }),
420 );
421 LogicalSchema::new(fields).unwrap()
422 }
423
424 fn entity_index(count: usize) -> IndexSpec {
425 IndexSpec {
426 column: "idx".to_string(),
427 entity_columns: (0..count)
428 .map(|position| format!("entity_{position}"))
429 .collect(),
430 kind: IndexKind::Int64 {
431 index_granularity: NonZeroU64::new(1).unwrap(),
432 },
433 }
434 }
435
436 #[test]
437 fn ordered_index_schema_validation_accepts_each_exact_domain() {
438 let cases = [
439 (
440 index(IndexKind::Timestamp {
441 index_granularity: TimeIndexGranularity::Seconds(1),
442 timezone: None,
443 }),
444 schema(LogicalDataType::Timestamp {
445 unit: LogicalTimestampUnit::Nanos,
446 timezone: Some("UTC".to_string()),
447 }),
448 ),
449 (
450 index(IndexKind::Int64 {
451 index_granularity: NonZeroU64::new(1).unwrap(),
452 }),
453 schema(LogicalDataType::Int64),
454 ),
455 (
456 index(IndexKind::UInt64 {
457 index_granularity: NonZeroU64::new(1).unwrap(),
458 }),
459 schema(LogicalDataType::UInt64),
460 ),
461 ];
462
463 for (index, schema) in cases {
464 ensure_index_spec_matches_schema(&schema, &index).unwrap();
465 }
466 }
467
468 #[test]
469 fn ordered_index_schema_validation_rejects_missing_and_wrong_domains() {
470 let unsigned = index(IndexKind::UInt64 {
471 index_granularity: NonZeroU64::new(1).unwrap(),
472 });
473 let missing = LogicalSchema::new(vec![LogicalField {
474 name: "other".to_string(),
475 data_type: LogicalDataType::UInt64,
476 nullable: false,
477 }])
478 .unwrap();
479
480 assert!(matches!(
481 ensure_index_spec_matches_schema(&missing, &unsigned),
482 Err(SchemaCompatibilityError::MissingIndexColumn { .. })
483 ));
484 assert!(matches!(
485 ensure_index_spec_matches_schema(&schema(LogicalDataType::Int64), &unsigned),
486 Err(SchemaCompatibilityError::IndexKindMismatch {
487 expected: "uint64",
488 actual: LogicalDataType::Int64,
489 ..
490 })
491 ));
492 }
493
494 #[test]
495 fn entity_schema_validation_accepts_only_supported_types() {
496 let supported = vec![
497 LogicalDataType::Utf8,
498 LogicalDataType::Int32,
499 LogicalDataType::Int64,
500 LogicalDataType::UInt64,
501 ];
502 ensure_index_spec_matches_schema(&schema_with_entities(supported), &entity_index(4))
503 .unwrap();
504
505 let missing = ensure_index_spec_matches_schema(
506 &schema_with_entities(vec![LogicalDataType::Utf8]),
507 &entity_index(2),
508 )
509 .unwrap_err();
510 assert!(matches!(
511 missing,
512 SchemaCompatibilityError::MissingEntityColumn { column }
513 if column == "entity_1"
514 ));
515
516 let unsupported = ensure_index_spec_matches_schema(
517 &schema_with_entities(vec![LogicalDataType::Bool]),
518 &entity_index(1),
519 )
520 .unwrap_err();
521 assert!(matches!(
522 unsupported,
523 SchemaCompatibilityError::UnsupportedEntityColumnType {
524 column,
525 actual: LogicalDataType::Bool,
526 } if column == "entity_0"
527 ));
528 }
529
530 #[test]
531 fn persisted_entity_identity_must_match_schema_types_and_arity() {
532 let schema = schema_with_entities(vec![
533 LogicalDataType::Utf8,
534 LogicalDataType::Int32,
535 LogicalDataType::Int64,
536 LogicalDataType::UInt64,
537 ]);
538 let index = entity_index(4);
539 let identity = EntityIdentity::try_new(vec![
540 EntityValue::from("device"),
541 EntityValue::Int32(-1),
542 EntityValue::Int64(i64::MIN),
543 EntityValue::UInt64(u64::MAX),
544 ])
545 .unwrap();
546 ensure_entity_identity_matches_schema(&schema, &index, &identity).unwrap();
547
548 let wrong_type = EntityIdentity::try_new(vec![
549 EntityValue::from("device"),
550 EntityValue::UInt64(1),
551 EntityValue::Int64(2),
552 EntityValue::UInt64(3),
553 ])
554 .unwrap();
555 assert!(matches!(
556 ensure_entity_identity_matches_schema(&schema, &index, &wrong_type),
557 Err(SchemaCompatibilityError::EntityIdentityTypeMismatch {
558 column,
559 expected: LogicalDataType::Int32,
560 actual: "uint64",
561 }) if column == "entity_1"
562 ));
563
564 let too_short = EntityIdentity::try_new(vec![EntityValue::from("device")]).unwrap();
565 assert!(matches!(
566 ensure_entity_identity_matches_schema(&schema, &index, &too_short),
567 Err(SchemaCompatibilityError::EntityIdentityArityMismatch {
568 expected: 4,
569 actual: 1,
570 })
571 ));
572 }
573}