mod common;
use common::TestEngine;
use hyperdb_mcp::engine::Engine;
use hyperdb_mcp::error::ErrorCode;
use hyperdb_mcp::ingest::{ingest_csv, ingest_json, qualified_table, IngestOptions};
use tempfile::TempDir;
#[test]
fn qualified_table_unqualified_when_target_db_none() {
let opts = IngestOptions {
table: "widgets".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
assert_eq!(qualified_table(&opts), "\"widgets\"");
}
#[test]
fn qualified_table_qualifies_when_target_db_set() {
let opts = IngestOptions {
table: "widgets".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: Some("persistent".into()),
};
assert_eq!(
qualified_table(&opts),
"\"persistent\".\"public\".\"widgets\""
);
}
#[test]
fn qualified_table_escapes_embedded_quotes() {
let opts = IngestOptions {
table: "weird\"name".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: Some("db\"alias".into()),
};
assert_eq!(
qualified_table(&opts),
"\"db\"\"alias\".\"public\".\"weird\"\"name\""
);
}
#[test]
fn resolve_target_db_none_means_primary() {
let te = TestEngine::new_ephemeral();
let primary = te.engine.primary_db_name();
assert_eq!(te.engine.resolve_target_db(None).unwrap(), primary);
assert_eq!(te.engine.resolve_target_db(Some("")).unwrap(), primary);
assert_eq!(
te.engine.resolve_target_db(Some(" ")).unwrap(),
primary,
"whitespace-only treated as None"
);
}
#[test]
fn resolve_target_db_persistent_when_attached() {
let te = TestEngine::new_ephemeral();
assert!(te.engine.has_persistent());
assert_eq!(
te.engine.resolve_target_db(Some("persistent")).unwrap(),
"persistent"
);
}
#[test]
fn resolve_target_db_persistent_errors_in_ephemeral_only() {
let engine = Engine::new_no_daemon(None).expect("ephemeral engine");
assert!(!engine.has_persistent());
let err = engine
.resolve_target_db(Some("persistent"))
.expect_err("must reject persistent in ephemeral-only mode");
assert_eq!(err.code, ErrorCode::InvalidArgument);
assert!(err.message.contains("ephemeral-only"));
}
#[test]
fn ingest_json_with_persistent_target_lands_in_persistent() {
let te = TestEngine::new_ephemeral();
let opts = IngestOptions {
table: "persisted_data".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: Some("persistent".into()),
};
let data = r#"[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]"#;
let result = ingest_json(&te.engine, data, &opts).unwrap();
assert_eq!(result.rows, 2);
let rows = te
.engine
.execute_query_to_json(
"SELECT * FROM \"persistent\".\"public\".\"persisted_data\" ORDER BY id",
)
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0]["name"], "Alice");
let primary_check = te
.engine
.execute_query_to_json("SELECT * FROM persisted_data");
assert!(
primary_check.is_err(),
"table should not exist in the primary database"
);
}
#[test]
fn ingest_csv_with_persistent_target_lands_in_persistent() {
let te = TestEngine::new_ephemeral();
let opts = IngestOptions {
table: "csv_target".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: Some("persistent".into()),
};
let data = "id,name\n1,Alice\n2,Bob\n";
let result = ingest_csv(&te.engine, data, &opts).unwrap();
assert_eq!(result.rows, 2);
let rows = te
.engine
.execute_query_to_json("SELECT * FROM \"persistent\".\"public\".\"csv_target\" ORDER BY id")
.unwrap();
assert_eq!(rows.len(), 2);
}
#[test]
fn ingest_to_persistent_survives_engine_recreate() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("survives.hyper");
let path_str = path.to_str().unwrap().to_string();
{
let engine = Engine::new_no_daemon(Some(path_str.clone())).unwrap();
let opts = IngestOptions {
table: "library".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: Some("persistent".into()),
};
let data = r#"[{"id": 1, "title": "Dune"}]"#;
ingest_json(&engine, data, &opts).unwrap();
}
let engine = Engine::new_no_daemon(Some(path_str)).unwrap();
let rows = engine
.execute_query_to_json("SELECT title FROM \"persistent\".\"public\".\"library\"")
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["title"], "Dune");
}
#[test]
fn ingest_with_no_target_db_lands_in_primary() {
let te = TestEngine::new_ephemeral();
let opts = IngestOptions {
table: "scratch".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let data = r#"[{"x": 1}]"#;
ingest_json(&te.engine, data, &opts).unwrap();
let rows = te
.engine
.execute_query_to_json("SELECT * FROM scratch")
.unwrap();
assert_eq!(rows.len(), 1);
}
#[test]
fn describe_tables_in_persistent_lists_only_persistent_tables() {
let te = TestEngine::new_ephemeral();
te.engine
.execute_command("CREATE TABLE primary_only (x INT)")
.unwrap();
te.engine
.execute_command("CREATE TABLE \"persistent\".\"public\".\"alpha\" (x INT)")
.unwrap();
te.engine
.execute_command("CREATE TABLE \"persistent\".\"public\".\"beta\" (y TEXT)")
.unwrap();
let persistent_tables = te.engine.describe_tables_in(Some("persistent")).unwrap();
let names: Vec<String> = persistent_tables
.iter()
.filter_map(|t| t.get("name").and_then(|v| v.as_str()).map(String::from))
.collect();
assert!(names.contains(&"alpha".into()));
assert!(names.contains(&"beta".into()));
assert!(
!names.contains(&"primary_only".into()),
"primary tables must not appear in persistent listing"
);
}
#[test]
fn describe_table_in_persistent_returns_schema() {
let te = TestEngine::new_ephemeral();
te.engine
.execute_command(
"CREATE TABLE \"persistent\".\"public\".\"orders\" (id INT, total NUMERIC(10,2))",
)
.unwrap();
let info = te
.engine
.describe_table_in(Some("persistent"), "orders")
.unwrap();
assert_eq!(info.get("name").and_then(|v| v.as_str()), Some("orders"));
let cols = info
.get("columns")
.and_then(|v| v.as_array())
.expect("columns array");
assert_eq!(cols.len(), 2);
}
#[test]
fn describe_table_in_unknown_returns_table_not_found() {
let te = TestEngine::new_ephemeral();
let err = te
.engine
.describe_table_in(Some("persistent"), "no_such_table")
.expect_err("missing table must error");
assert_eq!(err.code, ErrorCode::TableNotFound);
}
#[test]
fn sample_table_in_persistent_returns_rows() {
let te = TestEngine::new_ephemeral();
let opts = IngestOptions {
table: "samples".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: Some("persistent".into()),
};
ingest_json(&te.engine, r#"[{"id": 1}, {"id": 2}, {"id": 3}]"#, &opts).unwrap();
let sample = te
.engine
.sample_table_in(Some("persistent"), "samples", 5)
.unwrap();
assert_eq!(
sample.get("table").and_then(|v| v.as_str()),
Some("samples")
);
assert_eq!(
sample.get("row_count").and_then(serde_json::Value::as_i64),
Some(3)
);
let rows = sample
.get("rows")
.and_then(|v| v.as_array())
.expect("rows array");
assert_eq!(rows.len(), 3);
}
#[test]
fn scoped_search_path_redirects_and_restores() {
let te = TestEngine::new_ephemeral();
te.engine
.execute_command("CREATE TABLE \"persistent\".\"public\".\"t\" (x INT)")
.unwrap();
te.engine
.execute_command("INSERT INTO \"persistent\".\"public\".\"t\" VALUES (42)")
.unwrap();
te.engine.execute_command("CREATE TABLE t (x INT)").unwrap();
te.engine
.execute_command("INSERT INTO t VALUES (1), (2)")
.unwrap();
{
let _guard = te.engine.scoped_search_path("persistent").unwrap();
let rows = te
.engine
.execute_query_to_json("SELECT * FROM t ORDER BY x")
.unwrap();
assert_eq!(rows.len(), 1, "scoped to persistent's `t` (1 row)");
assert_eq!(rows[0]["x"], 42);
}
let rows = te
.engine
.execute_query_to_json("SELECT * FROM t ORDER BY x")
.unwrap();
assert_eq!(rows.len(), 2, "search path restored to primary (2 rows)");
}
#[test]
fn resolve_target_db_persistent_is_case_insensitive() {
let te = TestEngine::new_ephemeral();
for variant in ["persistent", "Persistent", "PERSISTENT", "PerSiStEnT"] {
let resolved = te
.engine
.resolve_target_db(Some(variant))
.unwrap_or_else(|e| panic!("variant {variant:?} should resolve: {}", e.message));
assert_eq!(
resolved, "persistent",
"variant {variant:?} must canonicalize to lowercase"
);
}
}
#[test]
fn resolve_target_db_persistent_uppercase_errors_in_ephemeral_only() {
let engine = Engine::new_no_daemon(None).expect("ephemeral engine");
let err = engine
.resolve_target_db(Some("PERSISTENT"))
.expect_err("uppercase persistent must reject too");
assert_eq!(err.code, ErrorCode::InvalidArgument);
}
#[test]
fn merge_into_persistent_creates_table_when_missing() {
let te = TestEngine::new_ephemeral();
let opts = IngestOptions {
table: "merged_persist".into(),
mode: "merge".into(),
schema_override: None,
merge_key: Some(vec!["id".into()]),
target_db: Some("persistent".into()),
};
let data = r#"[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]"#;
let result = ingest_json(&te.engine, data, &opts).unwrap();
assert_eq!(result.rows, 2);
assert!(
result.stats.schema_changed,
"newly-created target should report schema_changed=true"
);
let rows = te
.engine
.execute_query_to_json(
"SELECT id, name FROM \"persistent\".\"public\".\"merged_persist\" ORDER BY id",
)
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0]["name"], "Alice");
}
#[test]
fn merge_into_persistent_replaces_matching_and_appends_new() {
let te = TestEngine::new_ephemeral();
let seed_opts = IngestOptions {
table: "merge_target".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: Some("persistent".into()),
};
ingest_json(
&te.engine,
r#"[{"id": 1, "name": "old1"}, {"id": 2, "name": "old2"}]"#,
&seed_opts,
)
.unwrap();
let merge_opts = IngestOptions {
table: "merge_target".into(),
mode: "merge".into(),
schema_override: None,
merge_key: Some(vec!["id".into()]),
target_db: Some("persistent".into()),
};
let result = ingest_json(
&te.engine,
r#"[{"id": 1, "name": "new1"}, {"id": 3, "name": "new3"}]"#,
&merge_opts,
)
.unwrap();
assert_eq!(result.rows, 2, "INSERT row count from merge");
let rows = te
.engine
.execute_query_to_json(
"SELECT id, name FROM \"persistent\".\"public\".\"merge_target\" ORDER BY id",
)
.unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0]["name"], "new1", "id=1 replaced");
assert_eq!(rows[1]["name"], "old2", "id=2 untouched");
assert_eq!(rows[2]["name"], "new3", "id=3 appended");
}
#[test]
fn merge_into_persistent_alters_when_incoming_has_new_column() {
let te = TestEngine::new_ephemeral();
let seed_opts = IngestOptions {
table: "widens".into(),
mode: "replace".into(),
schema_override: None,
merge_key: None,
target_db: Some("persistent".into()),
};
ingest_json(&te.engine, r#"[{"id": 1, "name": "Alice"}]"#, &seed_opts).unwrap();
let merge_opts = IngestOptions {
table: "widens".into(),
mode: "merge".into(),
schema_override: None,
merge_key: Some(vec!["id".into()]),
target_db: Some("persistent".into()),
};
let result = ingest_json(
&te.engine,
r#"[{"id": 2, "name": "Bob", "email": "bob@x.com"}]"#,
&merge_opts,
)
.unwrap();
assert!(
result.stats.schema_changed,
"new column 'email' should signal schema_changed"
);
let rows = te
.engine
.execute_query_to_json(
"SELECT id, name, email FROM \"persistent\".\"public\".\"widens\" ORDER BY id",
)
.unwrap();
assert_eq!(rows.len(), 2);
assert!(
rows[0]["email"].is_null(),
"pre-existing row gets NULL for new column"
);
assert_eq!(rows[1]["email"], "bob@x.com");
}
#[test]
fn ensure_exists_in_seeds_catalog_inside_user_attached_db() {
use hyperdb_mcp::attach::{AttachRegistry, AttachRequest, AttachSource, OnMissing};
let dir = TempDir::new().unwrap();
let primary = dir.path().join("primary.hyper");
let attached = dir.path().join("attached.hyper");
let engine = Engine::new_no_daemon(Some(primary.to_string_lossy().into())).unwrap();
let reg = AttachRegistry::new();
reg.attach(
&engine,
AttachRequest {
alias: "user_db".into(),
source: AttachSource::LocalFile { path: attached },
writable: true,
on_missing: OnMissing::Create,
},
)
.unwrap();
hyperdb_mcp::table_catalog::ensure_exists_in(&engine, Some("user_db")).unwrap();
hyperdb_mcp::table_catalog::ensure_exists_in(&engine, Some("user_db")).unwrap();
let rows = engine
.execute_query_to_json(
"SELECT tablename FROM \"user_db\".pg_catalog.pg_tables \
WHERE schemaname = 'public' AND tablename = '_table_catalog'",
)
.unwrap();
assert_eq!(rows.len(), 1, "_table_catalog must exist in user_db");
}
#[test]
fn upsert_stub_in_routes_to_user_attached_db() {
use hyperdb_mcp::attach::{AttachRegistry, AttachRequest, AttachSource, OnMissing};
let dir = TempDir::new().unwrap();
let primary = dir.path().join("primary.hyper");
let attached = dir.path().join("attached.hyper");
let engine = Engine::new_no_daemon(Some(primary.to_string_lossy().into())).unwrap();
let reg = AttachRegistry::new();
reg.attach(
&engine,
AttachRequest {
alias: "user_db".into(),
source: AttachSource::LocalFile { path: attached },
writable: true,
on_missing: OnMissing::Create,
},
)
.unwrap();
engine
.execute_command("CREATE TABLE \"user_db\".\"public\".\"my_data\" (id INT)")
.unwrap();
hyperdb_mcp::table_catalog::upsert_stub_in(
&engine,
"my_data",
"load_data",
Some("{\"format\":\"json\"}"),
Some(42),
true,
Some("user_db"),
None,
)
.unwrap();
let rows = engine
.execute_query_to_json(
"SELECT table_name, load_tool, row_count \
FROM \"user_db\".\"public\".\"_table_catalog\" \
WHERE table_name = 'my_data'",
)
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["load_tool"], "load_data");
assert_eq!(rows[0]["row_count"], 42);
let persistent_rows = engine
.execute_query_to_json(
"SELECT table_name FROM \"persistent\".\"public\".\"_table_catalog\" \
WHERE table_name = 'my_data'",
)
.unwrap_or_default();
assert!(
persistent_rows.is_empty(),
"the row must NOT bleed into persistent's catalog"
);
}
#[test]
fn get_in_reads_from_user_attached_db_catalog() {
use hyperdb_mcp::attach::{AttachRegistry, AttachRequest, AttachSource, OnMissing};
let dir = TempDir::new().unwrap();
let primary = dir.path().join("primary.hyper");
let attached = dir.path().join("attached.hyper");
let engine = Engine::new_no_daemon(Some(primary.to_string_lossy().into())).unwrap();
let reg = AttachRegistry::new();
reg.attach(
&engine,
AttachRequest {
alias: "user_db".into(),
source: AttachSource::LocalFile { path: attached },
writable: true,
on_missing: OnMissing::Create,
},
)
.unwrap();
engine
.execute_command("CREATE TABLE \"user_db\".\"public\".\"events\" (id INT)")
.unwrap();
hyperdb_mcp::table_catalog::upsert_stub_in(
&engine,
"events",
"load_file",
None,
Some(7),
true,
Some("user_db"),
None,
)
.unwrap();
let entry = hyperdb_mcp::table_catalog::get_in(&engine, "events", Some("user_db"))
.unwrap()
.expect("get_in must find the row written via upsert_stub_in");
assert_eq!(entry.table_name, "events");
assert_eq!(entry.row_count, Some(7));
let persistent_entry = hyperdb_mcp::table_catalog::get_in(&engine, "events", None).unwrap();
assert!(persistent_entry.is_none());
}
#[test]
fn reconcile_in_per_db_does_not_touch_persistent_catalog() {
use hyperdb_mcp::attach::{AttachRegistry, AttachRequest, AttachSource, OnMissing};
let dir = TempDir::new().unwrap();
let primary = dir.path().join("primary.hyper");
let attached = dir.path().join("attached.hyper");
let engine = Engine::new_no_daemon(Some(primary.to_string_lossy().into())).unwrap();
let reg = AttachRegistry::new();
reg.attach(
&engine,
AttachRequest {
alias: "user_db".into(),
source: AttachSource::LocalFile { path: attached },
writable: true,
on_missing: OnMissing::Create,
},
)
.unwrap();
engine
.execute_command("CREATE TABLE \"persistent\".\"public\".\"persist_t\" (n INT)")
.unwrap();
engine
.execute_command("CREATE TABLE \"user_db\".\"public\".\"user_t\" (n INT)")
.unwrap();
hyperdb_mcp::table_catalog::reconcile_in(&engine, Some("user_db")).unwrap();
let user_rows = hyperdb_mcp::table_catalog::list_in(&engine, Some("user_db")).unwrap();
let user_names: Vec<String> = user_rows.iter().map(|e| e.table_name.clone()).collect();
assert!(
user_names.contains(&"user_t".into()),
"user_db reconcile must stub user_t; got {user_names:?}"
);
let persistent_rows = hyperdb_mcp::table_catalog::list_in(&engine, None).unwrap();
let persistent_names: Vec<String> = persistent_rows
.iter()
.map(|e| e.table_name.clone())
.collect();
assert!(
!persistent_names.contains(&"user_t".into()),
"user_t must not appear in persistent's catalog; got {persistent_names:?}"
);
}
#[test]
fn set_metadata_in_routes_to_user_attached_db() {
use hyperdb_mcp::attach::{AttachRegistry, AttachRequest, AttachSource, OnMissing};
use hyperdb_mcp::table_catalog::MetadataFields;
let dir = TempDir::new().unwrap();
let primary = dir.path().join("primary.hyper");
let attached = dir.path().join("attached.hyper");
let engine = Engine::new_no_daemon(Some(primary.to_string_lossy().into())).unwrap();
let reg = AttachRegistry::new();
reg.attach(
&engine,
AttachRequest {
alias: "user_db".into(),
source: AttachSource::LocalFile { path: attached },
writable: true,
on_missing: OnMissing::Create,
},
)
.unwrap();
engine
.execute_command("CREATE TABLE \"user_db\".\"public\".\"events\" (id INT)")
.unwrap();
hyperdb_mcp::table_catalog::upsert_stub_in(
&engine,
"events",
"load_data",
None,
Some(0),
true,
Some("user_db"),
None,
)
.unwrap();
let fields = MetadataFields {
source_url: Some("s3://bucket/events.parquet".into()),
source_description: Some("Tracking events".into()),
purpose: Some("daily reports".into()),
license: None,
notes: None,
};
let entry =
hyperdb_mcp::table_catalog::set_metadata_in(&engine, "events", &fields, Some("user_db"))
.unwrap();
assert_eq!(
entry.source_url.as_deref(),
Some("s3://bucket/events.parquet")
);
assert_eq!(entry.purpose.as_deref(), Some("daily reports"));
let rows = engine
.execute_query_to_json(
"SELECT source_url, purpose FROM \"user_db\".\"public\".\"_table_catalog\" \
WHERE table_name = 'events'",
)
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["source_url"], "s3://bucket/events.parquet");
assert_eq!(rows[0]["purpose"], "daily reports");
}
#[test]
fn set_metadata_in_missing_row_names_target_database_in_error() {
use hyperdb_mcp::attach::{AttachRegistry, AttachRequest, AttachSource, OnMissing};
use hyperdb_mcp::table_catalog::MetadataFields;
let dir = TempDir::new().unwrap();
let primary = dir.path().join("primary.hyper");
let attached = dir.path().join("attached.hyper");
let engine = Engine::new_no_daemon(Some(primary.to_string_lossy().into())).unwrap();
let reg = AttachRegistry::new();
reg.attach(
&engine,
AttachRequest {
alias: "user_db".into(),
source: AttachSource::LocalFile { path: attached },
writable: true,
on_missing: OnMissing::Create,
},
)
.unwrap();
engine
.execute_command("CREATE TABLE \"persistent\".\"public\".\"per_t\" (id INT)")
.unwrap();
hyperdb_mcp::table_catalog::upsert_stub_in(
&engine,
"per_t",
"load_data",
None,
Some(0),
true,
None,
None,
)
.unwrap();
let fields = MetadataFields {
source_url: Some("anything".into()),
..Default::default()
};
let err =
hyperdb_mcp::table_catalog::set_metadata_in(&engine, "per_t", &fields, Some("user_db"))
.expect_err("must error: row exists in persistent, not user_db");
assert_eq!(err.code, ErrorCode::TableNotFound);
assert!(
err.message.contains("user_db"),
"error must name the target db; got: {}",
err.message
);
}
#[test]
fn attach_canonicalizes_alias_to_lowercase_at_attach_time() {
use hyperdb_mcp::attach::{AttachRegistry, AttachRequest, AttachSource, OnMissing};
let dir = TempDir::new().unwrap();
let primary = dir.path().join("primary.hyper");
let attached = dir.path().join("attached.hyper");
let engine = Engine::new_no_daemon(Some(primary.to_string_lossy().into())).unwrap();
let reg = AttachRegistry::new();
reg.attach(
&engine,
AttachRequest {
alias: "User_DB".into(),
source: AttachSource::LocalFile { path: attached },
writable: true,
on_missing: OnMissing::Create,
},
)
.unwrap();
let listed = reg.list();
assert_eq!(listed.len(), 1);
assert_eq!(
listed[0].alias, "user_db",
"attach must store the alias in lowercase"
);
assert!(reg.get("user_db").is_some(), "lowercase get must find");
assert!(reg.get("User_DB").is_some(), "mixed-case get must find");
assert!(reg.get("USER_DB").is_some(), "uppercase get must find");
}
#[test]
fn detach_after_canonicalization_is_case_insensitive() {
use hyperdb_mcp::attach::{AttachRegistry, AttachRequest, AttachSource, OnMissing};
let dir = TempDir::new().unwrap();
let primary = dir.path().join("primary.hyper");
let attached = dir.path().join("attached.hyper");
let engine = Engine::new_no_daemon(Some(primary.to_string_lossy().into())).unwrap();
let reg = AttachRegistry::new();
reg.attach(
&engine,
AttachRequest {
alias: "User_DB".into(),
source: AttachSource::LocalFile { path: attached },
writable: true,
on_missing: OnMissing::Create,
},
)
.unwrap();
let detached = reg.detach(&engine, "user_db").unwrap();
assert!(detached, "detach with lowercase must succeed");
assert!(reg.list().is_empty());
}
#[test]
fn qualified_catalog_in_uses_canonical_alias_after_attach() {
use hyperdb_mcp::attach::{AttachRegistry, AttachRequest, AttachSource, OnMissing};
let dir = TempDir::new().unwrap();
let primary = dir.path().join("primary.hyper");
let attached = dir.path().join("attached.hyper");
let engine = Engine::new_no_daemon(Some(primary.to_string_lossy().into())).unwrap();
let reg = AttachRegistry::new();
reg.attach(
&engine,
AttachRequest {
alias: "User_DB".into(),
source: AttachSource::LocalFile { path: attached },
writable: true,
on_missing: OnMissing::Create,
},
)
.unwrap();
let resolved = engine.resolve_target_db(Some("User_DB")).unwrap();
assert_eq!(resolved, "user_db");
let rows = engine
.execute_query_to_json(
"SELECT tablename FROM \"user_db\".pg_catalog.pg_tables \
WHERE schemaname = 'public' AND tablename = '_table_catalog'",
)
.unwrap();
assert_eq!(
rows.len(),
1,
"_table_catalog must exist under the canonical (lowercase) alias"
);
engine
.execute_command("CREATE TABLE \"user_db\".\"public\".\"my_t\" (id INT)")
.unwrap();
hyperdb_mcp::table_catalog::upsert_stub_in(
&engine,
"my_t",
"load_data",
None,
Some(0),
true,
Some("User_DB"),
None,
)
.unwrap();
let rows = engine
.execute_query_to_json(
"SELECT table_name FROM \"user_db\".\"public\".\"_table_catalog\" \
WHERE table_name = 'my_t'",
)
.unwrap();
assert_eq!(rows.len(), 1);
}
#[test]
fn execute_drop_table_in_user_attached_reconciles_that_dbs_catalog() {
use hyperdb_mcp::attach::{AttachRegistry, AttachRequest, AttachSource, OnMissing};
let dir = TempDir::new().unwrap();
let primary = dir.path().join("primary.hyper");
let attached = dir.path().join("attached.hyper");
let engine = Engine::new_no_daemon(Some(primary.to_string_lossy().into())).unwrap();
let reg = AttachRegistry::new();
reg.attach(
&engine,
AttachRequest {
alias: "user_db".into(),
source: AttachSource::LocalFile { path: attached },
writable: true,
on_missing: OnMissing::Create,
},
)
.unwrap();
engine
.execute_command("CREATE TABLE \"user_db\".\"public\".\"to_drop\" (id INT)")
.unwrap();
hyperdb_mcp::table_catalog::upsert_stub_in(
&engine,
"to_drop",
"load_data",
None,
Some(0),
true,
Some("user_db"),
None,
)
.unwrap();
let before = engine
.execute_query_to_json(
"SELECT table_name FROM \"user_db\".\"public\".\"_table_catalog\" \
WHERE table_name = 'to_drop'",
)
.unwrap();
assert_eq!(before.len(), 1, "stub must exist before DROP");
engine
.execute_command("DROP TABLE \"user_db\".\"public\".\"to_drop\"")
.unwrap();
hyperdb_mcp::table_catalog::reconcile_in(&engine, None).unwrap();
hyperdb_mcp::table_catalog::reconcile_in(&engine, Some("user_db")).unwrap();
let after = engine
.execute_query_to_json(
"SELECT table_name FROM \"user_db\".\"public\".\"_table_catalog\" \
WHERE table_name = 'to_drop'",
)
.unwrap();
assert_eq!(
after.len(),
0,
"post-execute reconcile must drop catalog rows for tables removed by raw DDL in the user-attached DB"
);
let in_persistent = engine
.execute_query_to_json(
"SELECT table_name FROM \"persistent\".\"public\".\"_table_catalog\" \
WHERE table_name = 'to_drop'",
)
.unwrap();
assert_eq!(in_persistent.len(), 0);
}