From d7077f74f16cccbf6b7d2b6ff06f3a725b808621 Mon Sep 17 00:00:00 2001 From: Matt Casper Date: Fri, 22 Jan 2016 13:44:20 -0800 Subject: [PATCH] Implement `diesel setup` for diesel CLI --- diesel/src/migrations/mod.rs | 3 +- diesel_cli/Cargo.toml | 4 + diesel_cli/README.md | 31 +++++-- diesel_cli/src/main.rs | 148 +++++++++++++++++++++++++++++++++- diesel_cli/src/setup_error.rs | 63 +++++++++++++++ 5 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 diesel_cli/src/setup_error.rs diff --git a/diesel/src/migrations/mod.rs b/diesel/src/migrations/mod.rs index df430affed93..c3aa3321ef24 100644 --- a/diesel/src/migrations/mod.rs +++ b/diesel/src/migrations/mod.rs @@ -135,7 +135,8 @@ fn migration_with_version(ver: &str) -> Result, MigrationError> { } } -fn create_schema_migrations_table_if_needed(conn: &Conn) -> QueryResult { +#[doc(hidden)] +pub fn create_schema_migrations_table_if_needed(conn: &Conn) -> QueryResult { conn.silence_notices(|| { conn.execute("CREATE TABLE IF NOT EXISTS __diesel_schema_migrations ( version VARCHAR PRIMARY KEY NOT NULL, diff --git a/diesel_cli/Cargo.toml b/diesel_cli/Cargo.toml index 3352552b9784..b1cd20d7ac6e 100644 --- a/diesel_cli/Cargo.toml +++ b/diesel_cli/Cargo.toml @@ -16,3 +16,7 @@ name = "diesel" diesel = "^0.4.0" clap = "^1.5.5" chrono = "^0.2.17" + +[dev-dependencies] +dotenv = "^0.6.0" +tempdir = "^0.3.4" diff --git a/diesel_cli/README.md b/diesel_cli/README.md index ee0aa6837b6b..2834eba0aba6 100644 --- a/diesel_cli/README.md +++ b/diesel_cli/README.md @@ -9,7 +9,7 @@ Getting Started ```shell cargo install diesel_cli -mkdir migrations +diesel setup --database-url='postgres://localhost/my_db' diesel migration generate create_users_table ``` @@ -42,8 +42,29 @@ Alternatively, you can call Diesel will automatically keep track of which migrations have already been run, ensuring that they're never run twice. -[pending-migrations]: http://sgrif.github.io/diesel/diesel/migrations/fn.run_pending_migrations.html +### Commands +#### `setup` +Searches for a `migrations/` directory, and if it can't find one, creates one +in the same directory as the first `Cargo.toml` it finds. It then tries to +connect to the provided DATABASE_URL, and will create the given database if it +cannot connect to it. Finally it will create diesel's internal table for +tracking which migrations have been run, and run any existing migrations if the +internal table did not previously exist. + +#### `migration generate` +Takes the name of your migration as an argument, and will create a migration +directory with `migrations/` in the format of +`migrations/{current_timestamp}_{migration_name}`. It will also generate +`up.sql` and `down.sql` files, for running your migration up and down +respectively. + +#### `migration run` +Runs all pending migrations, as determined by diesel's internal schema table. -If you ever need to revert or make changes to your migrations, the commands -`diesel migration revert` and `diesel migration redo`. Type `diesel migration ---help` for more information. +#### `migration revert` +Runs the `down.sql` for the most recent migration. + +#### `migration redo` +Runs the `down.sql` and then the `up.sql` for the most recent migration. + +[pending-migrations]: http://sgrif.github.io/diesel/diesel/migrations/fn.run_pending_migrations.html diff --git a/diesel_cli/src/main.rs b/diesel_cli/src/main.rs index 0430536c1314..22fb0fab427c 100644 --- a/diesel_cli/src/main.rs +++ b/diesel_cli/src/main.rs @@ -3,11 +3,16 @@ extern crate chrono; extern crate clap; extern crate diesel; +mod setup_error; + use chrono::*; use clap::{App, AppSettings, Arg, ArgMatches, SubCommand}; use diesel::{migrations, Connection}; +use diesel::connection::PgConnection; +use self::setup_error::SetupError; use std::{env, fs}; use std::error::Error; +use std::path::{PathBuf, Path}; fn main() { let database_arg = || Arg::with_name("DATABASE_URL") @@ -41,15 +46,22 @@ fn main() { ) ).setting(AppSettings::SubcommandRequiredElseHelp); + let setup_subcommand = SubCommand::with_name("setup") + .about("Creates the migrations directory, creates the database \ + specified in your DATABASE_URL, and runs existing migrations.") + .arg(database_arg()); + let matches = App::new("diesel") .version(env!("CARGO_PKG_VERSION")) .setting(AppSettings::VersionlessSubcommands) .subcommand(migration_subcommand) + .subcommand(setup_subcommand) .setting(AppSettings::SubcommandRequiredElseHelp) .get_matches(); match matches.subcommand() { ("migration", Some(matches)) => run_migration_command(matches), + ("setup", Some(matches)) => run_setup_command(matches), _ => unreachable!(), } } @@ -91,6 +103,74 @@ fn run_migration_command(matches: &ArgMatches) { } } +fn run_setup_command(matches: &ArgMatches) { + migrations::find_migrations_directory() + .unwrap_or_else(|_| + create_migrations_directory() + .map_err(handle_error).unwrap() + ); + + if PgConnection::establish(&database_url(matches)).is_err() { + create_database(database_url(matches)).unwrap_or_else(handle_error); + } + + let connection = connection(&database_url(matches)); + + if !schema_table_exists(&connection).map_err(handle_error).unwrap() { + migrations::create_schema_migrations_table_if_needed(&connection) + .map_err(handle_error).unwrap(); + migrations::run_pending_migrations(&connection) + .map_err(handle_error).unwrap(); + } +} + +/// Creates the database specified in the connection url. It returns a +/// `SetupError::ConnectionError` if it can't connect to the postgres +/// connection url, and returns a `SetupError::QueryError` if it is unable +/// to create the database. +fn create_database(database_url: String) -> Result<(), SetupError> { + let mut split: Vec<&str> = database_url.split("/").collect(); + let database = split.pop().unwrap(); + let postgres_url = split.join("/"); + let connection = try!(PgConnection::establish(&postgres_url)); + try!(connection.execute(&format!("CREATE DATABASE {};", database))); + Ok(()) +} + +/// Looks for a migrations directory in the current path and all parent paths, +/// and creates one in the same directory as the Cargo.toml if it can't find +/// one. It also sticks a .gitkeep in the directory so git will pick it up. +/// Returns a `SetupError::CargoTomlNotFound` if no Cargo.toml is found. +fn create_migrations_directory() -> Result { + let project_root = try!(find_project_root()); + try!(fs::create_dir(project_root.join("migrations"))); + try!(fs::File::create(project_root.join("migrations/.gitkeep"))); + Ok(project_root) +} + +fn find_project_root() -> Result { + search_for_cargo_toml_directory(&try!(env::current_dir())) +} + +fn search_for_cargo_toml_directory(path: &Path) -> Result { + let toml_path = path.join("Cargo.toml"); + if toml_path.is_file() { + Ok(path.to_owned()) + } else { + path.parent().map(search_for_cargo_toml_directory) + .unwrap_or(Err(SetupError::CargoTomlNotFound)) + } +} + +/// Returns true if the '__diesel_schema_migrations' table exists in the +/// database we connect to, returns false if it does not. +pub fn schema_table_exists(connection: &PgConnection) -> Result { + let result = try!(connection.execute("SELECT 1 + FROM information_schema.tables + WHERE table_name = '__diesel_schema_migrations';")); + Ok(result != 0) +} + fn handle_error(error: E) { println!("{}", error); std::process::exit(1); @@ -104,7 +184,71 @@ fn database_url(matches: &ArgMatches) -> String { or the DATABASE_URL environment variable must be set.") } -fn connection(database_url: &str) -> diesel::connection::PgConnection { - diesel::connection::PgConnection::establish(database_url) +fn connection(database_url: &str) -> PgConnection { + PgConnection::establish(database_url) .expect(&format!("Error connecting to {}", database_url)) } + +#[cfg(test)] +mod tests { + extern crate diesel; + extern crate dotenv; + extern crate tempdir; + + use self::tempdir::TempDir; + use self::diesel::Connection; + + use setup_error::SetupError; + + use super::{schema_table_exists, search_for_cargo_toml_directory}; + + use std::{env, fs}; + + fn connection() -> diesel::connection::PgConnection { + dotenv::dotenv().ok(); + let database_url = env::var("DATABASE_URL") + .expect("DATABASE_URL must be set in order to run diesel_cli tests"); + diesel::connection::PgConnection::establish(&database_url).unwrap() + } + + #[test] + fn toml_directory_find_cargo_toml() { + let dir = TempDir::new("diesel").unwrap(); + let temp_path = dir.path().canonicalize().unwrap(); + let toml_path = temp_path.join("Cargo.toml"); + + fs::File::create(&toml_path).unwrap(); + + assert_eq!(Ok(temp_path.clone()), search_for_cargo_toml_directory(&temp_path)); + } + + #[test] + fn cargo_toml_not_found_if_no_cargo_toml() { + let dir = TempDir::new("diesel").unwrap(); + let temp_path = dir.path().canonicalize().unwrap(); + + assert_eq!(Err(SetupError::CargoTomlNotFound), + search_for_cargo_toml_directory(&temp_path)); + } + + #[test] + fn schema_table_exists_finds_table() { + let connection = connection(); + connection.silence_notices(|| { + connection.execute("DROP TABLE IF EXISTS __diesel_schema_migrations").unwrap(); + connection.execute("CREATE TABLE __diesel_schema_migrations ()").unwrap(); + }); + + assert!(schema_table_exists(&connection).unwrap()); + } + + #[test] + fn schema_table_exists_doesnt_find_table() { + let connection = connection(); + connection.silence_notices(|| { + connection.execute("DROP TABLE IF EXISTS __diesel_schema_migrations").unwrap(); + }); + + assert!(!schema_table_exists(&connection).unwrap()); + } +} diff --git a/diesel_cli/src/setup_error.rs b/diesel_cli/src/setup_error.rs new file mode 100644 index 000000000000..4eb1f0d9f87d --- /dev/null +++ b/diesel_cli/src/setup_error.rs @@ -0,0 +1,63 @@ +use diesel::result; + +use std::convert::From; +use std::{fmt, io}; +use std::error::Error; + +use self::SetupError::*; + +#[derive(Debug)] +pub enum SetupError { + #[allow(dead_code)] + CargoTomlNotFound, + IoError(io::Error), + QueryError(result::Error), + ConnectionError(result::ConnectionError), +} + +impl From for SetupError { + fn from(e: io::Error) -> Self { + IoError(e) + } +} + +impl From for SetupError { + fn from(e: result::Error) -> Self { + QueryError(e) + } +} + +impl From for SetupError { + fn from(e: result::ConnectionError) -> Self { + ConnectionError(e) + } +} + +impl Error for SetupError { + fn description(&self) -> &str { + match *self { + CargoTomlNotFound => "Unable to find Cargo.toml in this directory or any parent directories.", + IoError(ref error) => error.cause().map(|e| e.description()).unwrap_or(error.description()), + QueryError(ref error) => error.cause().map(|e| e.description()).unwrap_or(error.description()), + ConnectionError(ref error) => error.cause().map(|e| e.description()).unwrap_or(error.description()), + } + } +} + +impl fmt::Display for SetupError { + fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { + self.description().fmt(f) + } +} + +impl PartialEq for SetupError { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + ( + &CargoTomlNotFound, + &CargoTomlNotFound, + ) => true, + _ => false + } + } +}