on iko/more-quotes help sign in
reshape/src/main.rs
257 lines 7.7 kB history raw
1use std::{
2 fs::{self, File},
3 io::Read,
4 path::Path,
5};
7use anyhow::Context;
8use clap::{Args, Parser};
9use reshape::{
10 migrations::{Action, Migration},
11 Reshape,
12};
13use serde::{Deserialize, Serialize};
15#[derive(Parser)]
16#[clap(name = "Reshape", version, about)]
17struct Opts {
18 #[clap(subcommand)]
19 cmd: Command,
22#[derive(Parser)]
23#[clap(about)]
24enum Command {
25 #[clap(subcommand)]
26 Migration(MigrationCommand),
28 #[clap(
29 about = "Output the query your application should use to select the right schema",
30 display_order = 2
31 )]
32 SchemaQuery(FindMigrationsOptions),
34 #[clap(
35 about = "Deprecated. Use `reshape schema-query` instead",
36 display_order = 3
37 )]
38 GenerateSchemaQuery(FindMigrationsOptions),
40 #[clap(
41 about = "Deprecated. Use `reshape migration start` instead",
42 display_order = 4
43 )]
44 Migrate(MigrateOptions),
45 #[clap(
46 about = "Deprecated. Use `reshape migration complete` instead",
47 display_order = 5
48 )]
49 Complete(ConnectionOptions),
50 #[clap(
51 about = "Deprecated. Use `reshape migration abort` instead",
52 display_order = 6
53 )]
54 Abort(ConnectionOptions),
57#[derive(Parser)]
58#[clap(about = "Commands for managing migrations", display_order = 1)]
59enum MigrationCommand {
60 #[clap(
61 about = "Starts a new migration, applying any migrations which haven't yet been applied",
62 display_order = 1
63 )]
64 Start(MigrateOptions),
66 #[clap(about = "Completes an in-progress migration", display_order = 2)]
67 Complete(ConnectionOptions),
69 #[clap(
70 about = "Aborts an in-progress migration without losing any data",
71 display_order = 3
72 )]
73 Abort(ConnectionOptions),
76#[derive(Args)]
77struct MigrateOptions {
78 // Some comment
79 #[clap(long, short)]
80 complete: bool,
81 #[clap(flatten)]
82 connection_options: ConnectionOptions,
83 #[clap(flatten)]
84 find_migrations_options: FindMigrationsOptions,
87#[derive(Parser)]
88struct ConnectionOptions {
89 #[clap(long)]
90 url: Option<String>,
91 #[clap(long, default_value = "localhost")]
92 host: String,
93 #[clap(long, default_value = "5432")]
94 port: u16,
95 #[clap(long, short, default_value = "postgres")]
96 database: String,
97 #[clap(long, short, default_value = "postgres")]
98 username: String,
99 #[clap(long, short, default_value = "postgres")]
100 password: String,
103#[derive(Parser)]
104struct FindMigrationsOptions {
105 #[clap(long, default_value = "migrations")]
106 dirs: Vec<String>,
109fn main() -> anyhow::Result<()> {
110 let opts: Opts = Opts::parse();
111 run(opts)
114fn run(opts: Opts) -> anyhow::Result<()> {
115 match opts.cmd {
116 Command::Migration(MigrationCommand::Start(opts)) | Command::Migrate(opts) => {
117 let mut reshape = reshape_from_connection_options(&opts.connection_options)?;
118 let migrations = find_migrations(&opts.find_migrations_options)?;
119 reshape.migrate(migrations)?;
121 // Automatically complete migration if --complete flag is set
122 if opts.complete {
123 reshape.complete()?;
124 }
126 Ok(())
127 }
128 Command::Migration(MigrationCommand::Complete(opts)) | Command::Complete(opts) => {
129 let mut reshape = reshape_from_connection_options(&opts)?;
130 reshape.complete()
131 }
132 Command::Migration(MigrationCommand::Abort(opts)) | Command::Abort(opts) => {
133 let mut reshape = reshape_from_connection_options(&opts)?;
134 reshape.abort()
135 }
136 Command::SchemaQuery(opts) | Command::GenerateSchemaQuery(opts) => {
137 let migrations = find_migrations(&opts)?;
138 let query = migrations
139 .last()
140 .map(|migration| reshape::schema_query_for_migration(&migration.name));
141 println!("{}", query.unwrap_or_else(|| "".to_string()));
143 Ok(())
144 }
145 }
148fn reshape_from_connection_options(opts: &ConnectionOptions) -> anyhow::Result<Reshape> {
149 // Load environment variables from .env file if it exists
150 dotenv::dotenv().ok();
152 let url_env = std::env::var("DB_URL").ok();
153 let url = url_env.as_ref().or_else(|| opts.url.as_ref());
155 // Use the connection URL if it has been set
156 if let Some(url) = url {
157 return Reshape::new(url);
158 }
160 let host_env = std::env::var("DB_HOST").ok();
161 let host = host_env.as_ref().unwrap_or_else(|| &opts.host);
163 let port = std::env::var("DB_PORT")
164 .ok()
165 .and_then(|port| port.parse::<u16>().ok())
166 .unwrap_or(opts.port);
168 let username_env = std::env::var("DB_USERNAME").ok();
169 let username = username_env.as_ref().unwrap_or_else(|| &opts.username);
171 let password_env = std::env::var("DB_PASSWORD").ok();
172 let password = password_env.as_ref().unwrap_or_else(|| &opts.password);
174 let database_env = std::env::var("DB_NAME").ok();
175 let database = database_env.as_ref().unwrap_or_else(|| &opts.database);
177 Reshape::new_with_options(host, port, database, username, password)
180fn find_migrations(opts: &FindMigrationsOptions) -> anyhow::Result<Vec<Migration>> {
181 let search_paths = opts
182 .dirs
183 .iter()
184 .map(Path::new)
185 // Filter out all directories that don't exist
186 .filter(|path| path.exists());
188 // Find all files in the search paths
189 let mut file_paths = Vec::new();
190 for search_path in search_paths {
191 let entries = fs::read_dir(search_path)?;
192 for entry in entries {
193 let path = entry?.path();
194 file_paths.push(path);
195 }
196 }
198 // Sort all files by their file names (without extension)
199 // The files are sorted naturally, e.g. "1_test_migration" < "10_test_migration"
200 file_paths.sort_unstable_by(|path1, path2| {
201 let file1 = path1.as_path().file_stem().unwrap().to_str().unwrap();
202 let file2 = path2.as_path().file_stem().unwrap().to_str().unwrap();
204 lexical_sort::natural_cmp(file1, file2)
205 });
207 file_paths
208 .iter()
209 .map(|path| {
210 let mut file = File::open(path)?;
212 // Read file data
213 let mut data = String::new();
214 file.read_to_string(&mut data)?;
216 Ok((path, data))
217 })
218 .map(|result| {
219 result.and_then(|(path, data)| {
220 let extension = path.extension().and_then(|ext| ext.to_str()).unwrap();
221 let file_migration =
222 decode_migration_file(&data, extension).with_context(|| {
223 format!("failed to parse migration file {}", path.display())
224 })?;
226 let file_name = path.file_stem().and_then(|name| name.to_str()).unwrap();
227 Ok(Migration {
228 name: file_migration.name.unwrap_or_else(|| file_name.to_string()),
229 description: file_migration.description,
230 actions: file_migration.actions,
231 })
232 })
233 })
234 .collect()
237fn decode_migration_file(data: &str, extension: &str) -> anyhow::Result<FileMigration> {
238 let migration: FileMigration = match extension {
239 "json" => serde_json::from_str(data)?,
240 "toml" => toml::from_str(data)?,
241 extension => {
242 return Err(anyhow::anyhow!(
243 "unrecognized file extension '{}'",
244 extension
245 ))
246 }
247 };
249 Ok(migration)
252#[derive(Serialize, Deserialize)]
253struct FileMigration {
254 name: Option<String>,
255 description: Option<String>,
256 actions: Vec<Box<dyn Action>>,