use std::collections::HashMap;
use std::sync::Arc;
use arrow_schema::{DataType, Field, Schema, SchemaRef, TimeUnit};
use geopackage_core::types::ColumnType;
use crate::schema::{Column, GeometryColumn};
use crate::{Layer, Result};
pub(crate) const EXTENSION_NAME_KEY: &str = "ARROW:extension:name";
pub(crate) const EXTENSION_METADATA_KEY: &str = "ARROW:extension:metadata";
pub(crate) const GEOARROW_WKB: &str = "geoarrow.wkb";
pub(crate) const DATETIME_UNIT: TimeUnit = TimeUnit::Microsecond;
impl Layer<'_> {
pub fn arrow_schema(&self) -> Result<SchemaRef> {
let geometry = self.geometry_column();
let fields: Vec<Field> = self
.schema()
.columns
.iter()
.filter(|column| self.arrow_reads_column(column, geometry))
.map(|column| field_for(column, geometry))
.collect();
Ok(Arc::new(Schema::new(fields)))
}
fn arrow_reads_column(&self, column: &Column, geometry: Option<&GeometryColumn>) -> bool {
if self.primary_key_column() == Some(column.name.as_str()) {
return true;
}
if geometry.is_some_and(|g| g.column_name == column.name) {
return self.reads_geometry();
}
self.read_value_columns()
.iter()
.any(|kept| kept.name == column.name)
}
}
fn field_for(column: &Column, geometry: Option<&GeometryColumn>) -> Field {
let is_geometry = geometry.is_some_and(|g| g.column_name == column.name);
if is_geometry {
let srs_id = geometry.map_or(0, |g| g.srs_id);
return geometry_field(&column.name, column.not_null, srs_id);
}
let nullable = !column.not_null && !column.is_primary_key();
Field::new(&column.name, data_type_for(column), nullable)
}
fn data_type_for(column: &Column) -> DataType {
let Some(declared) = &column.column_type else {
return affinity_type(&column.declared_type);
};
match declared {
ColumnType::Boolean => DataType::Boolean,
ColumnType::TinyInt
| ColumnType::SmallInt
| ColumnType::MediumInt
| ColumnType::Integer => DataType::Int64,
ColumnType::Float | ColumnType::Double => DataType::Float64,
ColumnType::Text(_) => DataType::Utf8,
ColumnType::Blob(_) => DataType::Binary,
ColumnType::Date => DataType::Date32,
ColumnType::DateTime => DataType::Timestamp(DATETIME_UNIT, Some("UTC".into())),
ColumnType::Geometry(_) => DataType::Binary,
_ => affinity_type(&column.declared_type),
}
}
fn affinity_type(declared: &str) -> DataType {
let declared = declared.to_ascii_uppercase();
let has = |needle: &str| declared.contains(needle);
if has("INT") {
DataType::Int64
} else if has("CHAR") || has("CLOB") || has("TEXT") {
DataType::Utf8
} else if has("BLOB") || declared.is_empty() {
DataType::Binary
} else {
DataType::Float64
}
}
fn geometry_field(name: &str, not_null: bool, srs_id: i32) -> Field {
let mut metadata = HashMap::new();
metadata.insert(EXTENSION_NAME_KEY.to_owned(), GEOARROW_WKB.to_owned());
metadata.insert(EXTENSION_METADATA_KEY.to_owned(), crs_metadata(srs_id));
Field::new(name, DataType::Binary, !not_null).with_metadata(metadata)
}
fn crs_metadata(srs_id: i32) -> String {
if srs_id <= 0 {
return "{}".to_owned();
}
epsg_utils::epsg_to_projjson(srs_id).map_or_else(
|_| format!(r#"{{"crs":"EPSG:{srs_id}","crs_type":"authority_code"}}"#),
|projjson| format!(r#"{{"crs":{projjson},"crs_type":"projjson"}}"#),
)
}
pub(crate) fn epsg_code(metadata: &str) -> Option<i32> {
let value: serde_json::Value = serde_json::from_str(metadata).ok()?;
let crs = value.get("crs")?;
if let Some(id) = crs.get("id")
&& id
.get("authority")
.and_then(serde_json::Value::as_str)
.is_some_and(|a| a.eq_ignore_ascii_case("EPSG"))
{
return id.get("code")?.as_i64()?.try_into().ok();
}
let code = crs.as_str()?.strip_prefix("EPSG:")?;
code.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epsg_code_reads_the_crs_id_not_a_nested_one() {
let metadata = crs_metadata(4326);
assert!(
metadata.contains(r#""code":6422"#),
"the trap this guards against has moved, update the test: {metadata}"
);
assert_eq!(epsg_code(&metadata), Some(4326));
}
#[test]
fn epsg_code_reads_the_authority_code_form() {
assert_eq!(
epsg_code(r#"{"crs":"EPSG:27700","crs_type":"authority_code"}"#),
Some(27700)
);
}
#[test]
fn epsg_code_declines_what_it_cannot_identify() {
assert_eq!(epsg_code("{}"), None);
assert_eq!(epsg_code("not json"), None);
assert_eq!(
epsg_code(r#"{"crs":{"id":{"authority":"ESRI","code":104305}}}"#),
None
);
}
#[test]
fn a_layer_srs_round_trips_through_the_metadata() {
for code in [4326, 27700, 32630, 4979] {
assert_eq!(epsg_code(&crs_metadata(code)), Some(code), "code {code}");
}
}
#[test]
fn affinity_follows_sqlite_rules() {
assert_eq!(affinity_type("VARCHAR(20)"), DataType::Utf8);
assert_eq!(affinity_type("NVARCHAR(100)"), DataType::Utf8);
assert_eq!(affinity_type("CLOB"), DataType::Utf8);
assert_eq!(affinity_type("BIGINT"), DataType::Int64);
assert_eq!(affinity_type("UNSIGNED BIG INT"), DataType::Int64);
assert_eq!(affinity_type(""), DataType::Binary);
assert_eq!(affinity_type("DOUBLE PRECISION"), DataType::Float64);
assert_eq!(affinity_type("NUMERIC"), DataType::Float64);
assert_eq!(affinity_type("DECIMAL(10,5)"), DataType::Float64);
}
#[test]
fn int_wins_over_the_text_family() {
assert_eq!(affinity_type("INTCHAR"), DataType::Int64);
}
#[test]
fn char_family_wins_over_blob() {
assert_eq!(affinity_type("TEXTBLOB"), DataType::Utf8);
}
}