use anyhow::{Context, Result};
use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
use geojson::Feature;
use postgres::types::Type;
use postgres::{Client, NoTls, Row};
use std::sync::Arc;
use crate::db_input_common::{
apply_int_time_format, decimal_string_to_json, json_number_or_null, warn_dropped_columns,
RowOutcome, VertexCoercions,
};
use crate::input::{
parse_iso8601, parse_wkb_geometry, reject_negative_timestamp, InputStrictness, ParsedFeature,
TimeFormat,
};
pub const WKB_ALIAS: &str = "__stt_wkb";
pub const DEFAULT_BATCH_SIZE: usize = 8192;
#[derive(Debug, Clone)]
pub enum QuerySource {
Table(String),
Sql(String),
}
#[derive(Debug, Clone)]
pub struct QuerySpec {
pub source: QuerySource,
pub geom_column: String,
pub where_clause: Option<String>,
pub reproject_from_srid: Option<i32>,
}
impl QuerySpec {
fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
pub fn wrapped_query(&self) -> String {
let inner = match &self.source {
QuerySource::Table(t) => format!("SELECT * FROM {t}"),
QuerySource::Sql(s) => s.clone(),
};
let geom = format!("q.{}", Self::quote_ident(&self.geom_column));
let wkb_expr = match self.reproject_from_srid {
Some(srid) => {
format!("ST_AsEWKB(ST_Transform(ST_SetSRID({geom}, {srid}), 4326))")
}
None => format!("ST_AsEWKB({geom})"),
};
let where_sql = self
.where_clause
.as_ref()
.map(|w| format!(" WHERE {w}"))
.unwrap_or_default();
format!("SELECT {wkb_expr} AS {WKB_ALIAS}, q.* FROM ( {inner} ) AS q{where_sql}")
}
}
#[allow(clippy::too_many_arguments)]
pub fn build_tile_query(
table: &str,
geom_column: &str,
time_field: &str,
time_format: TimeFormat,
bbox: [f64; 4],
t_start_ms: i64,
t_end_ms: i64,
source_srid: Option<i32>,
) -> String {
let geom_raw = format!("q.{}", QuerySpec::quote_ident(geom_column));
let geom = match source_srid {
Some(srid) => format!("ST_Transform(ST_SetSRID({geom_raw}, {srid}), 4326)"),
None => geom_raw,
};
let time = format!("q.{}", QuerySpec::quote_ident(time_field));
let time_where = pg_time_window(&time, time_format, t_start_ms, t_end_ms);
format!(
"SELECT ST_AsEWKB({geom}) AS {WKB_ALIAS}, q.* \
FROM ( SELECT * FROM {table} ) AS q \
WHERE {geom} && ST_MakeEnvelope({}, {}, {}, {}, 4326) \
AND {time_where}",
bbox[0], bbox[1], bbox[2], bbox[3]
)
}
fn pg_time_window(time: &str, time_format: TimeFormat, t_start_ms: i64, t_end_ms: i64) -> String {
match time_format {
TimeFormat::Iso8601 => format!(
"{time} >= to_timestamp({t_start_ms}::double precision / 1000.0) \
AND {time} < to_timestamp({t_end_ms}::double precision / 1000.0)"
),
TimeFormat::UnixMs => {
format!("{time} >= {t_start_ms} AND {time} < {t_end_ms}")
}
TimeFormat::UnixSec => {
let start_s = crate::db_input_common::ceil_ms_to_seconds(t_start_ms);
let end_s = crate::db_input_common::ceil_ms_to_seconds(t_end_ms);
format!("{time} >= {start_s} AND {time} < {end_s}")
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn decode_rows(
rows: &[Row],
time_field: &str,
end_time_field: Option<&str>,
geom_column: &str,
time_format: TimeFormat,
time_strictness: InputStrictness,
geometry_strictness: InputStrictness,
) -> Result<Vec<ParsedFeature>> {
if rows.is_empty() {
return Ok(Vec::new());
}
let schema = RowSchema::from_columns(
rows[0].columns(),
time_field,
end_time_field,
geom_column,
time_format,
)?;
let mut out = Vec::with_capacity(rows.len());
let mut sink = VertexCoercions::default();
for (i, row) in rows.iter().enumerate() {
if let RowOutcome::Feature(f) =
schema.parse_row(row, time_strictness, geometry_strictness, i, &mut sink)?
{
out.push(*f);
}
}
Ok(out)
}
#[allow(clippy::too_many_arguments)]
pub fn load_features_postgres(
conn: &str,
spec: &QuerySpec,
time_field: &str,
end_time_field: Option<&str>,
time_format: TimeFormat,
time_strictness: InputStrictness,
geometry_strictness: InputStrictness,
) -> Result<Vec<ParsedFeature>> {
let mut features = Vec::new();
stream_features_postgres(
conn,
spec,
time_field,
end_time_field,
time_format,
time_strictness,
geometry_strictness,
DEFAULT_BATCH_SIZE,
|batch| {
features.extend(batch);
Ok(())
},
)?;
tracing::info!("Loaded {} total features from PostGIS", features.len());
Ok(features)
}
#[allow(clippy::too_many_arguments)]
pub fn stream_features_postgres<F>(
conn: &str,
spec: &QuerySpec,
time_field: &str,
end_time_field: Option<&str>,
time_format: TimeFormat,
time_strictness: InputStrictness,
geometry_strictness: InputStrictness,
batch_size: usize,
mut on_batch: F,
) -> Result<()>
where
F: FnMut(Vec<ParsedFeature>) -> Result<()>,
{
let wrapped = spec.wrapped_query();
tracing::debug!("PostGIS source query: {wrapped}");
let mut client = Client::connect(conn, NoTls)
.with_context(|| "failed to connect to PostgreSQL (NoTls; localhost / non-TLS only)")?;
let mut txn = client.transaction()?;
let declare = format!("DECLARE stt_cur NO SCROLL CURSOR FOR {wrapped}");
txn.batch_execute(&declare).with_context(|| {
"failed to open PostGIS cursor — check --table/--sql, --geom-column and the connection"
})?;
let fetch = format!("FETCH FORWARD {} FROM stt_cur", batch_size.max(1));
let mut schema: Option<RowSchema> = None;
let mut total_rows = 0usize;
let mut geom_failures = 0usize;
let mut vertex_coercions = VertexCoercions::default();
let mut seen_props: std::collections::HashSet<String> = std::collections::HashSet::new();
loop {
let rows = txn.query(&fetch, &[]).context("PostGIS cursor FETCH failed")?;
if rows.is_empty() {
break;
}
if schema.is_none() {
schema = Some(RowSchema::from_columns(
rows[0].columns(),
time_field,
end_time_field,
&spec.geom_column,
time_format,
)?);
}
let sch = schema.as_ref().unwrap();
let mut batch = Vec::with_capacity(rows.len());
for (i, row) in rows.iter().enumerate() {
match sch.parse_row(
row,
time_strictness,
geometry_strictness,
total_rows + i,
&mut vertex_coercions,
)? {
RowOutcome::Feature(f) => batch.push(*f),
RowOutcome::GeomSkip => geom_failures += 1,
}
}
total_rows += rows.len();
if total_rows % 100_000 < rows.len() {
tracing::info!("Loaded {total_rows} rows from PostGIS...");
}
if seen_props.len() < sch.props.len() {
for f in &batch {
if let Some(props) = &f.shared_properties {
for k in props.keys() {
seen_props.insert(k.clone());
}
}
}
}
on_batch(batch)?;
}
let _ = txn.batch_execute("CLOSE stt_cur");
if geom_failures > 0 {
tracing::warn!(
"{geom_failures}/{total_rows} PostGIS rows had null/unparseable geometry and were skipped"
);
}
if let Some(sch) = schema.as_ref() {
warn_dropped_columns(
sch.props
.iter()
.map(|p| (p.name.as_str(), Some(p.ty.name().to_string()))),
&seen_props,
total_rows,
"PostGIS",
"geometry/uuid/array",
);
}
vertex_coercions.warn("PostGIS");
Ok(())
}
pub fn property_kinds(
conn: &str,
spec: &QuerySpec,
time_field: &str,
end_time_field: Option<&str>,
) -> Result<crate::columnar::PropertyTypes> {
let mut client = Client::connect(conn, NoTls)
.with_context(|| "failed to connect to PostgreSQL (NoTls; localhost / non-TLS only)")?;
let stmt = client
.prepare(&spec.wrapped_query())
.context("prepare PostGIS schema probe — check --table/--sql and --geom-column")?;
Ok(property_kinds_from_columns(
stmt.columns(),
time_field,
end_time_field,
&spec.geom_column,
))
}
pub fn property_kinds_from_columns(
columns: &[postgres::Column],
time_field: &str,
end_time_field: Option<&str>,
geom_column: &str,
) -> crate::columnar::PropertyTypes {
let mut kinds = crate::columnar::PropertyTypes::new();
for col in columns {
let name = col.name();
if name == WKB_ALIAS
|| name == time_field
|| end_time_field == Some(name)
|| name == geom_column
|| crate::input::is_coordinate_column_name(name)
|| crate::input::is_vertex_metadata_column(name)
{
continue;
}
if let Some(kind) = property_kind_for_pg(col.type_()) {
kinds.insert(name.to_string(), kind);
}
}
kinds
}
fn property_kind_for_pg(ty: &Type) -> Option<crate::columnar::PropertyKind> {
use crate::columnar::PropertyKind;
match *ty {
Type::INT2
| Type::INT4
| Type::INT8
| Type::FLOAT4
| Type::FLOAT8
| Type::NUMERIC
| Type::TIMESTAMP
| Type::TIMESTAMPTZ
| Type::DATE => Some(PropertyKind::Numeric),
Type::BOOL | Type::TEXT | Type::VARCHAR | Type::BPCHAR | Type::NAME => {
Some(PropertyKind::Categorical)
}
_ => None,
}
}
struct TimeCol {
idx: usize,
ty: Type,
}
struct PropCol {
idx: usize,
name: String,
ty: Type,
}
struct RowSchema {
wkb_idx: usize,
time: TimeCol,
end_time: Option<TimeCol>,
vertex_timestamps: Option<TimeCol>,
vertex_values: Option<TimeCol>,
vertex_value_matrix: Option<TimeCol>,
props: Vec<PropCol>,
time_format: TimeFormat,
}
impl RowSchema {
fn from_columns(
columns: &[postgres::Column],
time_field: &str,
end_time_field: Option<&str>,
geom_column: &str,
time_format: TimeFormat,
) -> Result<Self> {
let find = |name: &str| columns.iter().position(|c| c.name() == name);
let wkb_idx = find(WKB_ALIAS).ok_or_else(|| {
anyhow::anyhow!("internal: wrapped query did not project the {WKB_ALIAS} column")
})?;
let time_idx = find(time_field).ok_or_else(|| {
anyhow::anyhow!("--time-field '{time_field}' not found in the PostGIS result columns")
})?;
let time = TimeCol {
idx: time_idx,
ty: columns[time_idx].type_().clone(),
};
let end_time = match end_time_field {
Some(f) => {
let idx = find(f).ok_or_else(|| {
anyhow::anyhow!("--end-time-field '{f}' not found in the PostGIS result columns")
})?;
Some(TimeCol {
idx,
ty: columns[idx].type_().clone(),
})
}
None => None,
};
let array_col = |name: &str| {
find(name).map(|idx| TimeCol {
idx,
ty: columns[idx].type_().clone(),
})
};
let vertex_timestamps = array_col("vertex_timestamps");
let vertex_values = array_col("vertex_values");
let vertex_value_matrix = array_col("vertex_value_matrix");
let mut props = Vec::new();
for (idx, col) in columns.iter().enumerate() {
let name = col.name();
if idx == wkb_idx
|| idx == time.idx
|| end_time.as_ref().map(|e| e.idx) == Some(idx)
|| name == geom_column
|| crate::input::is_coordinate_column_name(name)
|| crate::input::is_vertex_metadata_column(name)
{
continue;
}
props.push(PropCol {
idx,
name: name.to_string(),
ty: col.type_().clone(),
});
}
Ok(RowSchema {
wkb_idx,
time,
end_time,
vertex_timestamps,
vertex_values,
vertex_value_matrix,
props,
time_format,
})
}
fn parse_row(
&self,
row: &Row,
time_strictness: InputStrictness,
geometry_strictness: InputStrictness,
row_no: usize,
coercions: &mut VertexCoercions,
) -> Result<RowOutcome> {
let wkb: Option<Vec<u8>> = row.try_get(self.wkb_idx).ok();
let parsed = wkb.as_deref().and_then(parse_wkb_geometry);
let Some((geometry, lon, lat)) = parsed else {
if geometry_strictness == InputStrictness::Strict {
anyhow::bail!(
"row {row_no}: null or unparseable geometry (rerun without --strict-geometry to skip)"
);
}
return Ok(RowOutcome::GeomSkip);
};
let timestamp = match decode_time(row, &self.time, self.time_format, row_no)? {
Some(ts) => ts,
None => {
if time_strictness == InputStrictness::Strict {
anyhow::bail!(
"row {row_no}: null/unparseable timestamp (rerun without --strict-times to coerce to epoch 0)"
);
}
0
}
};
let end_timestamp = match &self.end_time {
Some(tc) => decode_time(row, tc, self.time_format, row_no)?,
None => None,
};
let vertex_timestamps = match &self.vertex_timestamps {
Some(c) => decode_u64_ms_array(row, c, row_no, &mut coercions.timestamps)?,
None => None,
};
let vertex_values = match &self.vertex_values {
Some(c) => decode_f32_array(row, c, &mut coercions.values),
None => None,
};
let vertex_value_matrix = match &self.vertex_value_matrix {
Some(c) => decode_f32_array(row, c, &mut coercions.values),
None => None,
};
let mut properties = serde_json::Map::new();
for p in &self.props {
if let Some(value) = decode_property(row, p) {
properties.insert(p.name.clone(), value);
}
}
let shared_properties = if properties.is_empty() {
None
} else {
Some(Arc::new(properties))
};
let feature = Feature {
bbox: None,
geometry: Some(geometry),
id: None,
properties: None,
foreign_members: None,
};
Ok(RowOutcome::Feature(Box::new(ParsedFeature {
geojson: feature,
shared_properties,
timestamp,
end_timestamp,
vertex_timestamps,
vertex_values,
vertex_value_matrix,
lon,
lat,
})))
}
}
fn decode_time(row: &Row, col: &TimeCol, time_format: TimeFormat, row_no: usize) -> Result<Option<u64>> {
let ms: Option<i64> = match col.ty {
Type::TIMESTAMPTZ => row
.try_get::<_, Option<DateTime<Utc>>>(col.idx)
.ok()
.flatten()
.map(|dt| dt.timestamp_millis()),
Type::TIMESTAMP => row
.try_get::<_, Option<NaiveDateTime>>(col.idx)
.ok()
.flatten()
.map(|dt| dt.and_utc().timestamp_millis()),
Type::DATE => row
.try_get::<_, Option<NaiveDate>>(col.idx)
.ok()
.flatten()
.and_then(|d| d.and_hms_opt(0, 0, 0))
.map(|dt| dt.and_utc().timestamp_millis()),
Type::INT8 => row
.try_get::<_, Option<i64>>(col.idx)
.ok()
.flatten()
.map(|v| apply_int_time_format(v, time_format, row_no))
.transpose()?,
Type::INT4 => row
.try_get::<_, Option<i32>>(col.idx)
.ok()
.flatten()
.map(|v| apply_int_time_format(v as i64, time_format, row_no))
.transpose()?,
Type::TEXT | Type::VARCHAR | Type::BPCHAR | Type::NAME => row
.try_get::<_, Option<String>>(col.idx)
.ok()
.flatten()
.and_then(|s| parse_iso8601(&s).ok()),
_ => None,
};
match ms {
Some(v) => {
reject_negative_timestamp(row_no, v)?;
Ok(Some(v as u64))
}
None => Ok(None),
}
}
fn decode_u64_ms_array(
row: &Row,
col: &TimeCol,
row_no: usize,
coerced: &mut usize,
) -> Result<Option<Vec<u64>>> {
let mut count_nulls = |nulls: usize| *coerced += nulls;
let ms: Option<Vec<i64>> = match col.ty {
Type::INT8_ARRAY => row
.try_get::<_, Option<Vec<Option<i64>>>>(col.idx)
.ok()
.flatten()
.map(|v| {
count_nulls(v.iter().filter(|x| x.is_none()).count());
v.into_iter().map(|x| x.unwrap_or(0)).collect()
}),
Type::INT4_ARRAY => row
.try_get::<_, Option<Vec<Option<i32>>>>(col.idx)
.ok()
.flatten()
.map(|v| {
count_nulls(v.iter().filter(|x| x.is_none()).count());
v.into_iter().map(|x| x.map(i64::from).unwrap_or(0)).collect()
}),
Type::TIMESTAMP_ARRAY => row
.try_get::<_, Option<Vec<Option<NaiveDateTime>>>>(col.idx)
.ok()
.flatten()
.map(|v| {
count_nulls(v.iter().filter(|x| x.is_none()).count());
v.into_iter()
.map(|x| x.map(|t| t.and_utc().timestamp_millis()).unwrap_or(0))
.collect()
}),
Type::TIMESTAMPTZ_ARRAY => row
.try_get::<_, Option<Vec<Option<DateTime<Utc>>>>>(col.idx)
.ok()
.flatten()
.map(|v| {
count_nulls(v.iter().filter(|x| x.is_none()).count());
v.into_iter()
.map(|x| x.map(|t| t.timestamp_millis()).unwrap_or(0))
.collect()
}),
Type::DATE_ARRAY => row
.try_get::<_, Option<Vec<Option<NaiveDate>>>>(col.idx)
.ok()
.flatten()
.map(|v| {
count_nulls(
v.iter()
.filter(|x| {
x.and_then(|d| d.and_hms_opt(0, 0, 0)).is_none()
})
.count(),
);
v.into_iter()
.map(|x| {
x.and_then(|d| d.and_hms_opt(0, 0, 0))
.map(|t| t.and_utc().timestamp_millis())
.unwrap_or(0)
})
.collect()
}),
_ => None,
};
match ms {
Some(v) => {
for &x in &v {
reject_negative_timestamp(row_no, x)?;
}
Ok(Some(v.into_iter().map(|x| x as u64).collect()))
}
None => Ok(None),
}
}
fn decode_f32_array(row: &Row, col: &TimeCol, coerced: &mut usize) -> Option<Vec<f32>> {
match col.ty {
Type::FLOAT8_ARRAY => row
.try_get::<_, Option<Vec<Option<f64>>>>(col.idx)
.ok()
.flatten()
.map(|v| {
*coerced += v.iter().filter(|x| x.is_none()).count();
v.into_iter()
.map(|x| x.map(|n| n as f32).unwrap_or(f32::NAN))
.collect()
}),
Type::FLOAT4_ARRAY => row
.try_get::<_, Option<Vec<Option<f32>>>>(col.idx)
.ok()
.flatten()
.map(|v| {
*coerced += v.iter().filter(|x| x.is_none()).count();
v.into_iter().map(|x| x.unwrap_or(f32::NAN)).collect()
}),
_ => None,
}
}
fn decode_property(row: &Row, col: &PropCol) -> Option<serde_json::Value> {
use serde_json::Value;
match col.ty {
Type::BOOL => row
.try_get::<_, Option<bool>>(col.idx)
.ok()
.flatten()
.map(Value::Bool),
Type::INT2 => row
.try_get::<_, Option<i16>>(col.idx)
.ok()
.flatten()
.map(|v| Value::from(v as i64)),
Type::INT4 => row
.try_get::<_, Option<i32>>(col.idx)
.ok()
.flatten()
.map(|v| Value::from(v as i64)),
Type::INT8 => row
.try_get::<_, Option<i64>>(col.idx)
.ok()
.flatten()
.map(Value::from),
Type::FLOAT4 => row
.try_get::<_, Option<f32>>(col.idx)
.ok()
.flatten()
.map(|v| json_number_or_null(v as f64)),
Type::FLOAT8 => row
.try_get::<_, Option<f64>>(col.idx)
.ok()
.flatten()
.map(json_number_or_null),
Type::NUMERIC => row
.try_get::<_, Option<rust_decimal::Decimal>>(col.idx)
.ok()
.flatten()
.map(|d| decimal_string_to_json(&d.to_string())),
Type::TEXT | Type::VARCHAR | Type::BPCHAR | Type::NAME => row
.try_get::<_, Option<String>>(col.idx)
.ok()
.flatten()
.map(Value::String),
Type::TIMESTAMPTZ => row
.try_get::<_, Option<DateTime<Utc>>>(col.idx)
.ok()
.flatten()
.map(|dt| Value::from(dt.timestamp_millis())),
Type::TIMESTAMP => row
.try_get::<_, Option<NaiveDateTime>>(col.idx)
.ok()
.flatten()
.map(|dt| Value::from(dt.and_utc().timestamp_millis())),
Type::DATE => row
.try_get::<_, Option<NaiveDate>>(col.idx)
.ok()
.flatten()
.and_then(|d| d.and_hms_opt(0, 0, 0))
.map(|dt| Value::from(dt.and_utc().timestamp_millis())),
Type::JSON | Type::JSONB => row.try_get::<_, Option<Value>>(col.idx).ok().flatten(),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wraps_table_query_with_ewkb_projection() {
let spec = QuerySpec {
source: QuerySource::Table("public.trips".into()),
geom_column: "geom".into(),
where_clause: None,
reproject_from_srid: None,
};
let q = spec.wrapped_query();
assert!(q.contains("ST_AsEWKB(q.\"geom\")"), "{q}");
assert!(q.contains(WKB_ALIAS), "{q}");
assert!(q.contains("FROM ( SELECT * FROM public.trips ) AS q"), "{q}");
}
#[test]
fn wraps_sql_with_reprojection_and_where() {
let spec = QuerySpec {
source: QuerySource::Sql("SELECT * FROM obs WHERE valid".into()),
geom_column: "the_geom".into(),
where_clause: Some("ts > now() - interval '1 day'".into()),
reproject_from_srid: Some(3857),
};
let q = spec.wrapped_query();
assert!(
q.contains("ST_AsEWKB(ST_Transform(ST_SetSRID(q.\"the_geom\", 3857), 4326))"),
"{q}"
);
assert!(q.contains("WHERE ts > now()"), "{q}");
}
#[test]
fn quotes_identifiers_safely() {
assert_eq!(QuerySpec::quote_ident("geom"), "\"geom\"");
assert_eq!(QuerySpec::quote_ident("we\"ird"), "\"we\"\"ird\"");
}
#[test]
fn tile_query_time_filter_honors_time_format() {
let bbox = [-10.0, -5.0, 10.0, 5.0];
let iso =
build_tile_query("obs", "geom", "ts", TimeFormat::Iso8601, bbox, 1000, 2000, None);
assert!(iso.contains("to_timestamp(1000::double precision / 1000.0)"), "{iso}");
assert!(iso.contains("to_timestamp(2000::double precision / 1000.0)"), "{iso}");
let ms = build_tile_query("obs", "geom", "ts", TimeFormat::UnixMs, bbox, 1000, 2000, None);
assert!(ms.contains("q.\"ts\" >= 1000 AND q.\"ts\" < 2000"), "{ms}");
assert!(!ms.contains("to_timestamp"), "{ms}");
let sec =
build_tile_query("obs", "geom", "ts", TimeFormat::UnixSec, bbox, 1000, 2000, None);
assert!(sec.contains("q.\"ts\" >= 1 AND q.\"ts\" < 2"), "{sec}");
assert!(!sec.contains("* 1000"), "{sec}");
let odd = pg_time_window("t", TimeFormat::UnixSec, 1500, 2001);
assert_eq!(odd, "t >= 2 AND t < 3");
}
#[test]
fn tile_query_reprojects_when_source_srid_given() {
let bbox = [-10.0, -5.0, 10.0, 5.0];
let q = build_tile_query(
"obs",
"geom",
"ts",
TimeFormat::Iso8601,
bbox,
1000,
2000,
Some(3857),
);
assert!(
q.contains("ST_AsEWKB(ST_Transform(ST_SetSRID(q.\"geom\", 3857), 4326))"),
"{q}"
);
assert!(
q.contains(
"ST_Transform(ST_SetSRID(q.\"geom\", 3857), 4326) && \
ST_MakeEnvelope(-10, -5, 10, 5, 4326)"
),
"{q}"
);
}
#[test]
fn property_kind_pg_classifies_numeric_types() {
use crate::columnar::PropertyKind;
for ty in [
Type::INT2,
Type::INT4,
Type::INT8,
Type::FLOAT4,
Type::FLOAT8,
Type::NUMERIC,
Type::TIMESTAMP,
Type::TIMESTAMPTZ,
Type::DATE,
] {
assert_eq!(
property_kind_for_pg(&ty),
Some(PropertyKind::Numeric),
"{ty} must classify as Numeric"
);
}
}
#[test]
fn property_kind_pg_classifies_categorical_types() {
use crate::columnar::PropertyKind;
for ty in [
Type::BOOL,
Type::TEXT,
Type::VARCHAR,
Type::BPCHAR,
Type::NAME,
] {
assert_eq!(
property_kind_for_pg(&ty),
Some(PropertyKind::Categorical),
"{ty} must classify as Categorical"
);
}
}
#[test]
fn property_kind_pg_drops_unmappable_and_nested() {
for ty in [
Type::JSON,
Type::JSONB,
Type::UUID,
Type::BYTEA,
Type::INT4_ARRAY,
Type::INT8_ARRAY,
Type::FLOAT8_ARRAY,
Type::TIMESTAMP_ARRAY,
Type::DATE_ARRAY,
] {
assert_eq!(property_kind_for_pg(&ty), None, "{ty} must be dropped (None)");
}
}
}