use faucet_core::{ColumnChange, SchemaEvolution, Sink};
use faucet_sink_postgres::{PostgresColumnMapping, PostgresSink, PostgresSinkConfig};
use serde_json::json;
use testcontainers::{ContainerAsync, ImageExt, runners::AsyncRunner};
use testcontainers_modules::postgres::Postgres;
async fn start_postgres() -> (ContainerAsync<Postgres>, String) {
let image = Postgres::default().with_tag("16-alpine");
let container: ContainerAsync<Postgres> =
image.start().await.expect("postgres container start");
let port = container
.get_host_port_ipv4(5432)
.await
.expect("postgres port");
let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres");
(container, url)
}
#[tokio::test(flavor = "multi_thread")]
async fn current_schema_then_evolve_add_and_widen_is_idempotent() {
let (_container, url) = start_postgres().await;
let pool = sqlx::PgPool::connect(&url).await.expect("pool connect");
sqlx::query("CREATE TABLE t (id bigint)")
.execute(&pool)
.await
.expect("create table");
pool.close().await;
let sink = PostgresSink::new(
PostgresSinkConfig::new(&url, "t").column_mapping(PostgresColumnMapping::AutoMap),
)
.await
.expect("sink new");
let schema = sink
.current_schema()
.await
.expect("current_schema")
.expect("table exists");
let props = schema
.get("properties")
.and_then(|p| p.as_object())
.expect("properties object");
assert_eq!(
props.get("id"),
Some(&json!({ "type": ["integer", "null"] })),
"id bigint must surface as a nullable integer; got {schema:?}"
);
assert!(!props.contains_key("email"), "email must not exist yet");
let evolution = SchemaEvolution {
additions: vec![ColumnChange {
name: "email".into(),
from: None,
to: json!({ "type": "string" }),
}],
widenings: vec![ColumnChange {
name: "id".into(),
from: Some(json!({ "type": ["integer", "null"] })),
to: json!({ "type": ["number", "null"] }),
}],
relax_nullability: vec![],
};
sink.evolve_schema(&evolution).await.expect("evolve_schema");
let schema2 = sink
.current_schema()
.await
.expect("current_schema 2")
.expect("table still exists");
let props2 = schema2
.get("properties")
.and_then(|p| p.as_object())
.expect("properties object");
assert_eq!(
props2.get("email"),
Some(&json!({ "type": ["string", "null"] })),
"email text must now exist as a nullable string; got {schema2:?}"
);
assert_eq!(
props2.get("id"),
Some(&json!({ "type": ["number", "null"] })),
"id must have widened to a numeric type (double precision); got {schema2:?}"
);
sink.evolve_schema(&evolution)
.await
.expect("re-running the same evolution must be idempotent");
let schema3 = sink
.current_schema()
.await
.expect("current_schema 3")
.expect("table still exists");
let props3 = schema3
.get("properties")
.and_then(|p| p.as_object())
.expect("properties object");
assert_eq!(
props3.get("id"),
Some(&json!({ "type": ["number", "null"] })),
"id type must be stable after a repeated evolution"
);
assert_eq!(
props3.get("email"),
Some(&json!({ "type": ["string", "null"] })),
"email must still exist after a repeated evolution"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn current_schema_is_none_for_missing_table() {
let (_container, url) = start_postgres().await;
let sink = PostgresSink::new(
PostgresSinkConfig::new(&url, "does_not_exist")
.column_mapping(PostgresColumnMapping::AutoMap),
)
.await
.expect("sink new");
assert_eq!(
sink.current_schema().await.expect("current_schema"),
None,
"a missing table must report no schema"
);
}