diff --git a/src/cli.rs b/src/cli.rs index d5a16dd..b88836b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -94,4 +94,23 @@ pub enum Command { #[arg(long)] pretty: bool, }, + + /// Compare database schema to config and interactively update the policy + UpdateSchema { + /// Named database profile from config + #[arg(short, long)] + database: Option, + + /// Include only these schemas (repeatable; default: default_schema and schemas from policy tables) + #[arg(long = "include-schema")] + include_schema: Vec, + + /// Remove policy tables/columns that no longer exist in the database + #[arg(long)] + remove_stale: bool, + + /// Pretty-print JSON output + #[arg(long)] + pretty: bool, + }, } diff --git a/src/config.rs b/src/config.rs index de4bfb8..7081791 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,8 @@ -use crate::policy::DatabaseProfile; +use crate::init::write_config_file; +use crate::policy::{DatabaseProfile, normalize_ident, parse_table_key}; use anyhow::{Context, Result, bail}; -use serde::Deserialize; -use std::collections::BTreeMap; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -12,7 +13,7 @@ const CONFIG_BASENAMES: &[&str] = &["config.yaml", "config.yml", "config"]; pub const DEFAULT_STATEMENT_TIMEOUT_MS: u64 = 10_000; pub const DEFAULT_MAX_ROWS: u64 = 1000; -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RawConfig { #[serde(default = "default_version")] pub version: u32, @@ -23,7 +24,7 @@ fn default_version() -> u32 { 1 } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RawDatabaseProfile { pub description: Option, pub url_env: String, @@ -43,7 +44,7 @@ fn default_max_rows() -> u64 { DEFAULT_MAX_ROWS } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RawPolicy { pub default_schema: String, pub tables: BTreeMap, @@ -51,17 +52,17 @@ pub struct RawPolicy { pub functions: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RawTablePolicy { #[serde(default)] pub access: Option, #[serde(default)] pub default_column_access: Option, - #[serde(default)] - pub columns: BTreeMap, + /// Absent in YAML means do not track per-column policy for this table. + pub columns: Option>, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RawColumnPolicy { #[serde(default)] pub access: Option, @@ -72,7 +73,12 @@ pub struct LoadedConfig { pub databases: BTreeMap, } -pub fn load_config(explicit_path: Option<&Path>) -> Result { +pub struct LoadedRawConfig { + pub path: PathBuf, + pub raw: RawConfig, +} + +pub fn load_raw_config(explicit_path: Option<&Path>) -> Result { let path = resolve_config_path(explicit_path)?; let contents = fs::read_to_string(&path) .with_context(|| format!("failed to read config file {}", path.display()))?; @@ -84,6 +90,15 @@ pub fn load_config(explicit_path: Option<&Path>) -> Result { bail!("config must define at least one database under `databases`"); } + Ok(LoadedRawConfig { path, raw }) +} + +pub fn load_config(explicit_path: Option<&Path>) -> Result { + let LoadedRawConfig { path, raw } = load_raw_config(explicit_path)?; + loaded_config_from_raw(path, raw) +} + +pub fn loaded_config_from_raw(path: PathBuf, raw: RawConfig) -> Result { let mut databases = BTreeMap::new(); for (name, profile) in raw.databases { databases.insert( @@ -96,6 +111,60 @@ pub fn load_config(explicit_path: Option<&Path>) -> Result { Ok(LoadedConfig { path, databases }) } +pub fn serialize_raw_config(raw: &RawConfig) -> Result { + serde_yaml::to_string(raw).context("failed to serialize config to YAML") +} + +pub fn write_raw_config(path: &Path, raw: &RawConfig) -> Result<()> { + let yaml = serialize_raw_config(raw)?; + write_config_file(path, &yaml) +} + +/// Schemas to introspect: explicit flags, or default_schema plus schemas from policy tables. +pub fn resolve_update_include_schemas( + include_schemas: &[String], + default_schema: &str, + policy_tables: &BTreeMap, +) -> Vec { + if !include_schemas.is_empty() { + return include_schemas + .iter() + .map(|s| normalize_ident(s)) + .collect(); + } + + let mut schemas = BTreeSet::new(); + schemas.insert(normalize_ident(default_schema)); + for key in policy_tables.keys() { + let qualified = parse_table_key(key, default_schema); + schemas.insert(qualified.schema); + } + schemas.into_iter().collect() +} + +pub fn select_database_name( + databases: &BTreeMap, + name: Option<&str>, +) -> Result { + match name { + Some(name) => { + if databases.contains_key(name) { + Ok(name.to_string()) + } else { + bail!("database profile `{name}` not found in config") + } + } + None if databases.len() == 1 => Ok(databases.keys().next().expect("one database").clone()), + None => { + let names: Vec<&str> = databases.keys().map(String::as_str).collect(); + bail!( + "multiple database profiles configured; use --database . Available: {}", + names.join(", ") + ) + } + } +} + pub fn resolve_config_path(explicit_path: Option<&Path>) -> Result { if let Some(path) = explicit_path { return Ok(path.to_path_buf()); diff --git a/src/lib.rs b/src/lib.rs index c29ad02..66da3d8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,4 +4,5 @@ pub mod db; pub mod init; pub mod output; pub mod policy; +pub mod schema_update; pub mod sql; diff --git a/src/main.rs b/src/main.rs index a0f9ad3..0ef3f56 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,19 @@ use anyhow::{Result, bail}; use querygate::cli::{Cli, Command}; -use querygate::config::{default_config_output_path, load_config, select_database}; +use querygate::config::{ + default_config_output_path, load_config, load_raw_config, resolve_update_include_schemas, + select_database, select_database_name, write_raw_config, +}; use querygate::init::{ InitOptions, introspect_database, render_config_yaml, resolve_include_schemas, write_config_file, }; use querygate::output::{ - DatabaseInfo, DatabasesResult, ErrorBody, InitResult, RunResult, SchemaResult, ValidateResult, - print_error, print_success, + DatabaseInfo, DatabasesResult, ErrorBody, InitResult, RunResult, SchemaResult, UpdateSchemaResult, + ValidateResult, print_error, print_success, +}; +use querygate::schema_update::{ + StderrPrompter, apply_schema_update, collect_decisions, compute_schema_diff, print_diff_summary, }; use querygate::sql::parse::parse_sql; use querygate::sql::validate::validate_query; @@ -86,6 +92,12 @@ async fn run() -> Result<()> { let loaded = load_config_or_exit(cli.config.as_deref(), pretty); cmd_run(&loaded, database.as_deref(), sql, pretty).await } + Command::UpdateSchema { + database, + include_schema, + remove_stale, + pretty, + } => cmd_update_schema(cli.config.as_deref(), database.as_deref(), include_schema, remove_stale, pretty).await, } } @@ -155,6 +167,142 @@ async fn cmd_init( Ok(()) } +async fn cmd_update_schema( + config_path: Option<&std::path::Path>, + database: Option<&str>, + include_schema: Vec, + remove_stale: bool, + pretty: bool, +) -> Result<()> { + let mut loaded = match load_raw_config(config_path) { + Ok(c) => c, + Err(e) => print_config_error(&e.to_string(), pretty), + }; + + let database_name = match select_database_name(&loaded.raw.databases, database) { + Ok(n) => n, + Err(e) => print_config_error(&e.to_string(), pretty), + }; + + // Validate config before mutating. + if let Err(e) = querygate::config::loaded_config_from_raw(loaded.path.clone(), loaded.raw.clone()) { + print_config_error(&e.to_string(), pretty); + } + + let profile = loaded + .raw + .databases + .get(&database_name) + .expect("database profile exists"); + + let url = match std::env::var(&profile.url_env) { + Ok(u) => u, + Err(_) => { + print_error( + ErrorBody { + code: "config_error".to_string(), + message: format!( + "environment variable `{}` is not set; required for database connection", + profile.url_env + ), + hint: Some(format!("Export {} before running update-schema", profile.url_env)), + location: None, + details: vec![], + }, + pretty, + ); + } + }; + + let default_schema = profile.policy.default_schema.clone(); + let policy_tables = profile.policy.tables.clone(); + let schemas = resolve_update_include_schemas(&include_schema, &default_schema, &policy_tables); + + let discovered = match introspect_database(&url, &schemas).await { + Ok(t) => t, + Err(e) => { + print_error( + ErrorBody { + code: "database_error".to_string(), + message: e.to_string(), + hint: Some( + "Check the connection URL and that the role can read information_schema" + .to_string(), + ), + location: None, + details: vec![], + }, + pretty, + ); + } + }; + + let profile_mut = loaded + .raw + .databases + .get_mut(&database_name) + .expect("database profile exists"); + + let diff = compute_schema_diff( + &profile_mut.policy, + &discovered, + &schemas, + remove_stale, + ); + + if !diff.has_changes() { + print_success( + UpdateSchemaResult { + path: loaded.path.display().to_string(), + database: database_name.clone(), + new_tables: 0, + new_columns: 0, + removed_tables: 0, + removed_columns: 0, + updated: false, + }, + pretty, + )?; + return Ok(()); + } + + print_diff_summary(&diff, remove_stale); + + let decisions = if diff.has_additions() { + match collect_decisions(&diff, &mut StderrPrompter) { + Ok(d) => d, + Err(e) => bail!("failed to read access decisions: {e}"), + } + } else { + querygate::schema_update::UpdateDecisions::default() + }; + + let (new_tables, new_columns, removed_tables, removed_columns) = apply_schema_update( + &mut profile_mut.policy, + &diff, + &decisions, + remove_stale, + ); + + if let Err(e) = write_raw_config(&loaded.path, &loaded.raw) { + print_config_error(&e.to_string(), pretty); + } + + print_success( + UpdateSchemaResult { + path: loaded.path.display().to_string(), + database: database_name, + new_tables, + new_columns, + removed_tables, + removed_columns, + updated: true, + }, + pretty, + )?; + Ok(()) +} + fn cmd_databases(loaded: &querygate::config::LoadedConfig, pretty: bool) -> Result<()> { let databases: Vec = loaded .databases diff --git a/src/output.rs b/src/output.rs index c20b591..7a1da11 100644 --- a/src/output.rs +++ b/src/output.rs @@ -35,6 +35,17 @@ pub struct DatabasesResult { pub databases: Vec, } +#[derive(Debug, Serialize)] +pub struct UpdateSchemaResult { + pub path: String, + pub database: String, + pub new_tables: usize, + pub new_columns: usize, + pub removed_tables: usize, + pub removed_columns: usize, + pub updated: bool, +} + #[derive(Debug, Serialize)] pub struct InitResult { pub path: String, diff --git a/src/policy.rs b/src/policy.rs index da0cdbb..210f9da 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -166,6 +166,7 @@ impl TablePolicy { let columns = raw .columns + .unwrap_or_default() .into_iter() .map(|(name, col)| { let access = parse_access_option(col.access)?; diff --git a/src/schema_update.rs b/src/schema_update.rs new file mode 100644 index 0000000..16b43d2 --- /dev/null +++ b/src/schema_update.rs @@ -0,0 +1,510 @@ +use crate::config::{RawColumnPolicy, RawPolicy, RawTablePolicy}; +use crate::init::DiscoveredTable; +use crate::policy::{Access, normalize_ident, parse_table_key}; +use anyhow::Result; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::io::{self, BufRead, Write}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewColumnItem { + pub table_key: String, + pub column_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewTableItem { + pub table_key: String, + pub columns: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct SchemaDiff { + pub new_tables: Vec, + pub new_columns: Vec, + pub stale_tables: Vec, + pub stale_columns: Vec, +} + +impl SchemaDiff { + pub fn has_additions(&self) -> bool { + !self.new_tables.is_empty() || !self.new_columns.is_empty() + } + + pub fn has_stale(&self) -> bool { + !self.stale_tables.is_empty() || !self.stale_columns.is_empty() + } + + pub fn has_changes(&self) -> bool { + self.has_additions() || self.has_stale() + } + + pub fn new_column_count(&self) -> usize { + self.new_columns.len() + + self + .new_tables + .iter() + .map(|t| t.columns.len()) + .sum::() + } +} + +fn raw_table_access(table: &RawTablePolicy) -> Access { + table + .access + .as_deref() + .map(|s| Access::from_str(s).unwrap_or(Access::Denied)) + .unwrap_or(Access::Denied) +} + +fn table_in_introspection_scope(table_key: &str, default_schema: &str, schemas: &BTreeSet) -> bool { + let qualified = parse_table_key(table_key, default_schema); + schemas.contains(&qualified.schema) +} + +pub fn compute_schema_diff( + policy: &RawPolicy, + discovered: &[DiscoveredTable], + introspection_schemas: &[String], + remove_stale: bool, +) -> SchemaDiff { + let default_schema = &policy.default_schema; + let schema_set: BTreeSet = introspection_schemas + .iter() + .map(|s| normalize_ident(s)) + .collect(); + + let mut discovered_by_key: BTreeMap = BTreeMap::new(); + for table in discovered { + let key = format!("{}.{}", table.schema, table.name); + discovered_by_key.insert(key, table); + } + + let mut diff = SchemaDiff::default(); + + for (table_key, raw_table) in &policy.tables { + if !table_in_introspection_scope(table_key, default_schema, &schema_set) { + continue; + } + + if remove_stale && !discovered_by_key.contains_key(table_key) { + diff.stale_tables.push(table_key.clone()); + continue; + } + + if raw_table_access(raw_table) == Access::Denied { + continue; + } + + let Some(columns_map) = &raw_table.columns else { + continue; + }; + + let Some(db_table) = discovered_by_key.get(table_key) else { + continue; + }; + + let configured: BTreeSet = columns_map.keys().cloned().collect(); + for col in &db_table.columns { + if !configured.contains(&col.name) { + diff.new_columns.push(NewColumnItem { + table_key: table_key.clone(), + column_name: col.name.clone(), + }); + } + } + + if remove_stale { + let db_cols: BTreeSet = db_table.columns.iter().map(|c| c.name.clone()).collect(); + for col_name in configured { + if !db_cols.contains(&col_name) { + diff.stale_columns.push(NewColumnItem { + table_key: table_key.clone(), + column_name: col_name, + }); + } + } + } + } + + for (table_key, db_table) in &discovered_by_key { + if policy.tables.contains_key(table_key) { + continue; + } + + diff.new_tables.push(NewTableItem { + table_key: table_key.clone(), + columns: db_table.columns.iter().map(|c| c.name.clone()).collect(), + }); + } + + diff +} + +#[derive(Debug, Clone)] +pub struct ColumnDecision { + pub table_key: String, + pub column_name: String, + pub access: Access, +} + +#[derive(Debug, Default)] +pub struct UpdateDecisions { + pub columns: Vec, +} + +pub fn apply_schema_update( + policy: &mut RawPolicy, + diff: &SchemaDiff, + decisions: &UpdateDecisions, + remove_stale: bool, +) -> (usize, usize, usize, usize) { + let mut new_tables = 0usize; + let mut new_columns = 0usize; + let mut removed_tables = 0usize; + let mut removed_columns = 0usize; + + if remove_stale { + for table_key in &diff.stale_tables { + if policy.tables.remove(table_key).is_some() { + removed_tables += 1; + } + } + for item in &diff.stale_columns { + if let Some(table) = policy.tables.get_mut(&item.table_key) { + if let Some(columns) = table.columns.as_mut() { + if columns.remove(&item.column_name).is_some() { + removed_columns += 1; + } + } + } + } + } + + let decision_map: HashMap<(&str, &str), Access> = decisions + .columns + .iter() + .map(|d| ((d.table_key.as_str(), d.column_name.as_str()), d.access)) + .collect(); + + for new_table in &diff.new_tables { + let mut columns = BTreeMap::new(); + let mut any_allowed = false; + for col_name in &new_table.columns { + let access = decision_map + .get(&(new_table.table_key.as_str(), col_name.as_str())) + .copied() + .unwrap_or(Access::Denied); + if access.is_allowed() { + any_allowed = true; + } + columns.insert( + col_name.clone(), + RawColumnPolicy { + access: Some(access.as_str().to_string()), + }, + ); + new_columns += 1; + } + + policy.tables.insert( + new_table.table_key.clone(), + RawTablePolicy { + access: Some( + if any_allowed { + Access::Allowed + } else { + Access::Denied + } + .as_str() + .to_string(), + ), + default_column_access: Some(Access::Denied.as_str().to_string()), + columns: Some(columns), + }, + ); + new_tables += 1; + } + + for item in &diff.new_columns { + let access = decision_map + .get(&(item.table_key.as_str(), item.column_name.as_str())) + .copied() + .unwrap_or(Access::Denied); + + let table = policy + .tables + .get_mut(&item.table_key) + .expect("new column must belong to existing table"); + + let columns = table.columns.get_or_insert_with(BTreeMap::new); + columns.insert( + item.column_name.clone(), + RawColumnPolicy { + access: Some(access.as_str().to_string()), + }, + ); + new_columns += 1; + } + + (new_tables, new_columns, removed_tables, removed_columns) +} + +pub trait AccessPrompter { + fn prompt(&mut self, table_key: &str, column_name: &str) -> Result; +} + +pub struct StderrPrompter; + +impl AccessPrompter for StderrPrompter { + fn prompt(&mut self, table_key: &str, column_name: &str) -> Result { + prompt_access_stderr(table_key, column_name) + } +} + +pub fn collect_decisions( + diff: &SchemaDiff, + prompter: &mut dyn AccessPrompter, +) -> Result { + let mut decisions = UpdateDecisions::default(); + + for table in &diff.new_tables { + for col in &table.columns { + let access = prompter.prompt(&table.table_key, col)?; + decisions.columns.push(ColumnDecision { + table_key: table.table_key.clone(), + column_name: col.clone(), + access, + }); + } + } + + for item in &diff.new_columns { + let access = prompter.prompt(&item.table_key, &item.column_name)?; + decisions.columns.push(ColumnDecision { + table_key: item.table_key.clone(), + column_name: item.column_name.clone(), + access, + }); + } + + Ok(decisions) +} + +pub fn print_diff_summary(diff: &SchemaDiff, remove_stale: bool) { + let new_table_count = diff.new_tables.len(); + let new_column_count = diff.new_column_count(); + let mut parts = Vec::new(); + if new_table_count > 0 { + parts.push(format!( + "{new_table_count} new table{}", + if new_table_count == 1 { "" } else { "s" } + )); + } + if new_column_count > 0 { + parts.push(format!( + "{new_column_count} new column{}", + if new_column_count == 1 { "" } else { "s" } + )); + } + if remove_stale { + let stale_table_count = diff.stale_tables.len(); + let stale_column_count = diff.stale_columns.len(); + if stale_table_count > 0 { + parts.push(format!( + "{stale_table_count} stale table{}", + if stale_table_count == 1 { "" } else { "s" } + )); + } + if stale_column_count > 0 { + parts.push(format!( + "{stale_column_count} stale column{}", + if stale_column_count == 1 { "" } else { "s" } + )); + } + } + + if parts.is_empty() { + eprintln!("No schema changes identified."); + } else { + eprintln!("Found {}.", parts.join(" and ")); + } +} + +fn prompt_access_stderr(table_key: &str, column_name: &str) -> Result { + let stderr = io::stderr(); + let mut out = stderr.lock(); + loop { + write!( + out, + "{table_key}.{column_name} access [a]llowed/[d]enied (default: denied): " + )?; + out.flush()?; + + let mut line = String::new(); + io::stdin().lock().read_line(&mut line)?; + let answer = line.trim().to_ascii_lowercase(); + match answer.as_str() { + "" | "d" | "denied" => return Ok(Access::Denied), + "a" | "allowed" => return Ok(Access::Allowed), + _ => { + writeln!(out, "Invalid input; enter 'a' or 'd' (or press Enter for denied).")?; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::init::DiscoveredColumn; + + fn col(name: &str) -> DiscoveredColumn { + DiscoveredColumn { + name: name.into(), + data_type: "text".into(), + } + } + + fn table(schema: &str, name: &str, columns: Vec<&str>) -> DiscoveredTable { + DiscoveredTable { + schema: schema.into(), + name: name.into(), + columns: columns.into_iter().map(col).collect(), + } + } + + fn policy_with_tables(tables: BTreeMap) -> RawPolicy { + RawPolicy { + default_schema: "public".into(), + tables, + functions: vec![], + } + } + + fn allowed_table(columns: Option>) -> RawTablePolicy { + RawTablePolicy { + access: Some("allowed".into()), + default_column_access: Some("denied".into()), + columns, + } + } + + #[test] + fn detects_new_table() { + let policy = policy_with_tables(BTreeMap::new()); + let discovered = vec![table("public", "users", vec!["id", "email"])]; + let diff = compute_schema_diff(&policy, &discovered, &["public".into()], false); + assert_eq!(diff.new_tables.len(), 1); + assert_eq!(diff.new_tables[0].table_key, "public.users"); + assert_eq!(diff.new_tables[0].columns, vec!["id", "email"]); + } + + #[test] + fn skips_denied_table_columns() { + let mut tables = BTreeMap::new(); + tables.insert( + "public.secrets".into(), + RawTablePolicy { + access: Some("denied".into()), + default_column_access: Some("denied".into()), + columns: Some(BTreeMap::from([( + "token".into(), + RawColumnPolicy { + access: Some("allowed".into()), + }, + )])), + }, + ); + let policy = policy_with_tables(tables); + let discovered = vec![table("public", "secrets", vec!["token", "new_col"])]; + let diff = compute_schema_diff(&policy, &discovered, &["public".into()], false); + assert!(diff.new_columns.is_empty()); + } + + #[test] + fn skips_allowed_table_without_columns_entry() { + let mut tables = BTreeMap::new(); + tables.insert("public.users".into(), allowed_table(None)); + let policy = policy_with_tables(tables); + let discovered = vec![table("public", "users", vec!["id", "email"])]; + let diff = compute_schema_diff(&policy, &discovered, &["public".into()], false); + assert!(diff.new_columns.is_empty()); + } + + #[test] + fn detects_new_columns_on_allowed_table_with_explicit_columns() { + let mut tables = BTreeMap::new(); + tables.insert( + "public.users".into(), + allowed_table(Some(BTreeMap::from([( + "id".into(), + RawColumnPolicy { + access: Some("allowed".into()), + }, + )]))), + ); + let policy = policy_with_tables(tables); + let discovered = vec![table("public", "users", vec!["id", "email"])]; + let diff = compute_schema_diff(&policy, &discovered, &["public".into()], false); + assert_eq!(diff.new_columns.len(), 1); + assert_eq!(diff.new_columns[0].column_name, "email"); + } + + #[test] + fn detects_stale_when_remove_stale_enabled() { + let mut tables = BTreeMap::new(); + tables.insert( + "public.users".into(), + allowed_table(Some(BTreeMap::from([ + ( + "id".into(), + RawColumnPolicy { + access: Some("allowed".into()), + }, + ), + ( + "removed".into(), + RawColumnPolicy { + access: Some("allowed".into()), + }, + ), + ]))), + ); + tables.insert("public.legacy".into(), allowed_table(Some(BTreeMap::new()))); + let policy = policy_with_tables(tables); + let discovered = vec![table("public", "users", vec!["id"])]; + let diff = compute_schema_diff(&policy, &discovered, &["public".into()], true); + assert_eq!(diff.stale_tables, vec!["public.legacy"]); + assert_eq!(diff.stale_columns.len(), 1); + assert_eq!(diff.stale_columns[0].column_name, "removed"); + } + + #[test] + fn apply_adds_new_table_with_derived_access() { + let mut policy = policy_with_tables(BTreeMap::new()); + let diff = SchemaDiff { + new_tables: vec![NewTableItem { + table_key: "public.orders".into(), + columns: vec!["id".into(), "total".into()], + }], + ..Default::default() + }; + let mut decisions = UpdateDecisions::default(); + decisions.columns.push(ColumnDecision { + table_key: "public.orders".into(), + column_name: "id".into(), + access: Access::Allowed, + }); + decisions.columns.push(ColumnDecision { + table_key: "public.orders".into(), + column_name: "total".into(), + access: Access::Denied, + }); + + let (new_tables, _, _, _) = apply_schema_update(&mut policy, &diff, &decisions, false); + assert_eq!(new_tables, 1); + let table = policy.tables.get("public.orders").unwrap(); + assert_eq!(table.access.as_deref(), Some("allowed")); + assert!(table.columns.as_ref().unwrap().contains_key("id")); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 19026d8..c3b85d1 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -28,6 +28,23 @@ fn init_refuses_to_overwrite_existing_file() { assert!(stderr.contains("already exists") || stderr.contains("refusing to overwrite")); } +#[test] +fn update_schema_requires_config() { + let dir = TempDir::new().unwrap(); + let output = Command::new(bin()) + .args([ + "--config", + dir.path().join("missing.yaml").to_str().unwrap(), + "update-schema", + ]) + .output() + .expect("run querygate update-schema"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("config") || stderr.contains("not found")); +} + #[test] fn init_requires_url() { let dir = TempDir::new().unwrap(); diff --git a/tests/schema_update.rs b/tests/schema_update.rs new file mode 100644 index 0000000..8ddcf0c --- /dev/null +++ b/tests/schema_update.rs @@ -0,0 +1,156 @@ +use querygate::config::{ + RawColumnPolicy, RawConfig, RawDatabaseProfile, RawPolicy, RawTablePolicy, load_config, + resolve_update_include_schemas, serialize_raw_config, write_raw_config, +}; +use querygate::init::DiscoveredTable; +use querygate::policy::Access; +use querygate::schema_update::{ + ColumnDecision, SchemaDiff, UpdateDecisions, apply_schema_update, compute_schema_diff, +}; +use std::collections::BTreeMap; +use tempfile::TempDir; + +fn sample_multi_db_config() -> RawConfig { + RawConfig { + version: 1, + databases: BTreeMap::from([ + ( + "app".into(), + RawDatabaseProfile { + description: Some("App".into()), + url_env: "QUERYGATE_APP_DATABASE_URL".into(), + dialect: "postgres".into(), + statement_timeout_ms: 10_000, + max_rows: 1000, + policy: RawPolicy { + default_schema: "public".into(), + tables: BTreeMap::from([( + "public.users".into(), + RawTablePolicy { + access: Some("allowed".into()), + default_column_access: Some("denied".into()), + columns: Some(BTreeMap::from([( + "id".into(), + RawColumnPolicy { + access: Some("allowed".into()), + }, + )])), + }, + )]), + functions: vec!["count".into()], + }, + }, + ), + ( + "analytics".into(), + RawDatabaseProfile { + description: None, + url_env: "QUERYGATE_ANALYTICS_DATABASE_URL".into(), + dialect: "postgres".into(), + statement_timeout_ms: 15_000, + max_rows: 5000, + policy: RawPolicy { + default_schema: "analytics".into(), + tables: BTreeMap::from([( + "analytics.daily_revenue".into(), + RawTablePolicy { + access: Some("allowed".into()), + default_column_access: Some("allowed".into()), + columns: None, + }, + )]), + functions: vec![], + }, + }, + ), + ]), + } +} + +#[test] +fn resolve_update_schemas_includes_policy_table_schemas() { + let config = sample_multi_db_config(); + let policy = &config.databases["app"].policy; + let schemas = resolve_update_include_schemas(&[], "public", &policy.tables); + assert!(schemas.contains(&"public".to_string())); +} + +#[test] +fn apply_update_only_changes_selected_profile() { + let mut config = sample_multi_db_config(); + let analytics_before = config.databases["analytics"].policy.tables.clone(); + + let diff = SchemaDiff { + new_columns: vec![querygate::schema_update::NewColumnItem { + table_key: "public.users".into(), + column_name: "email".into(), + }], + ..Default::default() + }; + let decisions = UpdateDecisions { + columns: vec![ColumnDecision { + table_key: "public.users".into(), + column_name: "email".into(), + access: Access::Denied, + }], + }; + + apply_schema_update( + &mut config.databases.get_mut("app").unwrap().policy, + &diff, + &decisions, + false, + ); + + assert_eq!( + config.databases["analytics"].policy.tables, + analytics_before + ); + assert!(config.databases["app"] + .policy + .tables + .get("public.users") + .unwrap() + .columns + .as_ref() + .unwrap() + .contains_key("email")); +} + +#[test] +fn raw_config_round_trips_through_yaml() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("config.yaml"); + let config = sample_multi_db_config(); + write_raw_config(&path, &config).unwrap(); + let loaded = load_config(Some(&path)).unwrap(); + assert_eq!(loaded.databases.len(), 2); + let yaml = serialize_raw_config(&config).unwrap(); + assert!(yaml.contains("public.users")); +} + +#[test] +fn wildcard_table_without_columns_is_not_updated_for_new_db_columns() { + let policy = RawPolicy { + default_schema: "public".into(), + tables: BTreeMap::from([( + "public.users".into(), + RawTablePolicy { + access: Some("allowed".into()), + default_column_access: Some("allowed".into()), + columns: None, + }, + )]), + functions: vec![], + }; + let discovered = vec![DiscoveredTable { + schema: "public".into(), + name: "users".into(), + columns: vec![querygate::init::DiscoveredColumn { + name: "new_col".into(), + data_type: "text".into(), + }], + }]; + let diff = compute_schema_diff(&policy, &discovered, &["public".into()], false); + assert!(diff.new_columns.is_empty()); +}