use serde_json::Value;
use super::error::OpenConnectorError;
use super::json_to_arrow::{ColumnSpec, FieldType};
use super::row_path::RowPath;
pub(crate) fn derive_raw_columns(
action_id: &str,
output_schema: Option<&Value>,
row_path: &RowPath,
) -> Result<Vec<ColumnSpec>, OpenConnectorError> {
let fail = |reason: String| OpenConnectorError::RawRowTypeIndeterminate {
action_id: action_id.to_string(),
row_path: row_path.as_str().to_string(),
reason,
};
let mut node =
output_schema.ok_or_else(|| fail("the action declares no output schema".to_string()))?;
for segment in row_path.segments() {
let properties = node
.get("properties")
.and_then(Value::as_object)
.ok_or_else(|| {
fail(format!(
"the schema containing segment '{segment}' declares no object properties"
))
})?;
node = properties.get(segment).ok_or_else(|| {
fail(format!(
"segment '{segment}' is not declared in the output schema"
))
})?;
}
if schema_type(node) != Some("array") {
return Err(fail(format!(
"the row-path target is declared as {}, not an array of row objects",
schema_type(node).unwrap_or("an undeclared type")
)));
}
let items = node
.get("items")
.filter(|items| items.is_object())
.ok_or_else(|| fail("the row array declares no single item schema".to_string()))?;
if schema_type(items) != Some("object") {
return Err(fail(format!(
"the row items are declared as {}, not objects",
schema_type(items).unwrap_or("an undeclared type")
)));
}
let properties = items
.get("properties")
.and_then(Value::as_object)
.filter(|properties| !properties.is_empty())
.ok_or_else(|| fail("the row object schema declares no properties".to_string()))?;
let mut columns = Vec::with_capacity(properties.len());
for (name, property) in properties {
if name.is_empty() || name.contains('.') {
return Err(fail(format!(
"property '{name}' cannot be exposed as a column (empty or dotted name)"
)));
}
let (field_type, nullable) = column_type(property);
columns.push(ColumnSpec {
name: name.clone(),
path: name.clone(),
field_type,
nullable,
});
}
columns.sort_by(|a, b| a.name.cmp(&b.name));
Ok(columns)
}
fn schema_type(node: &Value) -> Option<&str> {
match node.get("type") {
Some(Value::String(t)) => Some(t.as_str()),
Some(Value::Array(types)) => {
let mut non_null = types
.iter()
.filter_map(Value::as_str)
.filter(|t| *t != "null");
match (non_null.next(), non_null.next()) {
(Some(t), None) => Some(t),
_ => None,
}
}
_ => None,
}
}
fn column_type(property: &Value) -> (FieldType, bool) {
let field_type = match schema_type(property) {
Some("string") => FieldType::Utf8,
Some("integer") => FieldType::Int64,
Some("number") => FieldType::Float64,
Some("boolean") => FieldType::Boolean,
_ => FieldType::Json,
};
(field_type, true)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn path(raw: &str) -> RowPath {
RowPath::parse(raw).expect("valid path")
}
fn issues_schema() -> Value {
json!({
"type": "object",
"properties": {
"issues": {
"type": "array",
"items": {
"type": "object",
"properties": {
"number": {"type": "integer"},
"title": {"type": "string"},
"score": {"type": "number"},
"open": {"type": "boolean"},
"body": {"type": ["string", "null"]},
"user": {"type": "object", "properties": {"login": {"type": "string"}}},
"labels": {"type": "array", "items": {"type": "string"}},
"meta": {}
}
}
}
}
})
}
#[test]
fn derives_sorted_typed_columns_with_json_fallback() {
let columns =
derive_raw_columns("github.x", Some(&issues_schema()), &path("$.issues")).unwrap();
let summary: Vec<(&str, FieldType)> = columns
.iter()
.map(|c| (c.name.as_str(), c.field_type))
.collect();
assert_eq!(
summary,
vec![
("body", FieldType::Utf8), ("labels", FieldType::Json), ("meta", FieldType::Json), ("number", FieldType::Int64),
("open", FieldType::Boolean),
("score", FieldType::Float64),
("title", FieldType::Utf8),
("user", FieldType::Json), ],
"columns are sorted by name and typed conservatively"
);
assert!(
columns.iter().all(|c| c.nullable),
"raw columns are nullable"
);
assert!(
columns.iter().all(|c| c.name == c.path),
"raw columns read top-level row keys"
);
}
#[test]
fn nested_row_paths_descend_through_properties() {
let schema = json!({
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {"type": "object", "properties": {"id": {"type": "integer"}}}
}
}
}
}
});
let columns = derive_raw_columns("a.b", Some(&schema), &path("$.data.items")).unwrap();
assert_eq!(columns.len(), 1);
assert_eq!(columns[0].name, "id");
assert_eq!(columns[0].field_type, FieldType::Int64);
}
#[test]
fn missing_output_schema_is_indeterminate() {
let err = derive_raw_columns("a.b", None, &path("$.items")).unwrap_err();
assert!(matches!(
err,
OpenConnectorError::RawRowTypeIndeterminate { ref reason, .. }
if reason.contains("no output schema")
));
}
#[test]
fn unknown_segment_is_indeterminate() {
let err = derive_raw_columns("a.b", Some(&issues_schema()), &path("$.rows")).unwrap_err();
assert!(matches!(
err,
OpenConnectorError::RawRowTypeIndeterminate { ref reason, .. }
if reason.contains("segment 'rows'")
));
}
#[test]
fn non_array_target_is_indeterminate() {
let schema = json!({
"type": "object",
"properties": {"total": {"type": "integer"}}
});
let err = derive_raw_columns("a.b", Some(&schema), &path("$.total")).unwrap_err();
assert!(matches!(
err,
OpenConnectorError::RawRowTypeIndeterminate { ref reason, .. }
if reason.contains("not an array")
));
}
#[test]
fn array_of_non_objects_is_indeterminate() {
let schema = json!({
"type": "object",
"properties": {"ids": {"type": "array", "items": {"type": "integer"}}}
});
let err = derive_raw_columns("a.b", Some(&schema), &path("$.ids")).unwrap_err();
assert!(matches!(
err,
OpenConnectorError::RawRowTypeIndeterminate { ref reason, .. }
if reason.contains("not objects")
));
}
#[test]
fn propertyless_row_objects_are_indeterminate() {
let schema = json!({
"type": "object",
"properties": {"items": {"type": "array", "items": {"type": "object"}}}
});
let err = derive_raw_columns("a.b", Some(&schema), &path("$.items")).unwrap_err();
assert!(err.to_string().contains("source-pack"), "got: {err}");
}
#[test]
fn dotted_property_names_are_rejected() {
let schema = json!({
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {"type": "object", "properties": {"a.b": {"type": "string"}}}
}
}
});
let err = derive_raw_columns("a.b", Some(&schema), &path("$.items")).unwrap_err();
assert!(matches!(
err,
OpenConnectorError::RawRowTypeIndeterminate { ref reason, .. }
if reason.contains("'a.b'")
));
}
#[test]
fn wide_unions_fall_back_to_json() {
let schema = json!({
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {"value": {"type": ["string", "integer"]}}
}
}
}
});
let columns = derive_raw_columns("a.b", Some(&schema), &path("$.items")).unwrap();
assert_eq!(columns[0].field_type, FieldType::Json);
}
}