1use std::collections::HashMap;
8
9use snafu::prelude::*;
10
11use crate::{
12 coverage::{EntityIdentity, EntityValue},
13 metadata::{
14 logical_schema::{LogicalDataType, LogicalField, LogicalSchema, LogicalSchemaError},
15 table_metadata::{IndexKind, IndexSpec, TableMeta},
16 },
17};
18
19#[derive(Debug, Snafu)]
21pub enum SchemaCompatibilityError {
22 #[snafu(display("Table has no logical_schema; v0.1 cannot append without a canonical schema"))]
28 MissingTableSchema,
29
30 #[snafu(display("Segment schema is missing required column {column}"))]
32 MissingColumn {
33 column: String,
35 },
36
37 #[snafu(display("Schema is missing registered index column {column}"))]
39 MissingIndexColumn {
40 column: String,
42 },
43
44 #[snafu(display("Schema is missing configured entity column {column}"))]
46 MissingEntityColumn {
47 column: String,
49 },
50
51 #[snafu(display(
53 "Entity column {column} has unsupported logical type {actual}; expected utf8, int32, int64, or uint64"
54 ))]
55 UnsupportedEntityColumnType {
56 column: String,
58 actual: LogicalDataType,
60 },
61
62 #[snafu(display(
64 "Entity identity has {actual} components, but the table configures {expected} entity columns"
65 ))]
66 EntityIdentityArityMismatch {
67 expected: usize,
69 actual: usize,
71 },
72
73 #[snafu(display(
75 "Entity identity component for column {column} has type {actual}; expected {expected}"
76 ))]
77 EntityIdentityTypeMismatch {
78 column: String,
80 expected: LogicalDataType,
82 actual: &'static str,
84 },
85
86 #[snafu(display("Segment schema has extra column {column} not present in table schema"))]
88 ExtraColumn {
89 column: String,
91 },
92
93 #[snafu(display(
95 "Type mismatch for column {column}: table has {table_type}, segment has {segment_type}"
96 ))]
97 TypeMismatch {
98 column: String,
100 table_type: LogicalDataType,
102 segment_type: LogicalDataType,
104 },
105
106 #[snafu(display(
108 "Index column {column} has incompatible type: table has {table_type}, \
109 segment has {segment_type}"
110 ))]
111 IndexColumnTypeMismatch {
112 column: String,
114 table_type: LogicalDataType,
116 segment_type: LogicalDataType,
118 },
119
120 #[snafu(display(
122 "Index column {column} has incompatible logical type: expected {expected}, found {actual}"
123 ))]
124 IndexKindMismatch {
125 column: String,
127 expected: &'static str,
129 actual: LogicalDataType,
131 },
132
133 #[snafu(display("Logical schema is invalid: {source}"))]
135 LogicalSchema {
136 #[snafu(source)]
138 source: LogicalSchemaError,
139 },
140}
141
142pub type SchemaResult<T> = Result<T, SchemaCompatibilityError>;
144
145pub fn require_table_schema(meta: &TableMeta) -> SchemaResult<&LogicalSchema> {
147 match &meta.logical_schema {
148 Some(schema) => Ok(schema),
149 None => MissingTableSchemaSnafu.fail(),
150 }
151}
152
153fn columns_by_name(schema: &LogicalSchema) -> HashMap<&str, &LogicalField> {
154 schema
155 .columns()
156 .iter()
157 .map(|col| (col.name.as_str(), col))
158 .collect()
159}
160
161pub fn ensure_index_spec_matches_schema(
169 schema: &LogicalSchema,
170 index: &IndexSpec,
171) -> SchemaResult<()> {
172 let field = schema
173 .columns()
174 .iter()
175 .find(|field| field.name == index.column)
176 .ok_or_else(|| SchemaCompatibilityError::MissingIndexColumn {
177 column: index.column.clone(),
178 })?;
179
180 let matches = matches!(
181 (&index.kind, &field.data_type),
182 (
183 IndexKind::Timestamp { .. },
184 LogicalDataType::Timestamp { .. }
185 ) | (IndexKind::Int64 { .. }, LogicalDataType::Int64)
186 | (IndexKind::UInt64 { .. }, LogicalDataType::UInt64)
187 );
188
189 if !matches {
190 return Err(SchemaCompatibilityError::IndexKindMismatch {
191 column: index.column.clone(),
192 expected: index.kind.name(),
193 actual: field.data_type.clone(),
194 });
195 }
196
197 for column in &index.entity_columns {
198 let field = schema
199 .columns()
200 .iter()
201 .find(|field| field.name == *column)
202 .ok_or_else(|| SchemaCompatibilityError::MissingEntityColumn {
203 column: column.clone(),
204 })?;
205 if !matches!(
206 field.data_type,
207 LogicalDataType::Utf8
208 | LogicalDataType::Int32
209 | LogicalDataType::Int64
210 | LogicalDataType::UInt64
211 ) {
212 return Err(SchemaCompatibilityError::UnsupportedEntityColumnType {
213 column: column.clone(),
214 actual: field.data_type.clone(),
215 });
216 }
217 }
218
219 Ok(())
220}
221
222pub fn ensure_entity_identity_matches_schema(
228 schema: &LogicalSchema,
229 index: &IndexSpec,
230 identity: &EntityIdentity,
231) -> SchemaResult<()> {
232 if identity.components().len() != index.entity_columns.len() {
233 return Err(SchemaCompatibilityError::EntityIdentityArityMismatch {
234 expected: index.entity_columns.len(),
235 actual: identity.components().len(),
236 });
237 }
238
239 for (column, value) in index.entity_columns.iter().zip(identity.components()) {
240 let field = schema
241 .columns()
242 .iter()
243 .find(|field| field.name == *column)
244 .ok_or_else(|| SchemaCompatibilityError::MissingEntityColumn {
245 column: column.clone(),
246 })?;
247 let matches = matches!(
248 (&field.data_type, value),
249 (LogicalDataType::Utf8, EntityValue::Utf8(_))
250 | (LogicalDataType::Int32, EntityValue::Int32(_))
251 | (LogicalDataType::Int64, EntityValue::Int64(_))
252 | (LogicalDataType::UInt64, EntityValue::UInt64(_))
253 );
254 if !matches {
255 let actual = match value {
256 EntityValue::Utf8(_) => "utf8",
257 EntityValue::Int32(_) => "int32",
258 EntityValue::Int64(_) => "int64",
259 EntityValue::UInt64(_) => "uint64",
260 };
261 return Err(SchemaCompatibilityError::EntityIdentityTypeMismatch {
262 column: column.clone(),
263 expected: field.data_type.clone(),
264 actual,
265 });
266 }
267 }
268
269 Ok(())
270}
271
272pub fn ensure_schema_exact_match(
279 table_schema: &LogicalSchema,
280 segment_schema: &LogicalSchema,
281 index: &IndexSpec,
282) -> SchemaResult<()> {
283 let index_col_name = index.column.as_str();
284
285 let table_cols = columns_by_name(table_schema);
286 let seg_cols = columns_by_name(segment_schema);
287
288 for (name, table_field) in &table_cols {
289 let seg_field =
290 seg_cols
291 .get(name)
292 .ok_or_else(|| SchemaCompatibilityError::MissingColumn {
293 column: (*name).to_string(),
294 })?;
295
296 if table_field.data_type != seg_field.data_type
297 || table_field.nullable != seg_field.nullable
298 {
299 let err = if *name == index_col_name {
300 SchemaCompatibilityError::IndexColumnTypeMismatch {
301 column: (*name).to_string(),
302 table_type: table_field.data_type.clone(),
303 segment_type: seg_field.data_type.clone(),
304 }
305 } else {
306 SchemaCompatibilityError::TypeMismatch {
307 column: (*name).to_string(),
308 table_type: table_field.data_type.clone(),
309 segment_type: seg_field.data_type.clone(),
310 }
311 };
312 return Err(err);
313 }
314 }
315
316 for name in seg_cols.keys() {
317 if !table_cols.contains_key(name) {
318 return Err(SchemaCompatibilityError::ExtraColumn {
319 column: (*name).to_string(),
320 });
321 }
322 }
323
324 Ok(())
325}
326
327#[cfg(test)]
328mod tests {
329 use std::num::NonZeroU64;
330
331 use super::*;
332 use crate::metadata::{
333 logical_schema::{LogicalSchema, LogicalTimestampUnit},
334 table_metadata::TimeBucket,
335 };
336
337 fn schema(data_type: LogicalDataType) -> LogicalSchema {
338 LogicalSchema::new(vec![LogicalField {
339 name: "idx".to_string(),
340 data_type,
341 nullable: false,
342 }])
343 .unwrap()
344 }
345
346 fn index(kind: IndexKind) -> IndexSpec {
347 IndexSpec {
348 column: "idx".to_string(),
349 entity_columns: Vec::new(),
350 kind,
351 }
352 }
353
354 fn schema_with_entities(entity_types: Vec<LogicalDataType>) -> LogicalSchema {
355 let mut fields = vec![LogicalField {
356 name: "idx".to_string(),
357 data_type: LogicalDataType::Int64,
358 nullable: false,
359 }];
360 fields.extend(
361 entity_types
362 .into_iter()
363 .enumerate()
364 .map(|(position, data_type)| LogicalField {
365 name: format!("entity_{position}"),
366 data_type,
367 nullable: false,
368 }),
369 );
370 LogicalSchema::new(fields).unwrap()
371 }
372
373 fn entity_index(count: usize) -> IndexSpec {
374 IndexSpec {
375 column: "idx".to_string(),
376 entity_columns: (0..count)
377 .map(|position| format!("entity_{position}"))
378 .collect(),
379 kind: IndexKind::Int64 {
380 bucket_width: NonZeroU64::new(1).unwrap(),
381 },
382 }
383 }
384
385 #[test]
386 fn ordered_index_schema_validation_accepts_each_exact_domain() {
387 let cases = [
388 (
389 index(IndexKind::Timestamp {
390 bucket: TimeBucket::Seconds(1),
391 timezone: None,
392 }),
393 schema(LogicalDataType::Timestamp {
394 unit: LogicalTimestampUnit::Nanos,
395 timezone: Some("UTC".to_string()),
396 }),
397 ),
398 (
399 index(IndexKind::Int64 {
400 bucket_width: NonZeroU64::new(1).unwrap(),
401 }),
402 schema(LogicalDataType::Int64),
403 ),
404 (
405 index(IndexKind::UInt64 {
406 bucket_width: NonZeroU64::new(1).unwrap(),
407 }),
408 schema(LogicalDataType::UInt64),
409 ),
410 ];
411
412 for (index, schema) in cases {
413 ensure_index_spec_matches_schema(&schema, &index).unwrap();
414 }
415 }
416
417 #[test]
418 fn ordered_index_schema_validation_rejects_missing_and_wrong_domains() {
419 let unsigned = index(IndexKind::UInt64 {
420 bucket_width: NonZeroU64::new(1).unwrap(),
421 });
422 let missing = LogicalSchema::new(vec![LogicalField {
423 name: "other".to_string(),
424 data_type: LogicalDataType::UInt64,
425 nullable: false,
426 }])
427 .unwrap();
428
429 assert!(matches!(
430 ensure_index_spec_matches_schema(&missing, &unsigned),
431 Err(SchemaCompatibilityError::MissingIndexColumn { .. })
432 ));
433 assert!(matches!(
434 ensure_index_spec_matches_schema(&schema(LogicalDataType::Int64), &unsigned),
435 Err(SchemaCompatibilityError::IndexKindMismatch {
436 expected: "uint64",
437 actual: LogicalDataType::Int64,
438 ..
439 })
440 ));
441 }
442
443 #[test]
444 fn entity_schema_validation_accepts_only_supported_types() {
445 let supported = vec![
446 LogicalDataType::Utf8,
447 LogicalDataType::Int32,
448 LogicalDataType::Int64,
449 LogicalDataType::UInt64,
450 ];
451 ensure_index_spec_matches_schema(&schema_with_entities(supported), &entity_index(4))
452 .unwrap();
453
454 let missing = ensure_index_spec_matches_schema(
455 &schema_with_entities(vec![LogicalDataType::Utf8]),
456 &entity_index(2),
457 )
458 .unwrap_err();
459 assert!(matches!(
460 missing,
461 SchemaCompatibilityError::MissingEntityColumn { column }
462 if column == "entity_1"
463 ));
464
465 let unsupported = ensure_index_spec_matches_schema(
466 &schema_with_entities(vec![LogicalDataType::Bool]),
467 &entity_index(1),
468 )
469 .unwrap_err();
470 assert!(matches!(
471 unsupported,
472 SchemaCompatibilityError::UnsupportedEntityColumnType {
473 column,
474 actual: LogicalDataType::Bool,
475 } if column == "entity_0"
476 ));
477 }
478
479 #[test]
480 fn persisted_entity_identity_must_match_schema_types_and_arity() {
481 let schema = schema_with_entities(vec![
482 LogicalDataType::Utf8,
483 LogicalDataType::Int32,
484 LogicalDataType::Int64,
485 LogicalDataType::UInt64,
486 ]);
487 let index = entity_index(4);
488 let identity = EntityIdentity::try_new(vec![
489 EntityValue::from("device"),
490 EntityValue::Int32(-1),
491 EntityValue::Int64(i64::MIN),
492 EntityValue::UInt64(u64::MAX),
493 ])
494 .unwrap();
495 ensure_entity_identity_matches_schema(&schema, &index, &identity).unwrap();
496
497 let wrong_type = EntityIdentity::try_new(vec![
498 EntityValue::from("device"),
499 EntityValue::UInt64(1),
500 EntityValue::Int64(2),
501 EntityValue::UInt64(3),
502 ])
503 .unwrap();
504 assert!(matches!(
505 ensure_entity_identity_matches_schema(&schema, &index, &wrong_type),
506 Err(SchemaCompatibilityError::EntityIdentityTypeMismatch {
507 column,
508 expected: LogicalDataType::Int32,
509 actual: "uint64",
510 }) if column == "entity_1"
511 ));
512
513 let too_short = EntityIdentity::try_new(vec![EntityValue::from("device")]).unwrap();
514 assert!(matches!(
515 ensure_entity_identity_matches_schema(&schema, &index, &too_short),
516 Err(SchemaCompatibilityError::EntityIdentityArityMismatch {
517 expected: 4,
518 actual: 1,
519 })
520 ));
521 }
522}