use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DatasetDescriptor {
pub name: String,
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub estimated_rows: Option<u64>,
pub config_patch: Value,
}
impl DatasetDescriptor {
pub fn new(name: impl Into<String>, kind: impl Into<String>, config_patch: Value) -> Self {
Self {
name: name.into(),
kind: kind.into(),
schema: None,
estimated_rows: None,
config_patch,
}
}
pub fn with_schema(mut self, schema: Value) -> Self {
self.schema = Some(schema);
self
}
pub fn with_estimated_rows(mut self, rows: u64) -> Self {
self.estimated_rows = Some(rows);
self
}
}
pub fn sql_type_to_json_schema(data_type: &str) -> Value {
let t = data_type.to_ascii_lowercase();
let ty = if t.contains("bool") || t == "bit" {
"boolean"
} else if t.contains("json") || t.contains("variant") || t == "object" || t.contains("struct") {
"object"
} else if t.contains("array") || t.starts_with('_') {
"array"
} else if (t.contains("int") && !t.contains("interval") && !t.contains("point"))
|| t == "serial"
|| t == "bigserial"
|| t == "smallserial"
{
"integer"
} else if t.contains("double")
|| t.contains("float")
|| t.contains("real")
|| t.contains("decimal")
|| t.contains("numeric")
|| t.contains("money")
|| t == "number"
{
"number"
} else {
"string"
};
json!({ "type": ty })
}
pub fn nullable_type(fragment: Value) -> Value {
match fragment.get("type") {
Some(Value::String(t)) => json!({ "type": [t, "null"] }),
_ => fragment,
}
}
pub fn columns_to_schema(columns: impl IntoIterator<Item = (String, Value)>) -> Value {
let mut properties = serde_json::Map::new();
for (name, fragment) in columns {
properties.insert(name, fragment);
}
json!({ "type": "object", "properties": Value::Object(properties) })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn descriptor_builder_round_trips() {
let d = DatasetDescriptor::new("public.orders", "table", json!({"query": "SELECT 1"}))
.with_schema(json!({"type": "object", "properties": {}}))
.with_estimated_rows(42);
assert_eq!(d.name, "public.orders");
assert_eq!(d.kind, "table");
assert_eq!(d.estimated_rows, Some(42));
let v = serde_json::to_value(&d).unwrap();
let back: DatasetDescriptor = serde_json::from_value(v).unwrap();
assert_eq!(back, d);
}
#[test]
fn descriptor_omits_absent_optionals_in_json() {
let d = DatasetDescriptor::new("t", "table", json!({}));
let v = serde_json::to_value(&d).unwrap();
assert!(v.get("schema").is_none());
assert!(v.get("estimated_rows").is_none());
}
#[test]
fn sql_types_map_to_json_types() {
for (sql, want) in [
("integer", "integer"),
("BIGINT", "integer"),
("smallint", "integer"),
("tinyint(1)", "integer"),
("serial", "integer"),
("double precision", "number"),
("NUMERIC(10,2)", "number"),
("decimal", "number"),
("float8", "number"),
("money", "number"),
("boolean", "boolean"),
("BOOL", "boolean"),
("bit", "boolean"),
("json", "object"),
("JSONB", "object"),
("VARIANT", "object"),
("STRUCT<a INT>", "object"),
("ARRAY", "array"),
("_int4", "array"),
("text", "string"),
("character varying", "string"),
("uuid", "string"),
("timestamp with time zone", "string"),
("date", "string"),
("bytea", "string"),
("some_exotic_type", "string"),
] {
assert_eq!(
sql_type_to_json_schema(sql),
json!({ "type": want }),
"for SQL type {sql:?}"
);
}
}
#[test]
fn nullable_wraps_scalar_type() {
assert_eq!(
nullable_type(json!({"type": "integer"})),
json!({"type": ["integer", "null"]})
);
let complex = json!({"type": ["string", "null"]});
assert_eq!(nullable_type(complex.clone()), complex);
}
#[test]
fn columns_to_schema_shapes_like_infer_schema() {
let schema = columns_to_schema(vec![
("id".to_string(), json!({"type": "integer"})),
("name".to_string(), json!({"type": ["string", "null"]})),
]);
assert_eq!(schema["type"], "object");
assert_eq!(schema["properties"]["id"]["type"], "integer");
assert_eq!(schema["properties"]["name"]["type"][1], "null");
}
}