use cratestack::sqlx::query;
use cratestack::{CoolContext, Migration, Value, apply_pending, include_server_schema};
use cratestack_migrate::diff;
use cratestack_migrate::emit::postgres;
use cratestack_parser::{parse_schema, parse_schema_file};
include_server_schema!("tests/fixtures/migrate_enum_storage.cstack", db = Postgres);
use cratestack_schema::PrincipalType;
mod support;
use support::pg;
const FIXTURE: &str = "tests/fixtures/migrate_enum_storage.cstack";
const SCHEMA_V1: &str = r#"
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
auth Operator {
id Int
}
enum PrincipalType {
Device
Person
}
model Principal {
id Int @id
kind PrincipalType @default(Device)
role PrincipalType
fallback PrincipalType?
@@allow("read", auth() != null)
@@allow("create", auth() != null)
@@allow("update", auth() != null)
@@allow("delete", auth() != null)
}
"#;
fn ctx() -> CoolContext {
CoolContext::authenticated([("id".to_owned(), Value::Int(1))])
}
async fn reset(pool: &cratestack::sqlx::PgPool) {
query("DROP TABLE IF EXISTS cratestack_migrations, principals")
.execute(pool)
.await
.expect("drop");
query("DROP TYPE IF EXISTS principal_type")
.execute(pool)
.await
.expect("drop legacy enum type");
}
#[tokio::test]
async fn enum_columns_round_trip_through_the_generated_decoder() {
let _guard = pg::serial_guard().await;
let Some(test_pg) = pg::connect_or_skip().await else {
return;
};
let pool = &test_pg.pool;
reset(pool).await;
let empty = parse_schema("").expect("empty schema should parse");
let v1 = parse_schema(SCHEMA_V1).expect("v1 schema should parse");
let v2 = parse_schema_file(FIXTURE).expect("fixture schema should parse");
let initial = postgres::emit(&diff(&empty, &v1));
let add_variant = postgres::emit(&diff(&v1, &v2));
assert!(
!initial.up.contains("CREATE TYPE"),
"enum columns must not be typed as a native Postgres enum; up was: {}",
initial.up
);
assert!(
initial.up.contains("kind TEXT NOT NULL"),
"up was: {}",
initial.up
);
assert!(
initial.up.contains("DEFAULT 'Device'"),
"up was: {}",
initial.up
);
assert!(
!initial.up.contains("DEFAULT Device"),
"an unquoted bareword default parses as a column reference; up was: {}",
initial.up
);
assert!(
!add_variant.up.contains("ALTER TYPE"),
"up was: {}",
add_variant.up
);
assert!(
add_variant
.up
.contains("ADD CONSTRAINT principals_role_enum_check"),
"adding a variant must rebuild the membership CHECK; up was: {}",
add_variant.up
);
apply_pending(
pool,
&[
Migration {
id: "20260730000001_principals_init".to_owned(),
description: "principals with enum columns".to_owned(),
up: initial.up.clone(),
down: None,
},
Migration {
id: "20260730000002_principals_add_service".to_owned(),
description: "add the Service variant".to_owned(),
up: add_variant.up.clone(),
down: None,
},
],
)
.await
.expect("emitted DDL must apply cleanly against real Postgres");
let cool = cratestack_schema::Cratestack::builder(pool.clone()).build();
let created = cool
.principal()
.create(cratestack_schema::CreatePrincipalInput {
id: 1,
role: PrincipalType::Service,
fallback: Some(PrincipalType::Person),
})
.run(&ctx())
.await
.expect("create must round-trip the enum columns");
assert_eq!(created.role, PrincipalType::Service);
assert_eq!(created.fallback, Some(PrincipalType::Person));
assert_eq!(created.kind, PrincipalType::Device);
let found = cool
.principal()
.find_unique(1)
.run(&ctx())
.await
.expect("read must decode the enum columns")
.expect("row should exist");
assert_eq!(found.role, PrincipalType::Service);
assert_eq!(found.kind, PrincipalType::Device);
assert_eq!(found.fallback, Some(PrincipalType::Person));
let with_null = cool
.principal()
.create(cratestack_schema::CreatePrincipalInput {
id: 2,
role: PrincipalType::Device,
fallback: None,
})
.run(&ctx())
.await
.expect("create with a NULL enum column");
assert_eq!(with_null.fallback, None);
}
#[tokio::test]
async fn membership_check_rejects_values_outside_the_variant_set() {
let _guard = pg::serial_guard().await;
let Some(test_pg) = pg::connect_or_skip().await else {
return;
};
let pool = &test_pg.pool;
reset(pool).await;
let empty = parse_schema("").expect("empty schema should parse");
let v2 = parse_schema_file(FIXTURE).expect("fixture schema should parse");
let migration = postgres::emit(&diff(&empty, &v2));
apply_pending(
pool,
&[Migration {
id: "20260730000001_principals_init".to_owned(),
description: "principals with enum columns".to_owned(),
up: migration.up.clone(),
down: None,
}],
)
.await
.expect("emitted DDL must apply cleanly");
let rejected = query(
"INSERT INTO principals (id, kind, role, fallback) \
VALUES (1, 'Device', 'Wanderer', NULL)",
)
.execute(pool)
.await;
let error = rejected.expect_err("a value outside the variant set must be rejected");
assert!(
error.to_string().contains("principals_role_enum_check"),
"expected the enum membership CHECK to fire, got: {error}",
);
query(
"INSERT INTO principals (id, kind, role, fallback) \
VALUES (3, 'Person', 'Service', 'Device')",
)
.execute(pool)
.await
.expect("a valid value must be accepted");
}