Add a config file for Diesel CLI, autorun print-schema

As discussed in #1621 and elsewhere, we'd like to replace
`infer_schema!` with a config file that re-writes your schema file
automatically when it changes.

This file will eventually be expanded to support most of the CLI args to
`print-schema`, and gain additional options to further customize the
output. However, this is the minimal first step.

I would like to change the default file generated by `diesel setup` to
include `file = "src/schema.rs"`, but before we do that I think we
should change the behavior on tables with no PK to print a warning that
it's being skipped instead of panicking.
This commit is contained in:
Sean Griffin 2018-04-10 12:06:15 -06:00
parent 76978b5bc9
commit ae22270600
10 changed files with 255 additions and 17 deletions

View File

@ -17,11 +17,13 @@ path = "src/main.rs"
[dependencies]
chrono = "0.4"
clap = "2.27"
clippy = { optional = true, version = "=0.0.185" }
diesel = { version = "~1.2.0", default-features = false }
dotenv = ">=0.8, <0.11"
infer_schema_internals = "~1.2.0"
clippy = { optional = true, version = "=0.0.185" }
migrations_internals = "~1.2.0"
serde = { version = "1.0.0", features = ["derive"] }
toml = "0.4.6"
url = { version = "1.4.0", optional = true }
[dev-dependencies]

10
diesel_cli/build.rs Normal file
View File

@ -0,0 +1,10 @@
use std::env;
fn main() {
if env::var("CARGO_PKG_VERSION").unwrap() == "1.3.0" {
panic!(
"Did you remember to publish documentation on the config file? \
If not go do it. And then delete this build script."
);
}
}

View File

@ -122,6 +122,17 @@ pub fn build_cli() -> App<'static, 'static> {
.help("Render documentation comments for tables and columns"),
);
let config_arg = Arg::with_name("CONFIG_FILE")
.long("config-file")
.help(
"The location of the configuration file to use. Falls back to the \
`DIESEL_CONFIG_FILE` environment variable if unspecified. Defaults \
to `diesel.toml` in your project root. See \
diesel.rs/guides/configuring-diesel-cli for documentation on this file.",
)
.global(true)
.takes_value(true);
App::new("diesel")
.version(env!("CARGO_PKG_VERSION"))
.setting(AppSettings::VersionlessSubcommands)
@ -129,6 +140,7 @@ pub fn build_cli() -> App<'static, 'static> {
"You can also run `diesel SUBCOMMAND -h` to get more information about that subcommand.",
)
.arg(database_arg)
.arg(config_arg)
.subcommand(migration_subcommand)
.subcommand(setup_subcommand)
.subcommand(database_subcommand)

41
diesel_cli/src/config.rs Normal file
View File

@ -0,0 +1,41 @@
use clap::ArgMatches;
use std::env;
use std::error::Error;
use std::fs;
use std::io::Read;
use std::path::PathBuf;
use toml;
use super::{find_project_root, handle_error};
#[derive(Deserialize)]
pub struct Config {
#[serde(default)]
pub print_schema: PrintSchema,
}
impl Config {
pub fn file_path(matches: &ArgMatches) -> PathBuf {
matches
.value_of("CONFIG_FILE")
.map(PathBuf::from)
.or_else(|| env::var_os("DIESEL_CONFIG_FILE").map(PathBuf::from))
.unwrap_or_else(|| {
find_project_root()
.unwrap_or_else(handle_error)
.join("diesel.toml")
})
}
pub fn read(matches: &ArgMatches) -> Result<Self, Box<Error>> {
let path = Self::file_path(matches);
let mut bytes = Vec::new();
fs::File::open(path)?.read_to_end(&mut bytes)?;
toml::from_slice(&bytes).map_err(Into::into)
}
}
#[derive(Default, Deserialize)]
pub struct PrintSchema {
pub file: Option<PathBuf>,
}

View File

@ -0,0 +1,2 @@
# For documentation on how to configure this file,
# see diesel.rs/guides/configuring-diesel-cli

View File

@ -17,9 +17,13 @@ extern crate diesel;
extern crate dotenv;
extern crate infer_schema_internals;
extern crate migrations_internals;
#[macro_use]
extern crate serde;
extern crate toml;
#[cfg(feature = "url")]
extern crate url;
mod config;
mod database_error;
#[macro_use]
mod database;
@ -32,10 +36,12 @@ use chrono::*;
use clap::{ArgMatches, Shell};
use migrations_internals::{self as migrations, MigrationConnection};
use std::any::Any;
use std::error::Error;
use std::io::stdout;
use std::path::{Path, PathBuf};
use std::{env, fs};
use self::config::Config;
use self::database_error::{DatabaseError, DatabaseResult};
use migrations_internals::TIMESTAMP_FORMAT;
@ -46,7 +52,7 @@ fn main() {
let matches = cli::build_cli().get_matches();
match matches.subcommand() {
("migration", Some(matches)) => run_migration_command(matches),
("migration", Some(matches)) => run_migration_command(matches).unwrap_or_else(handle_error),
("setup", Some(matches)) => run_setup_command(matches),
("database", Some(matches)) => run_database_command(matches),
("bash-completion", Some(matches)) => generate_bash_completion_command(matches),
@ -55,7 +61,7 @@ fn main() {
}
}
fn run_migration_command(matches: &ArgMatches) {
fn run_migration_command(matches: &ArgMatches) -> Result<(), Box<Error>> {
match matches.subcommand() {
("run", Some(_)) => {
let database_url = database::database_url(matches);
@ -63,7 +69,8 @@ fn run_migration_command(matches: &ArgMatches) {
call_with_conn!(
database_url,
migrations::run_pending_migrations_in_directory(&dir, &mut stdout())
).unwrap_or_else(handle_error);
)?;
regenerate_schema_if_file_specified(matches)?;
}
("revert", Some(_)) => {
let database_url = database::database_url(matches);
@ -71,19 +78,20 @@ fn run_migration_command(matches: &ArgMatches) {
call_with_conn!(
database_url,
migrations::revert_latest_migration_in_directory(&dir)
).unwrap_or_else(handle_error);
)?;
regenerate_schema_if_file_specified(matches)?;
}
("redo", Some(_)) => {
let database_url = database::database_url(matches);
let dir = migrations_dir(matches);
call_with_conn!(database_url, redo_latest_migration(&dir));
regenerate_schema_if_file_specified(matches)?;
}
("list", Some(_)) => {
let database_url = database::database_url(matches);
let dir = migrations_dir(matches);
let mut migrations =
call_with_conn!(database_url, migrations::mark_migrations_in_directory(&dir))
.unwrap_or_else(handle_error);
call_with_conn!(database_url, migrations::mark_migrations_in_directory(&dir))?;
migrations.sort_by_key(|&(ref m, _)| m.version().to_string());
@ -101,8 +109,8 @@ fn run_migration_command(matches: &ArgMatches) {
}
("pending", Some(_)) => {
let database_url = database::database_url(matches);
let result = call_with_conn!(database_url, migrations::any_pending_migrations);
println!("{:?}", result.unwrap());
let result = call_with_conn!(database_url, migrations::any_pending_migrations)?;
println!("{:?}", result);
}
("generate", Some(args)) => {
use std::io::Write;
@ -111,10 +119,10 @@ fn run_migration_command(matches: &ArgMatches) {
let version = migration_version(args);
let versioned_name = format!("{}_{}", version, migration_name);
let migration_dir = migrations_dir(matches).join(versioned_name);
fs::create_dir(&migration_dir).unwrap();
fs::create_dir(&migration_dir)?;
let migration_dir_relative =
convert_absolute_path_to_relative(&migration_dir, &env::current_dir().unwrap());
convert_absolute_path_to_relative(&migration_dir, &env::current_dir()?);
let up_path = migration_dir.join("up.sql");
println!(
@ -129,12 +137,13 @@ fn run_migration_command(matches: &ArgMatches) {
"Creating {}",
migration_dir_relative.join("down.sql").display()
);
let mut down = fs::File::create(down_path).unwrap();
down.write_all(b"-- This file should undo anything in `up.sql`")
.unwrap();
let mut down = fs::File::create(down_path)?;
down.write_all(b"-- This file should undo anything in `up.sql`")?;
}
_ => unreachable!("The cli parser should prevent reaching here"),
}
};
Ok(())
}
use std::fmt::Display;
@ -165,6 +174,7 @@ fn migrations_dir(matches: &ArgMatches) -> PathBuf {
fn run_setup_command(matches: &ArgMatches) {
let migrations_dir = create_migrations_dir(matches).unwrap_or_else(handle_error);
create_config_file(matches).unwrap_or_else(handle_error);
database::setup_database(matches, &migrations_dir).unwrap_or_else(handle_error);
}
@ -187,6 +197,17 @@ fn create_migrations_dir(matches: &ArgMatches) -> DatabaseResult<PathBuf> {
Ok(dir.to_owned())
}
fn create_config_file(matches: &ArgMatches) -> DatabaseResult<()> {
use std::io::Write;
let path = Config::file_path(matches);
if !path.exists() {
let mut file = fs::File::create(path)?;
file.write_all(include_bytes!("default_files/diesel.toml"))?;
}
Ok(())
}
fn run_database_command(matches: &ArgMatches) {
match matches.subcommand() {
("setup", Some(args)) => {
@ -327,6 +348,22 @@ fn run_infer_schema(matches: &ArgMatches) {
).map_err(handle_error::<_, ()>);
}
fn regenerate_schema_if_file_specified(matches: &ArgMatches) -> Result<(), Box<Error>> {
use print_schema::*;
let config = Config::read(matches)?;
if let Some(path) = config.print_schema.file {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let database_url = database::database_url(matches);
let mut file = fs::File::create(path)?;
print_schema::output_schema(&database_url, None, &Filtering::None, false, &mut file)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
extern crate tempdir;

View File

@ -1,6 +1,7 @@
use infer_schema_internals::*;
use std::error::Error;
use std::fmt::{self, Display, Formatter, Write};
use std::io::{self, stdout};
pub enum Filtering {
Whitelist(Vec<TableName>),
@ -25,6 +26,22 @@ pub fn run_print_schema(
schema_name: Option<&str>,
filtering: &Filtering,
include_docs: bool,
) -> Result<(), Box<Error>> {
output_schema(
database_url,
schema_name,
filtering,
include_docs,
&mut stdout(),
)
}
pub fn output_schema<W: io::Write>(
database_url: &str,
schema_name: Option<&str>,
filtering: &Filtering,
include_docs: bool,
out: &mut W,
) -> Result<(), Box<Error>> {
let table_names = load_table_names(database_url, schema_name)?
.into_iter()
@ -44,9 +61,9 @@ pub fn run_print_schema(
};
if let Some(schema_name) = schema_name {
print!("{}", ModuleDefinition(schema_name, definitions));
write!(out, "{}", ModuleDefinition(schema_name, definitions))?;
} else {
print!("{}", definitions);
write!(out, "{}", definitions)?;
}
Ok(())
}

View File

@ -311,3 +311,38 @@ fn migration_run_runs_pending_migrations_custom_migration_dir_2() {
);
assert!(db.table_exists("users"));
}
#[test]
fn migration_run_updates_schema_if_config_present() {
let p = project("migration_run_updates_schema_if_config_present")
.folder("migrations")
.file(
"diesel.toml",
r#"
[print_schema]
file = "src/my_schema.rs"
"#,
)
.build();
// Make sure the project is setup
p.command("setup").run();
p.create_migration(
"12345_create_users_table",
"CREATE TABLE users (id INTEGER PRIMARY KEY)",
"DROP TABLE users",
);
assert!(!p.has_file("src/my_schema.rs"));
let result = p.command("migration").arg("run").run();
assert!(result.is_success(), "Result was unsuccessful {:?}", result);
assert!(
result.stdout().contains("Running migration 12345"),
"Unexpected stdout {}",
result.stdout()
);
assert!(p.has_file("src/my_schema.rs"));
}

View File

@ -182,3 +182,63 @@ fn setup_works_with_migration_dir_by_env() {
assert!(!p.has_file("migrations"));
assert!(p.has_file("bar"));
}
#[test]
fn setup_creates_config_file() {
let p = project("setup_creates_config_file").build();
// Make sure the project builder didn't create the file
assert!(!p.has_file("diesel.toml"));
let result = p.command("setup").run();
assert!(result.is_success(), "Result was unsuccessful {:?}", result);
assert!(p.has_file("diesel.toml"));
assert!(
p.file_contents("diesel.toml")
.contains("diesel.rs/guides/configuring-diesel-cli")
);
}
#[test]
fn setup_can_take_config_file_by_env() {
let p = project("setup_can_take_config_file_by_env").build();
// Make sure the project builder didn't create the file
assert!(!p.has_file("diesel.toml"));
assert!(!p.has_file("foo"));
let result = p.command("setup").env("DIESEL_CONFIG_FILE", "foo").run();
assert!(result.is_success(), "Result was unsuccessful {:?}", result);
assert!(!p.has_file("diesel.toml"));
assert!(p.has_file("foo"));
assert!(
p.file_contents("foo")
.contains("diesel.rs/guides/configuring-diesel-cli")
);
}
#[test]
fn setup_can_take_config_file_by_param() {
let p = project("setup_can_take_config_file_by_param").build();
// Make sure the project builder didn't create the file
assert!(!p.has_file("diesel.toml"));
assert!(!p.has_file("foo"));
assert!(!p.has_file("bar"));
let result = p.command("setup")
.env("DIESEL_CONFIG_FILE", "foo")
.arg("--config-file=bar")
.run();
assert!(result.is_success(), "Result was unsuccessful {:?}", result);
assert!(!p.has_file("diesel.toml"));
assert!(!p.has_file("foo"));
assert!(p.has_file("bar"));
assert!(
p.file_contents("bar")
.contains("diesel.rs/guides/configuring-diesel-cli")
);
}

View File

@ -4,6 +4,7 @@ extern crate dotenv;
extern crate url;
use std::fs::{self, File};
use std::io::prelude::*;
use std::path::{Path, PathBuf};
use tempdir::TempDir;
@ -16,6 +17,7 @@ pub fn project(name: &str) -> ProjectBuilder {
pub struct ProjectBuilder {
name: String,
folders: Vec<String>,
files: Vec<(PathBuf, String)>,
}
impl ProjectBuilder {
@ -23,6 +25,7 @@ impl ProjectBuilder {
ProjectBuilder {
name: name.into(),
folders: Vec::new(),
files: Vec::new(),
}
}
@ -31,6 +34,11 @@ impl ProjectBuilder {
self
}
pub fn file(mut self, name: &str, contents: &str) -> Self {
self.files.push((name.into(), contents.into()));
self
}
pub fn build(self) -> Project {
let tempdir = TempDir::new(&self.name).unwrap();
@ -40,6 +48,13 @@ impl ProjectBuilder {
fs::create_dir(tempdir.path().join(folder)).unwrap();
}
for (file, contents) in self.files {
fs::File::create(tempdir.path().join(file))
.unwrap()
.write_all(contents.as_bytes())
.unwrap()
}
Project {
directory: tempdir,
name: self.name,
@ -118,6 +133,13 @@ impl Project {
self.directory.path().join(path).exists()
}
pub fn file_contents<P: AsRef<Path>>(&self, path: P) -> String {
let mut f = File::open(self.directory.path().join(path)).expect("Could not open file");
let mut result = String::new();
f.read_to_string(&mut result).expect("Could not read file");
result
}
#[cfg(feature = "postgres")]
pub fn delete_file<P: AsRef<Path>>(&self, path: P) {
let file = self.directory.path().join(path);