Skip to main content

genson_core/schema/
core.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct SchemaInferenceConfig {
7    /// Whether to treat top-level arrays as streams of objects
8    pub ignore_outer_array: bool,
9    /// Delimiter for NDJSON format (None for regular JSON)
10    pub delimiter: Option<u8>,
11    /// Schema URI to use ("AUTO" for auto-detection)
12    pub schema_uri: Option<String>,
13    /// Threshold above which non-fixed keys are treated as a map
14    pub map_threshold: usize,
15    /// Maximum number of required keys a Map can have. If None, no gating based on required keys.
16    /// If Some(n), objects with more than n required keys will be forced to Record type.
17    pub map_max_required_keys: Option<usize>,
18    /// Enable unification of compatible but non-homogeneous record schemas into maps
19    pub unify_maps: bool,
20    /// Fields whose keys should not be merged during record unification
21    pub no_unify: std::collections::HashSet<String>,
22    /// Force override of field treatment, e.g. {"labels": "map"}
23    pub force_field_types: HashMap<String, String>,
24    /// Force parent objects containing these fields to remain as records, preventing map inference.
25    /// e.g. {"mainsnak": "record"} prevents any object containing a "mainsnak" field from being
26    /// converted to a map, ensuring homogeneity across array items.
27    pub force_parent_field_types: HashMap<String, String>,
28    /// Set of field names that should always be promoted to wrapped scalars,
29    /// even when they appear as simple scalars (not in type unions). This ensures
30    /// schema stability for fields known to have heterogeneous types across schematised files.
31    pub force_scalar_promotion: std::collections::HashSet<String>,
32    /// Whether to promote scalar values to wrapped objects when they collide with record values
33    /// during unification, or with scalars of another kind (an integer and a string, not an
34    /// integer and a number). If `true`, scalars are promoted under a synthetic property name
35    /// derived from the parent field and the scalar type (e.g. "foo__string"), so every value
36    /// keeps its type. If `false`, don't unify on conflicts.
37    pub wrap_scalars: bool,
38    /// Whether the rows' strings are to be coerced to the numbers or booleans beside them
39    /// when normalised (`NormaliseConfig::coerce_string`): a string then does not clash with
40    /// them, and is not given a field of its own (`wrap_scalars`). A configuration
41    /// serialised before this field was added reads it as `false`.
42    #[serde(default)]
43    pub coerce_strings: bool,
44    /// Wrap the inferred top-level schema under a single required field with this name.
45    /// Example: wrap_root = Some("labels") turns `{...}` into
46    /// `{"type":"object","properties":{"labels":{...}},"required":["labels"]}`.
47    pub wrap_root: Option<String>,
48    /// Prevent the document root from becoming a map type, even if it meets map inference criteria
49    pub no_root_map: bool,
50    /// Maximum number of schema builders to create in parallel at once
51    /// Lower values reduce peak memory usage during schema inference
52    /// None: process all strings at once
53    pub max_builders: Option<usize>,
54    /// Whether to output Avro schema rather than regular JSON Schema.
55    #[cfg(feature = "avro")]
56    pub avro: bool,
57    /// Enable debug output. When `true`, prints detailed information about schema inference
58    /// processes including field unification, map detection, and scalar wrapping decisions.
59    pub debug: bool,
60    /// Enable profiling output. When `true`, prints detailed information about timing.
61    pub profile: bool,
62    /// Controls the verbosity level of debug output
63    pub verbosity: DebugVerbosity,
64}
65
66#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
67pub enum DebugVerbosity {
68    /// Show important unification decisions and failures  
69    #[default]
70    Normal,
71    /// Show all debug information including field introductions
72    Verbose,
73}
74
75impl SchemaInferenceConfig {
76    pub(crate) fn profile(&self, args: std::fmt::Arguments) {
77        if self.profile {
78            let message = format!("{}", args);
79            anstream::eprintln!("{}", message);
80        }
81    }
82
83    pub(crate) fn profile_verbose(&self, args: std::fmt::Arguments) {
84        if self.profile && matches!(self.verbosity, DebugVerbosity::Verbose) {
85            let message = format!("{}", args);
86            anstream::eprintln!("{}", message);
87        }
88    }
89
90    pub(crate) fn debug(&self, args: std::fmt::Arguments) {
91        if self.debug {
92            let message = format!("{}", args);
93            anstream::eprintln!("{}", self.maybe_truncate(message));
94        }
95    }
96
97    pub(crate) fn debug_verbose(&self, args: std::fmt::Arguments) {
98        if self.debug_verbose_enabled() {
99            let message = format!("{}", args);
100            anstream::eprintln!("{}", self.maybe_truncate(message));
101        }
102    }
103
104    /// Whether `debug_verbose` prints.
105    pub(crate) fn debug_verbose_enabled(&self) -> bool {
106        self.debug && matches!(self.verbosity, DebugVerbosity::Verbose)
107    }
108
109    fn maybe_truncate(&self, message: String) -> String {
110        let lines: Vec<&str> = message.lines().collect();
111
112        if lines.len() > 20 && self.verbosity == DebugVerbosity::Normal {
113            let mut truncated = String::new();
114
115            // First 10 lines
116            for line in lines.iter().take(10) {
117                truncated.push_str(line);
118                truncated.push('\n');
119            }
120
121            truncated.push_str(&format!("... ({} lines truncated) ...\n", lines.len() - 15));
122
123            // Last 5 lines
124            for line in lines.iter().skip(lines.len() - 5) {
125                truncated.push_str(line);
126                truncated.push('\n');
127            }
128
129            truncated
130        } else {
131            message
132        }
133    }
134}
135
136impl Default for SchemaInferenceConfig {
137    fn default() -> Self {
138        Self {
139            ignore_outer_array: true,
140            delimiter: None,
141            schema_uri: Some("AUTO".to_string()),
142            map_threshold: 20,
143            map_max_required_keys: None,
144            unify_maps: false,
145            no_unify: std::collections::HashSet::new(),
146            force_field_types: std::collections::HashMap::new(),
147            force_parent_field_types: std::collections::HashMap::new(),
148            force_scalar_promotion: std::collections::HashSet::new(),
149            wrap_scalars: true,
150            coerce_strings: false,
151            wrap_root: None,
152            no_root_map: true,
153            max_builders: None,
154            #[cfg(feature = "avro")]
155            avro: false,
156            debug: false,
157            profile: false,
158            verbosity: DebugVerbosity::default(),
159        }
160    }
161}
162
163#[macro_export]
164macro_rules! profile {
165    ($cfg:expr, $($arg:tt)*) => {
166        $cfg.profile(format_args!($($arg)*))
167    };
168}
169
170#[macro_export]
171macro_rules! profile_verbose {
172    ($cfg:expr, $($arg:tt)*) => {
173        $cfg.profile_verbose(format_args!($($arg)*))
174    };
175}
176
177// `format_args!` evaluates its arguments where it is written, so the macros check that the
178// message prints before building it: an argument such as `serde_json::to_string(schema)`
179// would otherwise run on every call with debugging off.
180
181#[macro_export]
182macro_rules! debug {
183    ($cfg:expr, $($arg:tt)*) => {{
184        let cfg = &$cfg;
185        if cfg.debug {
186            cfg.debug(format_args!($($arg)*))
187        }
188    }};
189}
190
191#[macro_export]
192macro_rules! debug_verbose {
193    ($cfg:expr, $($arg:tt)*) => {{
194        let cfg = &$cfg;
195        if cfg.debug_verbose_enabled() {
196            cfg.debug_verbose(format_args!($($arg)*))
197        }
198    }};
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct SchemaInferenceResult {
203    pub schema: Value,
204    pub processed_count: usize,
205}
206
207#[cfg(feature = "avro")]
208impl SchemaInferenceResult {
209    pub fn to_avro_schema(
210        &self,
211        namespace: &str,
212        utility_namespace: Option<&str>,
213        base_uri: Option<&str>,
214        split_top_level: bool,
215    ) -> Value {
216        let mut schema = self.schema.clone();
217        type_empty_array_items_as_null(&mut schema);
218        keep_empty_objects_as_records(&mut schema);
219        avrotize::converter::jsons_to_avro(
220            &schema,
221            namespace,
222            utility_namespace.unwrap_or(""),
223            base_uri.unwrap_or("genson-core"),
224            split_top_level,
225        )
226    }
227}
228
229/// Give untyped array items (`"items": {}`, from arrays that were empty in every row)
230/// the type `null`. Left untyped, avrotize expands them to a generic union whose first
231/// non-null branch is `boolean`, so the column became `List(Boolean)` and could not be
232/// concatenated with a batch where the array has values.
233///
234/// An untyped array in an `anyOf` with scalars instead takes their types: normalisation
235/// wraps such a scalar into the array (its first branch), so typed `null` the array held
236/// values it could not decode (`List(Null)` holding `["x"]`).
237#[cfg(feature = "avro")]
238fn type_empty_array_items_as_null(schema: &mut Value) {
239    match schema {
240        Value::Object(obj) => {
241            if let Some(Value::Array(branches)) = obj.get_mut("anyOf") {
242                type_empty_array_items_by_scalars(branches);
243            }
244            if is_untyped_array(obj) {
245                obj.insert("items".to_string(), serde_json::json!({"type": "null"}));
246            }
247            obj.values_mut().for_each(type_empty_array_items_as_null);
248        }
249        Value::Array(arr) => arr.iter_mut().for_each(type_empty_array_items_as_null),
250        _ => {}
251    }
252}
253
254/// Give objects that were empty in every row (`{"type": "object"}`, alone or in a type
255/// list, with no properties of any kind) an explicit `"properties": {}`. avrotize (from
256/// 0.2.0) reads an object without `properties`, `patternProperties` or
257/// `additionalProperties` as holding any properties: a map of its generic union. With
258/// its properties given as none, the object stays a record with no fields
259/// (`Struct({})`), as inferred, since no row had a key there.
260#[cfg(feature = "avro")]
261fn keep_empty_objects_as_records(schema: &mut Value) {
262    match schema {
263        Value::Object(obj) => {
264            if is_empty_object(obj) {
265                obj.insert("properties".to_string(), serde_json::json!({}));
266            }
267            obj.values_mut().for_each(keep_empty_objects_as_records);
268        }
269        Value::Array(arr) => arr.iter_mut().for_each(keep_empty_objects_as_records),
270        _ => {}
271    }
272}
273
274#[cfg(feature = "avro")]
275fn is_empty_object(obj: &serde_json::Map<String, Value>) -> bool {
276    let object = match obj.get("type") {
277        Some(Value::String(t)) => t == "object",
278        Some(Value::Array(types)) => types.iter().any(|t| t == "object"),
279        _ => false,
280    };
281    object
282        && !["properties", "patternProperties", "additionalProperties"]
283            .iter()
284            .any(|k| obj.contains_key(*k))
285}
286
287#[cfg(feature = "avro")]
288fn is_untyped_array(obj: &serde_json::Map<String, Value>) -> bool {
289    obj.get("type").and_then(|t| t.as_str()) == Some("array")
290        && obj
291            .get("items")
292            .is_some_and(|i| i.as_object().is_some_and(|o| o.is_empty()))
293}
294
295/// The scalar types of an `anyOf`'s other branches (`null` aside) as the items of its
296/// untyped arrays.
297#[cfg(feature = "avro")]
298fn type_empty_array_items_by_scalars(branches: &mut [Value]) {
299    let mut scalars: Vec<Value> = Vec::new();
300    for branch in branches.iter() {
301        let types = match branch.get("type") {
302            Some(Value::String(t)) => vec![t.as_str()],
303            Some(Value::Array(ts)) => ts.iter().filter_map(Value::as_str).collect(),
304            _ => continue,
305        };
306        // Only a branch that is scalars alone: one with any other keyword is not wrapped
307        if branch.as_object().is_some_and(|o| o.len() != 1) {
308            continue;
309        }
310        for t in types {
311            let scalar = matches!(t, "string" | "integer" | "number" | "boolean");
312            if scalar && !scalars.iter().any(|s| s == t) {
313                scalars.push(Value::String(t.to_string()));
314            }
315        }
316    }
317    let items = match scalars.len() {
318        0 => return,
319        1 => serde_json::json!({"type": scalars[0]}),
320        _ => serde_json::json!({"type": scalars}),
321    };
322    for branch in branches.iter_mut() {
323        if let Some(obj) = branch.as_object_mut() {
324            if is_untyped_array(obj) {
325                obj.insert("items".to_string(), items.clone());
326            }
327        }
328    }
329}
330
331/// Generate a consistent key name for promoted scalar values.
332///
333/// Creates keys in the format `{field_prefix}__{scalar_type}` for scalar values
334/// that are promoted to object fields during schema unification or normalisation.
335pub fn make_promoted_scalar_key(field_prefix: &str, scalar_type: &str) -> String {
336    // Could be parameterised by config in future to make configurable
337    format!("{}__{}", field_prefix, scalar_type)
338}