#![allow(clippy::disallowed_methods)]
#[cfg(any(test, feature = "testing"))]
pub mod cli;
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum DerivedParityError {
#[error("derived field parity drift in `{visage}` at field `{field}`")]
Drift {
visage: &'static str,
field: &'static str,
},
#[error("derived parity fetch failed: {source}")]
Fetch {
#[source]
source: DjogiError,
},
}
pub trait DerivedParity: private::DerivedParitySealed {
fn assert_derived_parity(&self, other: &Self) -> Result<(), DerivedParityError>;
}
#[doc(hidden)]
pub mod private {
pub trait DerivedParitySealed {}
}
pub async fn assert_derived_parity_fetched<V, Fetch, Fut>(
in_memory: &V,
fetch: Fetch,
) -> Result<(), DerivedParityError>
where
V: DerivedParity,
Fetch: FnOnce() -> Fut,
Fut: std::future::Future<Output = crate::Result<V>>,
{
let from_db = fetch()
.await
.map_err(|source| DerivedParityError::Fetch { source })?;
in_memory.assert_derived_parity(&from_db)
}
use std::collections::{BTreeMap, BTreeSet};
use crate::__bypass::RawAccessExt as _;
use crate::apps::AppRegistry;
use crate::descriptor::{EnumDescriptor, ModelDescriptor};
use crate::migrate::diff::{Classification, SchemaOperation};
use crate::migrate::projection::{BucketKey, project_from_iters, rfc3339_now_seconds};
use crate::migrate::schema::{AppliedSchema, SNAPSHOT_FORMAT_VERSION};
use crate::migrate::{MigrationPlan, SegmentKind, diff_bucket_maps, plan_delta};
use crate::pg::pool::DjogiPool;
use crate::{DbError, DjogiContext, DjogiError};
use tokio_postgres::NoTls;
use uuid::Uuid;
pub struct TestDbCleanup {
admin_url: String,
db_name: String,
}
impl TestDbCleanup {
pub fn db_name(&self) -> &str {
&self.db_name
}
pub fn test_url(&self) -> Result<String, DjogiError> {
replace_db_in_url(&self.admin_url, &self.db_name)
}
}
pub async fn setup_test_db() -> Result<(TestDbCleanup, DjogiContext), DjogiError> {
setup_test_db_with_extensions(&[]).await
}
pub async fn setup_test_db_with_extensions(
extensions: &[&str],
) -> Result<(TestDbCleanup, DjogiContext), DjogiError> {
for name in extensions {
validate_extension_name(name)?;
}
let database_url = std::env::var("DATABASE_URL").map_err(|_| {
DjogiError::Db(DbError::other(
"DATABASE_URL env var is not set; djogi_test requires it to connect to Postgres",
))
})?;
let (admin_client, admin_conn) = tokio_postgres::connect(&database_url, NoTls)
.await
.map_err(|e| DjogiError::Db(DbError::other(format!("admin connect failed: {e}"))))?;
tokio::spawn(async move {
if let Err(e) = admin_conn.await {
eprintln!("[djogi_test] admin connection error: {e}");
}
});
let unique_suffix = Uuid::new_v4().simple().to_string();
let db_name = format!("djogi_test_{unique_suffix}");
let create_sql = format!("CREATE DATABASE {}", quoted(&db_name));
admin_client
.batch_execute(&create_sql)
.await
.map_err(|e| DjogiError::Db(DbError::other(format!("CREATE DATABASE failed: {e}"))))?;
let test_url = replace_db_in_url(&database_url, &db_name)?;
drop(admin_client);
let (test_client, test_conn) = tokio_postgres::connect(&test_url, NoTls)
.await
.map_err(|e| DjogiError::Db(DbError::other(format!("test DB connect failed: {e}"))))?;
tokio::spawn(async move {
if let Err(e) = test_conn.await {
eprintln!("[djogi_test] test connection error: {e}");
}
});
let extension_set: std::collections::BTreeSet<String> =
extensions.iter().map(|s| s.to_string()).collect();
crate::migrate::bootstrap::run_phase_zero(
&test_client,
&db_name,
&extension_set,
crate::migrate::bootstrap::DEFAULT_NODE_ID,
)
.await
.map_err(|e| DjogiError::Db(DbError::other(format!("phase 0 bootstrap failed: {e}"))))?;
drop(test_client);
let app_pool = DjogiPool::connect(&test_url).await?;
let ctx = DjogiContext::from_pool(app_pool);
let cleanup = TestDbCleanup {
admin_url: database_url,
db_name,
};
Ok((cleanup, ctx))
}
pub async fn teardown_test_db(cleanup: TestDbCleanup) {
let TestDbCleanup { admin_url, db_name } = cleanup;
match tokio_postgres::connect(&admin_url, NoTls).await {
Err(e) => {
eprintln!(
"[djogi_test] WARNING: failed to connect to admin DB for teardown \
(database \"{db_name}\" may need manual cleanup): {e}"
);
}
Ok((admin_client, admin_conn)) => {
tokio::spawn(async move {
if let Err(e) = admin_conn.await {
eprintln!("[djogi_test] teardown connection error: {e}");
}
});
let sql = format!("DROP DATABASE IF EXISTS {} WITH (FORCE)", quoted(&db_name));
if let Err(e) = admin_client.batch_execute(&sql).await {
eprintln!("[djogi_test] WARNING: failed to drop test database \"{db_name}\": {e}");
}
}
}
}
pub const TEST_NON_SUPERUSER_ROLE: &str = "djogi_test_user";
pub const TEST_NON_SUPERUSER_PASSWORD: &str = "djogi_test_user";
pub async fn connect_test_db_as_non_superuser(
cleanup: &TestDbCleanup,
) -> Result<DjogiContext, DjogiError> {
let (admin_client, admin_conn) = tokio_postgres::connect(&cleanup.admin_url, NoTls)
.await
.map_err(|e| {
DjogiError::Db(DbError::other(format!(
"non-superuser admin connect failed: {e}"
)))
})?;
tokio::spawn(async move {
if let Err(e) = admin_conn.await {
eprintln!("[djogi_test] non-superuser admin connection error: {e}");
}
});
let role_exists: bool = admin_client
.query_one(
"SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)",
&[&TEST_NON_SUPERUSER_ROLE],
)
.await
.map_err(|e| {
DjogiError::Db(DbError::other(format!(
"non-superuser role lookup failed: {e}"
)))
})?
.get(0);
let role_quoted = quoted(TEST_NON_SUPERUSER_ROLE);
let password_literal = sql_string_literal(TEST_NON_SUPERUSER_PASSWORD);
let role_sql = if role_exists {
format!(
"ALTER ROLE {role_quoted} WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE \
NOREPLICATION NOBYPASSRLS PASSWORD {password_literal}"
)
} else {
format!(
"CREATE ROLE {role_quoted} WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE \
NOREPLICATION NOBYPASSRLS PASSWORD {password_literal}"
)
};
admin_client.batch_execute(&role_sql).await.map_err(|e| {
DjogiError::Db(DbError::other(format!(
"non-superuser role provisioning failed: {e}"
)))
})?;
drop(admin_client);
let test_admin_url = replace_db_in_url(&cleanup.admin_url, &cleanup.db_name)?;
let (test_admin_client, test_admin_conn) = tokio_postgres::connect(&test_admin_url, NoTls)
.await
.map_err(|e| {
DjogiError::Db(DbError::other(format!(
"non-superuser test-db admin connect failed: {e}"
)))
})?;
tokio::spawn(async move {
if let Err(e) = test_admin_conn.await {
eprintln!("[djogi_test] non-superuser test-db admin connection error: {e}");
}
});
let db_quoted = quoted(&cleanup.db_name);
let grants_sql = format!(
"GRANT CONNECT ON DATABASE {db_quoted} TO {role_quoted};\n\
GRANT USAGE ON SCHEMA public TO {role_quoted};\n\
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO {role_quoted};\n\
GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO {role_quoted};\n\
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO {role_quoted};"
);
test_admin_client
.batch_execute(&grants_sql)
.await
.map_err(|e| DjogiError::Db(DbError::other(format!("non-superuser grants failed: {e}"))))?;
drop(test_admin_client);
let non_super_url = build_non_superuser_url(&cleanup.admin_url, &cleanup.db_name)?;
let pool = DjogiPool::connect(&non_super_url).await?;
Ok(DjogiContext::from_pool(pool))
}
fn build_non_superuser_url(admin_url: &str, db_name: &str) -> Result<String, DjogiError> {
let body = admin_url
.strip_prefix("postgres://")
.or_else(|| admin_url.strip_prefix("postgresql://"))
.ok_or_else(|| {
DjogiError::Db(DbError::other(
"DATABASE_URL must start with `postgres://` or `postgresql://` to derive \
the non-superuser test URL",
))
})?;
let scheme = if admin_url.starts_with("postgres://") {
"postgres://"
} else {
"postgresql://"
};
let body_bytes = body.as_bytes();
let mut idx = 0usize;
while idx < body_bytes.len() && body_bytes[idx] != b'/' {
idx += 1;
}
if idx >= body_bytes.len() {
return Err(DjogiError::Db(DbError::other(
"DATABASE_URL does not contain a database name component; \
cannot derive the non-superuser test URL",
)));
}
let authority = &body[..idx];
let host_and_port = match authority.rfind('@') {
Some(at) => &authority[at + 1..],
None => authority,
};
let path_start = idx + 1;
let mut path_end = path_start;
while path_end < body_bytes.len() && body_bytes[path_end] != b'?' {
path_end += 1;
}
let trailing = &body[path_end..]; reject_identity_overriding_query_params(trailing)?;
Ok(format!(
"{scheme}{role}:{password}@{host_and_port}/{db_name}{trailing}",
role = TEST_NON_SUPERUSER_ROLE,
password = TEST_NON_SUPERUSER_PASSWORD,
))
}
fn reject_identity_overriding_query_params(query_with_marker: &str) -> Result<(), DjogiError> {
let Some(query) = query_with_marker.strip_prefix('?') else {
return Ok(());
};
for pair in query.split('&') {
let key = pair.split_once('=').map_or(pair, |(key, _)| key);
let key = percent_decode_query_key(key)?;
if matches!(key.as_str(), "user" | "password" | "dbname") {
return Err(DjogiError::Db(DbError::other(format!(
"DATABASE_URL query parameter `{key}` would override the non-superuser \
test role or per-test database; remove it before calling \
connect_test_db_as_non_superuser",
))));
}
}
Ok(())
}
fn percent_decode_query_key(key: &str) -> Result<String, DjogiError> {
let mut out = Vec::with_capacity(key.len());
let bytes = key.as_bytes();
let mut index = 0usize;
while index < bytes.len() {
if bytes[index] == b'%' {
if index + 2 >= bytes.len() {
return Err(DjogiError::Db(DbError::other(
"DATABASE_URL query parameter contains an incomplete percent escape",
)));
}
let hi = hex_value(bytes[index + 1]).ok_or_else(|| {
DjogiError::Db(DbError::other(
"DATABASE_URL query parameter contains an invalid percent escape",
))
})?;
let lo = hex_value(bytes[index + 2]).ok_or_else(|| {
DjogiError::Db(DbError::other(
"DATABASE_URL query parameter contains an invalid percent escape",
))
})?;
out.push((hi << 4) | lo);
index += 3;
} else {
out.push(bytes[index]);
index += 1;
}
}
String::from_utf8(out).map_err(|e| {
DjogiError::Db(DbError::other(format!(
"DATABASE_URL query parameter key is not valid UTF-8 after percent decoding: {e}",
)))
})
}
fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
fn sql_string_literal(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('\'');
for ch in value.chars() {
if ch == '\'' {
out.push('\'');
out.push('\'');
} else {
out.push(ch);
}
}
out.push('\'');
out
}
pub async fn list_orphaned_test_databases(admin_url: &str) -> Result<Vec<String>, DjogiError> {
let (client, conn) = tokio_postgres::connect(admin_url, NoTls)
.await
.map_err(|e| DjogiError::Db(DbError::other(format!("admin connect failed: {e}"))))?;
tokio::spawn(async move {
if let Err(e) = conn.await {
eprintln!("[djogi_test] list connection error: {e}");
}
});
let rows = client
.query(
"SELECT datname FROM pg_database \
WHERE datname LIKE 'djogi\\_test\\_%' ESCAPE '\\' \
ORDER BY datname",
&[],
)
.await
.map_err(|e| {
DjogiError::Db(DbError::other(format!(
"pg_database query failed during orphan listing: {e}"
)))
})?;
let mut names = Vec::with_capacity(rows.len());
for row in &rows {
match row.try_get::<_, String>(0) {
Ok(name) => names.push(name),
Err(e) => {
eprintln!(
"[djogi_test] WARNING: failed to decode datname during orphan listing: {e}"
);
}
}
}
Ok(names)
}
pub async fn cleanup_orphaned_test_databases(admin_url: &str) -> Result<Vec<String>, DjogiError> {
let (client, conn) = tokio_postgres::connect(admin_url, NoTls)
.await
.map_err(|e| DjogiError::Db(DbError::other(format!("admin connect failed: {e}"))))?;
tokio::spawn(async move {
if let Err(e) = conn.await {
eprintln!("[djogi_test] cleanup connection error: {e}");
}
});
let rows = client
.query(
"SELECT datname FROM pg_database \
WHERE datname LIKE 'djogi\\_test\\_%' ESCAPE '\\' \
ORDER BY datname",
&[],
)
.await
.map_err(|e| {
DjogiError::Db(DbError::other(format!(
"pg_database query failed during orphan cleanup: {e}"
)))
})?;
let mut dropped = Vec::with_capacity(rows.len());
for row in &rows {
let datname: String = match row.try_get(0) {
Ok(s) => s,
Err(e) => {
eprintln!(
"[djogi_test] WARNING: failed to decode datname during orphan cleanup: {e}"
);
continue;
}
};
let sql = format!("DROP DATABASE IF EXISTS {} WITH (FORCE)", quoted(&datname));
match client.batch_execute(&sql).await {
Ok(()) => dropped.push(datname),
Err(e) => {
eprintln!(
"[djogi_test] WARNING: failed to drop orphaned test database \
\"{datname}\": {e}"
);
}
}
}
Ok(dropped)
}
fn quoted(ident: &str) -> String {
let mut out = String::with_capacity(ident.len() + 2);
out.push('"');
for b in ident.bytes() {
if b == b'"' {
out.push('"');
out.push('"');
} else {
out.push(b as char);
}
}
out.push('"');
out
}
fn validate_extension_name(name: &str) -> Result<(), DjogiError> {
let bytes = name.as_bytes();
if bytes.is_empty() {
return Err(DjogiError::Db(DbError::other(
"djogi_test: extension name must not be empty",
)));
}
if bytes.len() > 63 {
return Err(DjogiError::Db(DbError::other(format!(
"djogi_test: extension name `{name}` exceeds 63-byte Postgres identifier limit",
))));
}
let first = bytes[0];
let first_ok = first.is_ascii_alphabetic() || first == b'_';
if !first_ok {
return Err(DjogiError::Db(DbError::other(format!(
"djogi_test: extension name `{name}` must start with an ASCII letter or underscore",
))));
}
for &b in &bytes[1..] {
let ok = b.is_ascii_alphanumeric() || b == b'_';
if !ok {
return Err(DjogiError::Db(DbError::other(format!(
"djogi_test: extension name `{name}` contains invalid byte `{c}` \
(only ASCII letters, digits, and underscores are allowed)",
c = b as char,
))));
}
}
Ok(())
}
fn replace_db_in_url(url: &str, new_db: &str) -> Result<String, DjogiError> {
crate::migrate::reset::replace_db_in_url(url, new_db).ok_or_else(|| {
DjogiError::Db(DbError::other(
"DATABASE_URL does not contain a database name component; \
cannot derive the per-test database URL",
))
})
}
pub async fn sync_models(
ctx: &mut DjogiContext,
descriptors: &[&'static ModelDescriptor],
) -> Result<(), DjogiError> {
let plans = build_sync_plans(descriptors)?;
for plan in &plans {
execute_plan(ctx, plan).await?;
}
Ok(())
}
#[doc(hidden)]
pub async fn install_accounts_balance_increment_trigger_for_test(
ctx: &mut DjogiContext,
) -> Result<(), DjogiError> {
ctx.raw_ddl(
"CREATE OR REPLACE FUNCTION accounts_balance_increment_trigger() \
RETURNS trigger AS $$ \
BEGIN NEW.balance := NEW.balance + 1; RETURN NEW; END; \
$$ LANGUAGE plpgsql;",
)
.await?;
ctx.raw_ddl("DROP TRIGGER IF EXISTS t_accounts_balance_increment ON accounts;")
.await?;
ctx.raw_ddl(
"CREATE TRIGGER t_accounts_balance_increment \
BEFORE UPDATE ON accounts \
FOR EACH ROW EXECUTE FUNCTION accounts_balance_increment_trigger();",
)
.await
}
#[cfg(feature = "hmac-codec")]
#[doc(hidden)]
pub unsafe fn install_presentation_hmac_key_for_testing(key: &str) {
#[allow(unsafe_code)]
unsafe {
std::env::set_var("DJOGI_PRESENTATION_HMAC_KEY", key);
}
crate::presentation::validate_startup_inventory()
.expect("install_presentation_hmac_key_for_testing: key validation failed");
}
#[derive(Debug, Clone)]
#[doc(hidden)]
pub struct OutboxRowForTest {
pub id: crate::types::HeerId,
pub row_id: String,
pub action: String,
pub payload: serde_json::Value,
pub state: String,
}
fn validate_outbox_table_for_test(table: &str) -> Result<(), DjogiError> {
crate::ident::check_user_supplied_ident(table, false).map_err(|error| {
DjogiError::Db(DbError::other(format!(
"invalid outbox table name {table:?}: {error:?}"
)))
})
}
#[doc(hidden)]
pub async fn outbox_rows_for_test(
ctx: &mut DjogiContext,
table: &str,
) -> Result<Vec<OutboxRowForTest>, DjogiError> {
validate_outbox_table_for_test(table)?;
let sql = format!(
"SELECT id, row_id::text, action, payload, state \
FROM {table} \
ORDER BY created_at, id"
);
let rows = ctx.raw_rows(&sql, &[]).await?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let id_raw: i64 = row
.try_get(0)
.map_err(|e| DjogiError::Db(DbError::other(format!("outbox id decode: {e}"))))?;
let id = crate::types::HeerId::from_i64(id_raw).map_err(|e| {
DjogiError::Db(DbError::other(format!(
"outbox id is not a valid HeerId {id_raw}: {e}"
)))
})?;
let row_id = row
.try_get(1)
.map_err(|e| DjogiError::Db(DbError::other(format!("outbox row_id decode: {e}"))))?;
let action = row
.try_get(2)
.map_err(|e| DjogiError::Db(DbError::other(format!("outbox action decode: {e}"))))?;
let payload = row
.try_get(3)
.map_err(|e| DjogiError::Db(DbError::other(format!("outbox payload decode: {e}"))))?;
let state = row
.try_get(4)
.map_err(|e| DjogiError::Db(DbError::other(format!("outbox state decode: {e}"))))?;
out.push(OutboxRowForTest {
id,
row_id,
action,
payload,
state,
});
}
Ok(out)
}
#[doc(hidden)]
pub async fn clear_outbox_for_test(ctx: &mut DjogiContext, table: &str) -> Result<(), DjogiError> {
validate_outbox_table_for_test(table)?;
ctx.raw_execute(&format!("DELETE FROM {table}"), &[])
.await?;
Ok(())
}
pub fn build_sync_plans(
descriptors: &[&'static ModelDescriptor],
) -> Result<Vec<MigrationPlan>, DjogiError> {
if descriptors.is_empty() {
return Ok(Vec::new());
}
wrap_relation_registry_for_sync_models(
crate::relation::registry::validate_global_relation_accessor_registry(),
)?;
let supplied_type_names: BTreeSet<&'static str> =
descriptors.iter().map(|d| d.type_name).collect();
for d in descriptors {
for f in d.fields {
if f.relation_kind.is_none() {
continue;
}
let Some(target) = f.target_type_name else {
return Err(DjogiError::Db(DbError::other(format!(
"sync_models: model `{src}`.`{col}` has relation_kind \
but no target_type_name — this is a macro emission bug; \
please file an issue",
src = d.type_name,
col = f.name,
))));
};
if !supplied_type_names.contains(target) {
return Err(DjogiError::Db(DbError::other(format!(
"sync_models: model `{src}`.`{col}` references `{tgt}` via foreign key, \
but `{tgt}` is not in sync_models. \
Add `{tgt}` to sync_models = [...] alongside `{src}`.",
src = d.type_name,
col = f.name,
tgt = target,
))));
}
}
}
let target = project_from_iters(
descriptors.iter().copied(),
inventory::iter::<EnumDescriptor>(),
AppRegistry::all().iter(),
rfc3339_now_seconds(),
)
.map_err(|e| {
DjogiError::Db(DbError::other(format!(
"sync_models: descriptor projection failed: {e:?}"
)))
})?;
let before: BTreeMap<BucketKey, AppliedSchema> = target
.keys()
.map(|bucket| (bucket.clone(), empty_applied_schema()))
.collect();
let deltas = diff_bucket_maps(&before, &target)
.map_err(|e| DjogiError::Db(DbError::other(format!("sync_models differ failed: {e}"))))?;
for delta in &deltas {
match delta.classification {
Classification::NoOp | Classification::Additive => {}
ref other => {
return Err(DjogiError::Db(DbError::other(format!(
"sync_models: internal invariant violated — empty-target diff classified as \
`{other:?}` for bucket `{db}/{app}`; expected NoOp or Additive only",
db = delta.bucket.database,
app = delta.bucket.app,
))));
}
}
for op in &delta.operations {
if !is_additive_op(op) {
return Err(DjogiError::Db(DbError::other(format!(
"sync_models: internal invariant violated — empty-target diff produced \
destructive op `{kind}` for bucket `{db}/{app}`",
kind = additive_op_label(op),
db = delta.bucket.database,
app = delta.bucket.app,
))));
}
}
}
let mut plans = Vec::new();
for delta in &deltas {
if matches!(delta.classification, Classification::NoOp) {
continue;
}
let plan = plan_delta(delta).map_err(|e| {
DjogiError::Db(DbError::other(format!(
"sync_models: SQL emission failed for bucket `{db}/{app}`: {e:?}",
db = delta.bucket.database,
app = delta.bucket.app,
)))
})?;
plans.push(plan);
}
Ok(plans)
}
fn wrap_relation_registry_for_sync_models(
result: Result<(), crate::relation::registry::RelationRegistryError>,
) -> Result<(), DjogiError> {
result.map_err(|e| {
DjogiError::Db(DbError::other(format!(
"sync_models: relation accessor registry contains cross-kind \
collisions (GH #158); fix the colliding relation macro declaration(s) \
before re-running sync_models:\n{e}"
)))
})
}
fn empty_applied_schema() -> AppliedSchema {
AppliedSchema {
djogi_version: env!("CARGO_PKG_VERSION").to_string(),
enums: BTreeMap::new(),
format_version: SNAPSHOT_FORMAT_VERSION.to_string(),
generated_at: rfc3339_now_seconds(),
indexes: Vec::new(),
models: BTreeMap::new(),
registered_apps: Vec::new(),
}
}
fn is_additive_op(op: &SchemaOperation) -> bool {
matches!(
op,
SchemaOperation::AddTable(_)
| SchemaOperation::AddColumn { .. }
| SchemaOperation::AddIndex(_)
| SchemaOperation::AddEnum(_)
| SchemaOperation::AddEnumVariant { .. }
| SchemaOperation::AddForeignKey { .. }
)
}
fn additive_op_label(op: &SchemaOperation) -> &'static str {
match op {
SchemaOperation::AddTable(_) => "AddTable",
SchemaOperation::DropTable(_) => "DropTable",
SchemaOperation::RenameTable { .. } => "RenameTable",
SchemaOperation::AddColumn { .. } => "AddColumn",
SchemaOperation::DropColumn { .. } => "DropColumn",
SchemaOperation::RenameColumn { .. } => "RenameColumn",
SchemaOperation::AlterColumn { .. } => "AlterColumn",
SchemaOperation::AddForeignKey { .. } => "AddForeignKey",
SchemaOperation::DropForeignKey { .. } => "DropForeignKey",
SchemaOperation::AddIndex(_) => "AddIndex",
SchemaOperation::DropIndex(_) => "DropIndex",
SchemaOperation::AddExclusionConstraint { .. } => "AddExclusionConstraint",
SchemaOperation::DropExclusionConstraint { .. } => "DropExclusionConstraint",
SchemaOperation::SetTableComment { .. } => "SetTableComment",
SchemaOperation::SetStorageParams { .. } => "SetStorageParams",
SchemaOperation::SetTablespace { .. } => "SetTablespace",
SchemaOperation::AddEnum(_) => "AddEnum",
SchemaOperation::DropEnum(_) => "DropEnum",
SchemaOperation::AddEnumVariant { .. } => "AddEnumVariant",
SchemaOperation::RenameApp { .. } => "RenameApp",
SchemaOperation::MoveModelBetweenApps { .. } => "MoveModelBetweenApps",
SchemaOperation::PkTypeFlip { .. } => "PkTypeFlip",
SchemaOperation::PkTypeFlipGroup(_) => "PkTypeFlipGroup",
SchemaOperation::PkTypeFlipMultiGroup(_) => "PkTypeFlipMultiGroup",
SchemaOperation::Unsupported { .. } => "Unsupported",
}
}
async fn execute_plan(
ctx: &mut DjogiContext,
plan: &crate::migrate::MigrationPlan,
) -> Result<(), DjogiError> {
for segment in &plan.segments {
if matches!(segment.kind, SegmentKind::MetadataOnly) {
continue;
}
for op_sql in &segment.statements {
ctx.raw_ddl(&op_sql.up).await.map_err(|e| {
DjogiError::Db(DbError::other(format!(
"sync_models: failed to apply `{label}` on bucket `{db}/{app}`: {e}\n\
-- offending SQL --\n{sql}",
label = op_sql.label,
db = plan.bucket.database,
app = plan.bucket.app,
sql = op_sql.up,
)))
})?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
TEST_NON_SUPERUSER_PASSWORD, TEST_NON_SUPERUSER_ROLE, build_non_superuser_url,
replace_db_in_url, sql_string_literal, validate_extension_name,
};
#[test]
fn replace_db_preserves_host_and_port() {
let url = "postgres://user:pass@localhost:5432/old_db";
let result = replace_db_in_url(url, "new_db").unwrap();
assert_eq!(result, "postgres://user:pass@localhost:5432/new_db");
}
#[test]
fn replace_db_no_port() {
let url = "postgres://user:pass@localhost/old_db";
let result = replace_db_in_url(url, "new_db").unwrap();
assert_eq!(result, "postgres://user:pass@localhost/new_db");
}
#[test]
fn replace_db_postgresql_scheme() {
let url = "postgresql://localhost/old_db";
let result = replace_db_in_url(url, "fresh_db").unwrap();
assert_eq!(result, "postgresql://localhost/fresh_db");
}
#[test]
fn validate_extension_name_accepts_real_extensions() {
validate_extension_name("postgis").unwrap();
validate_extension_name("pg_trgm").unwrap();
validate_extension_name("pgcrypto").unwrap();
validate_extension_name("_leading_underscore").unwrap();
validate_extension_name("WithMixedCase").unwrap();
validate_extension_name("ext1").unwrap();
}
#[test]
fn validate_extension_name_rejects_empty() {
let err = validate_extension_name("").unwrap_err();
assert!(err.to_string().contains("must not be empty"), "{err}");
}
#[test]
fn validate_extension_name_rejects_too_long() {
let name = "a".repeat(64);
let err = validate_extension_name(&name).unwrap_err();
assert!(err.to_string().contains("exceeds 63-byte"), "{err}");
}
#[test]
fn validate_extension_name_rejects_leading_digit() {
let err = validate_extension_name("1ext").unwrap_err();
assert!(
err.to_string().contains("must start with"),
"error message should mention leading character rule: {err}",
);
}
#[test]
fn validate_extension_name_rejects_injection_attempts() {
for candidate in [
"postgis; DROP DATABASE postgres",
"a\"b",
"a b",
"a-b",
"a.b",
"a/b",
"\"postgis\"",
] {
let err = validate_extension_name(candidate).unwrap_err();
assert!(
err.to_string().contains("invalid byte")
|| err.to_string().contains("must start with"),
"expected validation failure for `{candidate}`, got: {err}",
);
}
}
use super::teardown_test_db;
use super::{additive_op_label, build_sync_plans, is_additive_op, setup_test_db, sync_models};
use crate::descriptor::{
FieldDescriptor, FieldSqlType, PkType, field_descriptor, model_descriptor,
};
use crate::migrate::diff::SchemaOperation;
use crate::migrate::schema::{OnDeleteSchema, PkKindSchema, PrimaryKeySchema, TableSchema};
const NUMERIC_ARRAY_MODEL_FIELDS: &[FieldDescriptor] = &[
field_descriptor("id", FieldSqlType::BigInt, false),
field_descriptor("values", FieldSqlType::NumericArray, true),
];
const NUMERIC_ARRAY_MODEL_DESCRIPTOR: crate::descriptor::ModelDescriptor = model_descriptor(
"NumericArrayFixture",
"numeric_arrays",
PkType::HeerId,
NUMERIC_ARRAY_MODEL_FIELDS,
);
fn synthetic_table(name: &str) -> TableSchema {
TableSchema {
app: None,
columns: Vec::new(),
exclusion_constraints: Vec::new(),
fts: None,
is_through: false,
moved_from_app: None,
partition: None,
primary_key: PrimaryKeySchema {
columns: vec!["id".to_string()],
kind: PkKindSchema::HeerId,
},
rationale: None,
renamed_from: None,
rls_enabled: false,
table: name.to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
}
}
#[test]
fn is_additive_op_classifies_add_variants_as_additive() {
let t = synthetic_table("widgets");
assert!(is_additive_op(&SchemaOperation::AddTable(t)));
assert!(is_additive_op(&SchemaOperation::AddEnum(
crate::migrate::schema::EnumSchema {
name: "color".to_string(),
variants: vec!["red".to_string()],
}
)));
assert!(is_additive_op(&SchemaOperation::AddForeignKey {
table: "widgets".to_string(),
column: "category_id".to_string(),
fk: crate::migrate::schema::ForeignKeySchema {
deferrable: false,
initially_deferred: false,
on_delete: OnDeleteSchema::Restrict,
ref_column: "id".to_string(),
ref_table: "categories".to_string(),
},
}));
}
#[test]
fn is_additive_op_classifies_drop_variants_as_destructive() {
assert!(!is_additive_op(&SchemaOperation::DropTable(
"widgets".to_string()
)));
assert!(!is_additive_op(&SchemaOperation::DropColumn {
table: "widgets".to_string(),
column: "name".to_string(),
}));
assert!(!is_additive_op(&SchemaOperation::DropEnum(
"color".to_string()
)));
assert!(!is_additive_op(&SchemaOperation::RenameTable {
from: "old".to_string(),
to: "new".to_string(),
}));
assert!(!is_additive_op(&SchemaOperation::DropForeignKey {
table: "widgets".to_string(),
column: "category_id".to_string(),
fk: crate::migrate::schema::ForeignKeySchema {
deferrable: false,
initially_deferred: false,
on_delete: OnDeleteSchema::Restrict,
ref_column: "id".to_string(),
ref_table: "categories".to_string(),
},
}));
}
#[test]
fn additive_op_label_emits_variant_name_for_each_kind() {
assert_eq!(
additive_op_label(&SchemaOperation::AddTable(synthetic_table("x"))),
"AddTable",
);
assert_eq!(
additive_op_label(&SchemaOperation::DropTable("x".to_string())),
"DropTable",
);
assert_eq!(
additive_op_label(&SchemaOperation::RenameTable {
from: "a".to_string(),
to: "b".to_string(),
}),
"RenameTable",
);
}
#[test]
fn build_sync_plans_includes_numeric_array_helper_operation() {
let plans =
build_sync_plans(&[&NUMERIC_ARRAY_MODEL_DESCRIPTOR]).expect("plan should build");
let plan = plans
.into_iter()
.next()
.expect("sync_models for one descriptor should yield one plan");
let labels: Vec<&str> = plan
.segments
.iter()
.flat_map(|s| s.statements.iter())
.map(|s| s.label.as_str())
.collect();
assert!(
labels.contains(&"Ensure djogi numeric-array helper"),
"plan should preload helper prelude for NUMERIC[] checks; labels: {labels:?}"
);
assert!(
plan.segments
.iter()
.flat_map(|s| s.statements.iter())
.any(|op| op
.up
.contains("djogi.__djogi_numeric_array_is_rust_decimal_v1(")),
"table DDL from NUMERIC[] descriptor should reference helper in CHECK",
);
}
#[tokio::test]
async fn sync_models_creates_numeric_array_helper_in_postgres_when_database_url_present() {
use std::env;
let database_url = env::var("DATABASE_URL").ok();
if !matches!(&database_url, Some(url) if !url.is_empty()) {
return;
}
let (cleanup, mut ctx) = setup_test_db()
.await
.expect("setup_test_db should provision test database");
sync_models(&mut ctx, &[&NUMERIC_ARRAY_MODEL_DESCRIPTOR])
.await
.expect("sync_models should apply helper-backed NUMERIC[] table");
let helper_exists = ctx
.query_one(
"SELECT EXISTS(\
SELECT 1 \
FROM pg_catalog.pg_proc p \
INNER JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace \
WHERE n.nspname = 'djogi' \
AND p.proname = '__djogi_numeric_array_is_rust_decimal_v1'\
)",
&[],
)
.await
.expect("helper function existence check should execute")
.get::<_, bool>(0);
teardown_test_db(cleanup).await;
assert!(
helper_exists,
"sync_models must create `djogi.__djogi_numeric_array_is_rust_decimal_v1`"
);
}
#[test]
fn sql_string_literal_wraps_in_single_quotes() {
assert_eq!(sql_string_literal("djogi_test_user"), "'djogi_test_user'");
assert_eq!(sql_string_literal(""), "''");
}
#[test]
fn sql_string_literal_doubles_embedded_single_quote() {
assert_eq!(sql_string_literal("a'b"), "'a''b'");
assert_eq!(sql_string_literal("'leading"), "'''leading'");
assert_eq!(sql_string_literal("trailing'"), "'trailing'''");
}
#[test]
fn build_non_superuser_url_swaps_userinfo_and_database() {
let url = build_non_superuser_url(
"postgres://djogi:djogi@localhost:5432/djogi_test",
"djogi_test_abc123",
)
.expect("valid URL must round-trip");
assert_eq!(
url,
format!(
"postgres://{role}:{pass}@localhost:5432/djogi_test_abc123",
role = TEST_NON_SUPERUSER_ROLE,
pass = TEST_NON_SUPERUSER_PASSWORD,
),
);
}
#[test]
fn build_non_superuser_url_preserves_query_string() {
let url = build_non_superuser_url(
"postgres://djogi:djogi@localhost:5432/djogi_test?sslmode=disable",
"djogi_test_xyz",
)
.expect("valid URL with query must round-trip");
assert!(
url.ends_with("/djogi_test_xyz?sslmode=disable"),
"query string must be preserved on splice; got: {url}",
);
}
#[test]
fn build_non_superuser_url_rejects_identity_override_query_params() {
for key in ["user", "password", "dbname"] {
let url = format!("postgres://localhost/djogi_test?sslmode=disable&{key}=djogi");
let err = build_non_superuser_url(&url, "djogi_test_xyz")
.expect_err("identity-overriding query params must be rejected");
assert!(
err.to_string().contains("would override"),
"error must explain identity override for {key}; got: {err}",
);
}
}
#[test]
fn build_non_superuser_url_rejects_percent_encoded_identity_override_keys() {
let err = build_non_superuser_url(
"postgres://localhost/djogi_test?%75ser=djogi",
"djogi_test_xyz",
)
.expect_err("percent-encoded `user` key must be rejected");
assert!(
err.to_string().contains("would override"),
"error must explain identity override; got: {err}",
);
}
#[test]
fn build_non_superuser_url_handles_postgresql_scheme() {
let url = build_non_superuser_url("postgresql://localhost/djogi_test", "djogi_test_001")
.expect("postgresql:// scheme accepted");
assert!(
url.starts_with("postgresql://"),
"scheme preserved on splice; got: {url}",
);
assert!(
url.ends_with("/djogi_test_001"),
"database swapped; got: {url}",
);
}
#[test]
fn build_non_superuser_url_strips_existing_userinfo() {
let url = build_non_superuser_url(
"postgres://admin:secret@db.local:5432/main",
"djogi_test_001",
)
.expect("admin URL with userinfo accepted");
assert!(
!url.contains("admin:secret"),
"previous userinfo must be stripped; got: {url}",
);
assert!(
url.contains(&format!(
"{TEST_NON_SUPERUSER_ROLE}:{TEST_NON_SUPERUSER_PASSWORD}@"
)),
"non-superuser userinfo must be present; got: {url}",
);
}
#[test]
fn build_non_superuser_url_rejects_missing_database() {
let err = build_non_superuser_url("postgres://localhost", "djogi_test_001")
.expect_err("URL without /db component must be rejected");
assert!(
err.to_string().contains("does not contain a database name"),
"error must explain the missing path; got: {err}",
);
}
#[test]
fn build_non_superuser_url_rejects_unknown_scheme() {
let err = build_non_superuser_url("mysql://localhost/main", "djogi_test_001")
.expect_err("non-postgres schemes must be rejected");
assert!(
err.to_string().contains("postgres://"),
"error must mention the supported schemes; got: {err}",
);
}
#[test]
fn outbox_table_for_test_rejects_reserved_djogi_prefix() {
let err = super::validate_outbox_table_for_test("__djogi_outbox")
.expect_err("must reject framework-reserved prefix");
let msg = err.to_string();
assert!(msg.contains("ReservedDjogiPrefix"), "got: {msg}");
assert!(super::validate_outbox_table_for_test("__djogi_").is_err());
assert!(super::validate_outbox_table_for_test("app_outbox").is_ok());
assert!(super::validate_outbox_table_for_test("_djogi_outbox").is_ok());
}
#[test]
fn wrap_relation_registry_for_sync_models_passes_ok_through() {
super::wrap_relation_registry_for_sync_models(Ok(()))
.expect("Ok input must propagate unchanged");
}
#[test]
fn wrap_relation_registry_for_sync_models_wraps_collision_into_djogi_error() {
use crate::relation::registry::{RelationKind, validate_relation_accessor_collisions};
let make = |kind, source, name, target, via| {
crate::relation::registry::__macro_support::__make_reverse_relation_marker(
kind, source, name, target, via,
)
};
let markers = [
make(RelationKind::FK, "Owner", "cars", "Vehicle", "owner_id"),
make(RelationKind::M2M, "Owner", "cars", "Garage", "owner_id"),
];
let registry_err = validate_relation_accessor_collisions(markers.iter())
.expect_err("synthetic FK + M2M markers must collide");
let err = super::wrap_relation_registry_for_sync_models(Err(registry_err))
.expect_err("registry error must surface as DjogiError::Db");
let msg = err.to_string();
assert!(
msg.contains("sync_models"),
"missing entry-point anchor: {msg}"
);
assert!(msg.contains("GH #158"), "missing issue anchor: {msg}");
assert!(msg.contains("Owner"), "missing source: {msg}");
assert!(msg.contains("cars"), "missing accessor: {msg}");
assert!(msg.contains("FK"), "missing FK kind: {msg}");
assert!(msg.contains("M2M"), "missing M2M kind: {msg}");
}
}