Skip to main content

arrow_sql_server/write/record_batch/
validate.rs

1//! Runtime RecordBatch validation against planned schema mappings.
2
3use arrow_array::{Array, RecordBatch};
4use arrow_schema::{Field, Schema};
5
6use crate::{
7    Diagnostic, DiagnosticCode, DiagnosticSet, FieldRef, Result, SchemaMapping,
8    conversion::arrow_to_mssql::variable_width::arrow_type_compatible_with_mapping,
9};
10
11/// Validates a runtime Arrow schema against planned Arrow-side schema mappings.
12///
13/// This is a strict schema-contract check for callers that plan once and later
14/// want to confirm a runtime schema still matches that plan before writing. It
15/// verifies field count, field order, planned Arrow index, field name, Arrow
16/// data type compatibility, and Arrow nullability.
17///
18/// This function does not inspect row values. A nullable runtime value in a
19/// non-nullable SQL Server target column is still a value-conversion error and
20/// is reported by writer paths that inspect rows.
21pub fn validate_arrow_schema_against_mappings(
22    schema: &Schema,
23    mappings: &[SchemaMapping],
24) -> Result<()> {
25    validate_schema_field_count_against_mappings(schema, mappings)?;
26
27    for (position, (field, mapping)) in schema.fields().iter().zip(mappings).enumerate() {
28        validate_schema_field_shape_against_mapping(position, field, mapping)?;
29        validate_schema_field_nullability_against_mapping(field, mapping)?;
30    }
31
32    Ok(())
33}
34
35fn validate_schema_shape_for_record_batch_encoding(
36    schema: &Schema,
37    mappings: &[SchemaMapping],
38) -> Result<()> {
39    validate_schema_field_count_against_mappings(schema, mappings)?;
40
41    for (position, (field, mapping)) in schema.fields().iter().zip(mappings).enumerate() {
42        validate_schema_field_shape_against_mapping(position, field, mapping)?;
43    }
44
45    Ok(())
46}
47
48fn validate_schema_field_count_against_mappings(
49    schema: &Schema,
50    mappings: &[SchemaMapping],
51) -> Result<()> {
52    if schema.fields().len() < mappings.len() {
53        let mapping = &mappings[schema.fields().len()];
54        return Err(value_conversion_error(mapping_diagnostic(
55            mapping,
56            DiagnosticCode::SchemaMismatch,
57            format!(
58                "planned column index {} is outside runtime schema with {} field(s)",
59                mapping.arrow().index(),
60                schema.fields().len()
61            ),
62        )));
63    }
64
65    if schema.fields().len() > mappings.len() {
66        return Err(value_conversion_error(Diagnostic::error(
67            DiagnosticCode::SchemaMismatch,
68            format!(
69                "runtime schema has {} field(s) but mappings contain {} field(s)",
70                schema.fields().len(),
71                mappings.len()
72            ),
73        )));
74    }
75
76    Ok(())
77}
78
79/// Validates a RecordBatch schema against planned Arrow-side schema mappings.
80///
81/// This is a convenience wrapper around
82/// [`validate_arrow_schema_against_mappings`] for callers that already have a
83/// batch. It validates `batch.schema()` only; it does not scan arrays or row
84/// values.
85pub fn validate_record_batch_schema_against_mappings(
86    batch: &RecordBatch,
87    mappings: &[SchemaMapping],
88) -> Result<()> {
89    validate_arrow_schema_against_mappings(batch.schema().as_ref(), mappings)
90}
91
92pub(crate) fn validate_record_batch_encoding_shape(
93    batch: &RecordBatch,
94    mappings: &[SchemaMapping],
95) -> Result<()> {
96    validate_schema_shape_for_record_batch_encoding(batch.schema().as_ref(), mappings)?;
97
98    // RecordBatch::try_new already enforces schema/array type consistency. This
99    // internal guard catches unchecked batches before row conversion.
100    for (array, mapping) in batch.columns().iter().zip(mappings) {
101        validate_unchecked_column_array_against_mapping(array.as_ref(), mapping)?;
102    }
103
104    Ok(())
105}
106
107fn validate_schema_field_shape_against_mapping(
108    position: usize,
109    field: &Field,
110    mapping: &SchemaMapping,
111) -> Result<()> {
112    if mapping.arrow().index() != position {
113        return Err(value_conversion_error(mapping_diagnostic(
114            mapping,
115            DiagnosticCode::SchemaMismatch,
116            format!(
117                "mapping position {position} does not match planned Arrow field index {}",
118                mapping.arrow().index()
119            ),
120        )));
121    }
122
123    if field.name() != mapping.arrow().name() {
124        return Err(value_conversion_error(mapping_diagnostic(
125            mapping,
126            DiagnosticCode::SchemaMismatch,
127            format!(
128                "runtime Arrow field name {} does not match planned Arrow field name {}",
129                field.name(),
130                mapping.arrow().name()
131            ),
132        )));
133    }
134
135    if !arrow_type_compatible_with_mapping(field.data_type(), mapping) {
136        return Err(value_conversion_error(mapping_diagnostic(
137            mapping,
138            DiagnosticCode::SchemaMismatch,
139            format!(
140                "runtime Arrow type {} does not match planned Arrow type {}",
141                field.data_type(),
142                mapping.arrow().data_type()
143            ),
144        )));
145    }
146
147    Ok(())
148}
149
150fn validate_schema_field_nullability_against_mapping(
151    field: &Field,
152    mapping: &SchemaMapping,
153) -> Result<()> {
154    if field.is_nullable() != mapping.arrow().nullable() {
155        return Err(value_conversion_error(mapping_diagnostic(
156            mapping,
157            DiagnosticCode::SchemaMismatch,
158            format!(
159                "runtime Arrow nullability {} does not match planned Arrow nullability {}",
160                field.is_nullable(),
161                mapping.arrow().nullable()
162            ),
163        )));
164    }
165
166    Ok(())
167}
168
169fn validate_unchecked_column_array_against_mapping(
170    array: &dyn Array,
171    mapping: &SchemaMapping,
172) -> Result<()> {
173    if !arrow_type_compatible_with_mapping(array.data_type(), mapping) {
174        return Err(value_conversion_error(mapping_diagnostic(
175            mapping,
176            DiagnosticCode::SchemaMismatch,
177            format!(
178                "runtime Arrow type {} does not match planned Arrow type {}",
179                array.data_type(),
180                mapping.arrow().data_type()
181            ),
182        )));
183    }
184
185    Ok(())
186}
187
188fn mapping_diagnostic(
189    mapping: &SchemaMapping,
190    code: DiagnosticCode,
191    message: impl Into<String>,
192) -> Diagnostic {
193    Diagnostic::error(code, message).with_field(FieldRef::new(
194        mapping.arrow().index(),
195        mapping.arrow().name(),
196    ))
197}
198
199fn value_conversion_error(diagnostic: Diagnostic) -> crate::Error {
200    crate::Error::ValueConversion {
201        diagnostics: DiagnosticSet::from(vec![diagnostic]),
202    }
203}