mod common;
use common::*;
use pylon_core::diff::{Verb, diff_schema_steps, diff_schema_steps_with_renames_and_fills, schema_to_db_state};
use pylon_core::export::export_schema;
use pylon_core::query;
use pylon_core::schema::{SchemaDescriptor, SignalEntry, TypeDescriptor};
use pylon_pgcon::ExtensionOids;
use pylon_value::DecodedValue;
use std::collections::HashMap;
fn triggered_schema(module: &str) -> SchemaDescriptor {
let qname = format!("{module}::Widget");
SchemaDescriptor {
types: vec![TypeDescriptor {
name: "Widget".into(),
module: module.into(),
table: "Widget".into(),
abstract_: false,
materialized: true,
description: None,
parents: vec![],
interfaces: vec![],
bases: vec![],
properties: vec![id_prop(), text_prop("name")],
links: vec![],
multilinks: vec![multilink("related", &qname)],
computed: vec![],
constraints: vec![],
indexes: vec![],
partition: None,
vector_indexes: vec![],
search_indexes: vec![],
triggers: vec![],
junction: false,
signals: vec![SignalEntry { on: 1 | 2 | 4 }],
}],
..Default::default()
}
}
#[tokio::test]
#[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
async fn phantom_trigger_regression_second_create_reports_zero_changes() {
let module = unique_module("live_migdiff_offline");
let schema = triggered_schema(&module);
let empty = pylon_core::diff::DbState::default();
let steps = diff_schema_steps(&schema, &empty, &HashMap::new()).unwrap();
let pool = test_pool().await;
pool.batch_execute(&pylon_core::stdlib::export_stdlib()).await.unwrap();
for step in &steps {
for op in step.resolved_ddl(&HashMap::new()) {
pool.batch_execute(&op.sql).await.unwrap();
}
}
let baseline = schema_to_db_state(&schema);
let further = diff_schema_steps(&schema, &baseline, &HashMap::new()).unwrap();
assert!(
further.is_empty(),
"expected zero further migration steps against the offline baseline, got: {further:?}"
);
pool.batch_execute(&format!("DROP SCHEMA IF EXISTS \"{module}\" CASCADE;"))
.await
.unwrap();
}
#[tokio::test]
#[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
async fn zero_changes_against_live_introspection_after_apply() {
let module = unique_module("live_migdiff_introspect");
let schema = triggered_schema(&module);
let ddl = export_schema(&schema).unwrap();
let pool = test_pool().await;
pool.batch_execute(&pylon_core::stdlib::export_stdlib()).await.unwrap();
pool.batch_execute(&ddl).await.unwrap();
assert_zero_further_steps(&pool, &schema).await;
pool.batch_execute(&format!("DROP SCHEMA IF EXISTS \"{module}\" CASCADE;"))
.await
.unwrap();
}
#[tokio::test]
#[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
async fn rename_detected_and_applied_survives_real_data() {
let module = unique_module("live_migdiff_rename");
let v1 = SchemaDescriptor {
types: vec![TypeDescriptor {
name: "Widget".into(),
module: module.clone(),
table: "Widget".into(),
abstract_: false,
materialized: true,
description: None,
parents: vec![],
interfaces: vec![],
bases: vec![],
properties: vec![id_prop(), text_prop("name")],
links: vec![],
multilinks: vec![],
computed: vec![],
constraints: vec![],
indexes: vec![],
partition: None,
vector_indexes: vec![],
search_indexes: vec![],
triggers: vec![],
junction: false,
signals: vec![],
}],
..Default::default()
};
let ddl = export_schema(&v1).unwrap();
let pool = test_pool().await;
pool.batch_execute(&pylon_core::stdlib::export_stdlib()).await.unwrap();
pool.batch_execute(&ddl).await.unwrap();
let insert = query::compile(&format!("insert {module}::Widget {{ name := 'keep-me' }}"), &v1).unwrap();
assert_eq!(pool.execute_typed(&insert.sql, &[]).await.unwrap(), 1);
let mut v2 = v1.clone();
v2.types[0].name = "Gadget".into();
v2.types[0].table = "Gadget".into();
let live = pylon_core::introspect::introspect_db_state(&pool).await.unwrap();
let type_renames = vec![(
module.clone(),
"Widget".to_string(),
module.clone(),
"Gadget".to_string(),
)];
pool.batch_execute(&format!(r#"ALTER TABLE "{module}"."Widget" RENAME TO "Gadget";"#))
.await
.unwrap();
let steps = diff_schema_steps_with_renames_and_fills(&v2, &live, &type_renames, &[], &[]).unwrap();
for step in &steps {
for op in step.resolved_ddl(&HashMap::new()) {
pool.batch_execute(&op.sql).await.unwrap();
}
}
let select = query::compile(
&format!("select {module}::Gadget {{ name }} filter .name = 'keep-me'"),
&v2,
)
.unwrap();
let rows = pool
.query_typed(&select.sql, &[], &ExtensionOids::default())
.await
.unwrap();
assert_eq!(
rows.len(),
1,
"renamed row should still be there with its original data, got {rows:?}"
);
match &rows[0] {
DecodedValue::Composite(fields) => {
assert_eq!(fields.get(2), Some(&DecodedValue::Str("keep-me".to_string())));
}
other => panic!("expected a Composite-shaped row, got {other:?}"),
}
pool.batch_execute(&format!("DROP SCHEMA IF EXISTS \"{module}\" CASCADE;"))
.await
.unwrap();
}
#[tokio::test]
#[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
async fn property_type_change_casts_existing_data() {
let module = unique_module("live_migdiff_cast");
let v1 = SchemaDescriptor {
types: vec![TypeDescriptor {
name: "Widget".into(),
module: module.clone(),
table: "Widget".into(),
abstract_: false,
materialized: true,
description: None,
parents: vec![],
interfaces: vec![],
bases: vec![],
properties: vec![id_prop(), text_prop("code")],
links: vec![],
multilinks: vec![],
computed: vec![],
constraints: vec![],
indexes: vec![],
partition: None,
vector_indexes: vec![],
search_indexes: vec![],
triggers: vec![],
junction: false,
signals: vec![],
}],
..Default::default()
};
let ddl = export_schema(&v1).unwrap();
let pool = test_pool().await;
pool.batch_execute(&pylon_core::stdlib::export_stdlib()).await.unwrap();
pool.batch_execute(&ddl).await.unwrap();
let insert = query::compile(&format!("insert {module}::Widget {{ code := '42' }}"), &v1).unwrap();
assert_eq!(pool.execute_typed(&insert.sql, &[]).await.unwrap(), 1);
let mut v2 = v1.clone();
v2.types[0].properties[1].pg_type = "int8".into();
let live = pylon_core::introspect::introspect_db_state(&pool).await.unwrap();
let steps = diff_schema_steps(&v2, &live, &HashMap::new()).unwrap();
let table_step = steps
.iter()
.find(|s| s.verb == Verb::Alter && s.object_desc.contains("Widget"))
.expect("expected an alter step for Widget's type change");
assert!(
!table_step.required_input.is_empty(),
"type change should carry a required_input cast expression"
);
for op in table_step.resolved_ddl(&HashMap::new()) {
pool.batch_execute(&op.sql).await.unwrap();
}
let select = query::compile(&format!("select {module}::Widget {{ code }} filter .code = 42"), &v2).unwrap();
let rows = pool
.query_typed(&select.sql, &[], &ExtensionOids::default())
.await
.unwrap();
assert_eq!(
rows.len(),
1,
"existing row should have survived the cast with the correct value, got {rows:?}"
);
match &rows[0] {
DecodedValue::Composite(fields) => {
assert_eq!(fields.get(2), Some(&DecodedValue::I64(42)));
}
other => panic!("expected a Composite-shaped row, got {other:?}"),
}
pool.batch_execute(&format!("DROP SCHEMA IF EXISTS \"{module}\" CASCADE;"))
.await
.unwrap();
}