Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// Include only these schemas (repeatable; default: default_schema and schemas from policy tables)
#[arg(long = "include-schema")]
include_schema: Vec<String>,

/// Remove policy tables/columns that no longer exist in the database
#[arg(long)]
remove_stale: bool,

/// Pretty-print JSON output
#[arg(long)]
pretty: bool,
},
}
91 changes: 80 additions & 11 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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,
Expand All @@ -23,7 +24,7 @@ fn default_version() -> u32 {
1
}

#[derive(Debug, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RawDatabaseProfile {
pub description: Option<String>,
pub url_env: String,
Expand All @@ -43,25 +44,25 @@ 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<String, RawTablePolicy>,
#[serde(default)]
pub functions: Vec<String>,
}

#[derive(Debug, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RawTablePolicy {
#[serde(default)]
pub access: Option<String>,
#[serde(default)]
pub default_column_access: Option<String>,
#[serde(default)]
pub columns: BTreeMap<String, RawColumnPolicy>,
/// Absent in YAML means do not track per-column policy for this table.
pub columns: Option<BTreeMap<String, RawColumnPolicy>>,
}

#[derive(Debug, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RawColumnPolicy {
#[serde(default)]
pub access: Option<String>,
Expand All @@ -72,7 +73,12 @@ pub struct LoadedConfig {
pub databases: BTreeMap<String, DatabaseProfile>,
}

pub fn load_config(explicit_path: Option<&Path>) -> Result<LoadedConfig> {
pub struct LoadedRawConfig {
pub path: PathBuf,
pub raw: RawConfig,
}

pub fn load_raw_config(explicit_path: Option<&Path>) -> Result<LoadedRawConfig> {
let path = resolve_config_path(explicit_path)?;
let contents = fs::read_to_string(&path)
.with_context(|| format!("failed to read config file {}", path.display()))?;
Expand All @@ -84,6 +90,15 @@ pub fn load_config(explicit_path: Option<&Path>) -> Result<LoadedConfig> {
bail!("config must define at least one database under `databases`");
}

Ok(LoadedRawConfig { path, raw })
}

pub fn load_config(explicit_path: Option<&Path>) -> Result<LoadedConfig> {
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<LoadedConfig> {
let mut databases = BTreeMap::new();
for (name, profile) in raw.databases {
databases.insert(
Expand All @@ -96,6 +111,60 @@ pub fn load_config(explicit_path: Option<&Path>) -> Result<LoadedConfig> {
Ok(LoadedConfig { path, databases })
}

pub fn serialize_raw_config(raw: &RawConfig) -> Result<String> {
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<String, RawTablePolicy>,
) -> Vec<String> {
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<String, RawDatabaseProfile>,
name: Option<&str>,
) -> Result<String> {
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 <name>. Available: {}",
names.join(", ")
)
}
}
}

pub fn resolve_config_path(explicit_path: Option<&Path>) -> Result<PathBuf> {
if let Some(path) = explicit_path {
return Ok(path.to_path_buf());
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ pub mod db;
pub mod init;
pub mod output;
pub mod policy;
pub mod schema_update;
pub mod sql;
154 changes: 151 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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<String>,
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<DatabaseInfo> = loaded
.databases
Expand Down
11 changes: 11 additions & 0 deletions src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ pub struct DatabasesResult {
pub databases: Vec<DatabaseInfo>,
}

#[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,
Expand Down
1 change: 1 addition & 0 deletions src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down
Loading
Loading