Skip to content

Commit 83e3474

Browse files
committed
Allow migrations to have attached metadata, avoid running in transaction
On PostgreSQL, certain commands cannot be run inside of a transaction, such as `CREATE INDEX CONCURRENTLY` and `ALTER TYPE`. This causes problems when attempting to use Diesel migrations, since we always run these migrations inside a transaction. To allow opting out of this, we introduce the concept of migration metadata. I've opted to keep this incredibly general, rather than adding a specific `should_run_in_migration` function, as there's been interest expressed in having this capability to enable alternate migration runners. Since we need to retain object safety, we can't actually have any defined structure for the metadata. The best we can do is either something that is conceptually `Map<String, String>`, or an enum like `serde_json::Value` or `toml::Value`. I really don't want to restrict what can or cannot be in here, so I've opted for the more general option.
1 parent 9199335 commit 83e3474

8 files changed

Lines changed: 193 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ for Rust libraries in [RFC #1105](https://github.com/rust-lang/rfcs/blob/master/
2323
* Added `sqlite-bundled` feature to `diesel_cli` to make installing on
2424
some platforms easier.
2525

26+
* Migrations can now opt out of being wrapped in a transaction by adding
27+
`metadata.toml` to their directory with `run_in_transaction = false`.
28+
2629
### Changed
2730

2831
* `sql_function!` has been redesigned. The syntax is now `sql_function!(fn

diesel/src/migration/errors.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,22 @@ use result;
1414
pub enum MigrationError {
1515
/// The migration directory wasn't found
1616
MigrationDirectoryNotFound,
17+
1718
/// Provided migration was in an unknown format
1819
UnknownMigrationFormat(PathBuf),
20+
1921
/// General system IO error
2022
IoError(io::Error),
23+
2124
/// Provided migration had an incompatible version number
2225
UnknownMigrationVersion(String),
26+
2327
/// No migrations had to be/ could be run
2428
NoMigrationRun,
25-
///
29+
30+
/// The metadatafile for a given migration was invalid
31+
InvalidMetadata(Box<Error>),
32+
2633
#[doc(hidden)]
2734
__NonExhaustive,
2835
}
@@ -33,20 +40,41 @@ impl Error for MigrationError {
3340
MigrationError::MigrationDirectoryNotFound => {
3441
"Unable to find migrations directory in this directory or any parent directories."
3542
}
43+
3644
MigrationError::UnknownMigrationFormat(_) => {
3745
"Invalid migration directory, the directory's name should be \
3846
<timestamp>_<name_of_migration>, and it should only contain up.sql and down.sql."
3947
}
48+
4049
MigrationError::IoError(ref error) => error.description(),
50+
4151
MigrationError::UnknownMigrationVersion(_) => {
4252
"Unable to find migration version to revert in the migrations directory."
4353
}
54+
4455
MigrationError::NoMigrationRun => {
4556
"No migrations have been run. Did you forget `diesel migration run`?"
4657
}
58+
59+
MigrationError::InvalidMetadata(_) => "Migration metadata was invalid",
60+
4761
MigrationError::__NonExhaustive => unreachable!(),
4862
}
4963
}
64+
65+
fn cause(&self) -> Option<&Error> {
66+
match *self {
67+
MigrationError::IoError(ref err) => Some(err),
68+
69+
MigrationError::InvalidMetadata(ref err) => Some(&**err),
70+
71+
MigrationError::MigrationDirectoryNotFound
72+
| MigrationError::UnknownMigrationFormat(_)
73+
| MigrationError::UnknownMigrationVersion(_)
74+
| MigrationError::NoMigrationRun
75+
| MigrationError::__NonExhaustive => None,
76+
}
77+
}
5078
}
5179

5280
impl fmt::Display for MigrationError {
@@ -83,11 +111,13 @@ impl From<io::Error> for MigrationError {
83111
pub enum RunMigrationsError {
84112
/// A general migration error occured
85113
MigrationError(MigrationError),
114+
86115
/// The provided migration included an invalid query
87116
QueryError(result::Error),
117+
88118
/// The provided migration was empty
89119
EmptyMigration,
90-
///
120+
91121
#[doc(hidden)]
92122
__NonExhaustive,
93123
}

diesel/src/migration/mod.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,28 @@ pub use self::errors::{MigrationError, RunMigrationsError};
55

66
use connection::SimpleConnection;
77
use std::path::Path;
8+
use std::borrow::Cow;
89

910
/// Represents a migration that interacts with diesel
1011
pub trait Migration {
1112
/// Get the migration version
1213
fn version(&self) -> &str;
14+
1315
/// Apply this migration
1416
fn run(&self, conn: &SimpleConnection) -> Result<(), RunMigrationsError>;
17+
1518
/// Revert this migration
1619
fn revert(&self, conn: &SimpleConnection) -> Result<(), RunMigrationsError>;
20+
1721
/// Get the migration file path
1822
fn file_path(&self) -> Option<&Path> {
1923
None
2024
}
25+
26+
/// Get the metadata associated with this migration, if any
27+
fn metadata(&self) -> Option<&Metadata> {
28+
None
29+
}
2130
}
2231

2332
impl Migration for Box<Migration> {
@@ -32,9 +41,14 @@ impl Migration for Box<Migration> {
3241
fn revert(&self, conn: &SimpleConnection) -> Result<(), RunMigrationsError> {
3342
(&**self).revert(conn)
3443
}
44+
3545
fn file_path(&self) -> Option<&Path> {
3646
(&**self).file_path()
3747
}
48+
49+
fn metadata(&self) -> Option<&Metadata> {
50+
(&**self).metadata()
51+
}
3852
}
3953

4054
impl<'a> Migration for &'a Migration {
@@ -49,7 +63,27 @@ impl<'a> Migration for &'a Migration {
4963
fn revert(&self, conn: &SimpleConnection) -> Result<(), RunMigrationsError> {
5064
(&**self).revert(conn)
5165
}
66+
5267
fn file_path(&self) -> Option<&Path> {
5368
(&**self).file_path()
5469
}
70+
71+
fn metadata(&self) -> Option<&Metadata> {
72+
(&**self).metadata()
73+
}
74+
}
75+
76+
/// Represents metadata associated with a migration.
77+
///
78+
/// The format of a migration's metadata is dependent on the migration format
79+
/// being used.
80+
///
81+
/// For Diesel's built in SQL file migrations, metadata is stored in a file
82+
/// called `metadata.toml`. Diesel looks for a single key, `run_in_transaction`.
83+
/// By default, all migrations are run in a transaction on SQLite and
84+
/// PostgreSQL. This behavior can be disabled for a single migration by setting
85+
/// this to `false`.
86+
pub trait Metadata {
87+
/// Get the metadata at the given key, if present
88+
fn get(&self, key: &str) -> Option<Cow<str>>;
5589
}

diesel_cli/tests/migration_run.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,3 +346,41 @@ file = "src/my_schema.rs"
346346
);
347347
assert!(p.has_file("src/my_schema.rs"));
348348
}
349+
350+
#[test]
351+
#[cfg(feature = "postgres")]
352+
fn migrations_can_be_run_without_transactions() {
353+
let p = project("migration_run_without_transaction").build();
354+
let db = database(&p.database_url());
355+
356+
// Make sure the project is setup
357+
p.command("setup").run();
358+
359+
p.create_migration(
360+
"12345_create_users_table",
361+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
362+
"DROP TABLE users",
363+
);
364+
365+
// CREATE INDEX CONCURRENTLY cannot be run inside a transaction.
366+
// It also can't be run in a multiline statement, so it needs its own
367+
// migration.
368+
p.create_migration(
369+
"12346_index_users_table",
370+
"CREATE UNIQUE INDEX CONCURRENTLY idx ON users (name)",
371+
"DROP INDEX idx",
372+
);
373+
p.add_migration_metadata("12346_index_users_table", "run_in_transaction = false");
374+
375+
assert!(!db.table_exists("users"));
376+
377+
let result = p.command("migration").arg("run").run();
378+
379+
assert!(result.is_success(), "Result was unsuccessful {:?}", result);
380+
assert!(
381+
result.stdout().contains("Running migration 12346"),
382+
"Unexpected stdout {}",
383+
result.stdout()
384+
);
385+
assert!(db.table_exists("users"));
386+
}

diesel_cli/tests/support/project_builder.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,17 @@ impl Project {
166166
let mut down_file = fs::File::create(&migration_path.join("down.sql")).unwrap();
167167
down_file.write_all(down.as_bytes()).unwrap();
168168
}
169+
170+
#[cfg(feature = "postgres")]
171+
pub fn add_migration_metadata(&self, name: &str, metadata: &str) {
172+
use std::io::Write;
173+
174+
let migration_path = self.directory.path().join("migrations").join(name);
175+
let mut file = fs::File::create(&migration_path.join("metadata.toml"))
176+
.expect("Could not create migration metadata file");
177+
file.write_all(metadata.as_bytes())
178+
.expect("Could not write to migration metadata file");
179+
}
169180
}
170181

171182
#[cfg(not(feature = "sqlite"))]

diesel_migrations/migrations_internals/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ homepage = "http://diesel.rs"
1010
clippy = { optional = true, version = "=0.0.195" }
1111
diesel = { version = "~1.2.0", default-features = false }
1212
barrel = { version = "<= 0.2.0", optional = true, features = ["diesel-filled"] }
13+
toml = "0.4.6"
1314

1415
[dev-dependencies]
1516
tempdir = "0.3.4"

diesel_migrations/migrations_internals/src/lib.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
//! ```
7777
#[macro_use]
7878
extern crate diesel;
79+
extern crate toml;
7980

8081
#[cfg(feature = "barrel")]
8182
extern crate barrel;
@@ -320,14 +321,33 @@ fn run_migration<Conn>(
320321
where
321322
Conn: MigrationConnection,
322323
{
323-
conn.transaction(|| {
324+
let mut run_migration = || {
324325
if migration.version() != "00000000000000" {
325326
try!(writeln!(output, "Running migration {}", name(&migration)));
326327
}
327328
try!(migration.run(conn));
328329
try!(conn.insert_new_migration(migration.version()));
329330
Ok(())
330-
})
331+
};
332+
333+
let run_in_transaction = migration
334+
.metadata()
335+
.and_then(|m| m.get("run_in_transaction"));
336+
let run_in_transaction = match run_in_transaction.as_ref().map(|s| s.as_ref()) {
337+
Some("true") => true,
338+
Some("false") | None => false,
339+
Some(_) => {
340+
return Err(MigrationError::InvalidMetadata(
341+
"run_in_transaction must be a boolean".into(),
342+
).into())
343+
}
344+
};
345+
346+
if run_in_transaction {
347+
conn.transaction(run_migration)
348+
} else {
349+
run_migration().map_err(Into::into)
350+
}
331351
}
332352

333353
fn revert_migration<Conn: Connection>(

diesel_migrations/migrations_internals/src/migration.rs

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
use diesel::connection::SimpleConnection;
22
use diesel::migration::*;
33

4-
use std::path::{Path, PathBuf};
4+
use std::borrow::Cow;
55
use std::fmt;
6+
use std::fs::File;
7+
use std::io::Read;
8+
use std::path::{Path, PathBuf};
9+
use toml;
610

711
#[allow(missing_debug_implementations)]
812
#[derive(Clone, Copy)]
@@ -38,7 +42,7 @@ pub fn migration_from(path: PathBuf) -> Result<Box<Migration>, MigrationError> {
3842

3943
if valid_sql_migration_directory(&path) {
4044
let version = try!(version_from_path(&path));
41-
Ok(Box::new(SqlFileMigration(path, version)))
45+
SqlFileMigration::new(path, version).map(|m| Box::new(m) as _)
4246
} else {
4347
Err(MigrationError::UnknownMigrationFormat(path))
4448
}
@@ -82,26 +86,53 @@ pub fn version_from_path(path: &Path) -> Result<String, MigrationError> {
8286
.unwrap_or_else(|| Err(MigrationError::UnknownMigrationFormat(path.to_path_buf())))
8387
}
8488

85-
use std::fs::File;
86-
use std::io::Read;
89+
struct SqlFileMigration {
90+
directory: PathBuf,
91+
version: String,
92+
metadata: Option<TomlMetadata>,
93+
}
8794

88-
struct SqlFileMigration(PathBuf, String);
95+
impl SqlFileMigration {
96+
fn new(directory: PathBuf, version: String) -> Result<Self, MigrationError> {
97+
let metadata_path = directory.join("metadata.toml");
98+
let metadata = if metadata_path.exists() {
99+
let mut buf = Vec::new();
100+
let mut file = File::open(metadata_path)?;
101+
file.read_to_end(&mut buf)?;
102+
let value =
103+
toml::from_slice(&buf).map_err(|e| MigrationError::InvalidMetadata(e.into()))?;
104+
Some(TomlMetadata(value))
105+
} else {
106+
None
107+
};
108+
109+
Ok(Self {
110+
directory,
111+
version,
112+
metadata,
113+
})
114+
}
115+
}
89116

90117
impl Migration for SqlFileMigration {
91118
fn file_path(&self) -> Option<&Path> {
92-
Some(self.0.as_path())
119+
Some(&self.directory)
93120
}
94121

95122
fn version(&self) -> &str {
96-
&self.1
123+
&self.version
97124
}
98125

99126
fn run(&self, conn: &SimpleConnection) -> Result<(), RunMigrationsError> {
100-
run_sql_from_file(conn, &self.0.join("up.sql"))
127+
run_sql_from_file(conn, &self.directory.join("up.sql"))
101128
}
102129

103130
fn revert(&self, conn: &SimpleConnection) -> Result<(), RunMigrationsError> {
104-
run_sql_from_file(conn, &self.0.join("down.sql"))
131+
run_sql_from_file(conn, &self.directory.join("down.sql"))
132+
}
133+
134+
fn metadata(&self) -> Option<&Metadata> {
135+
self.metadata.as_ref().map(|m| m as _)
105136
}
106137
}
107138

@@ -118,6 +149,18 @@ fn run_sql_from_file(conn: &SimpleConnection, path: &Path) -> Result<(), RunMigr
118149
Ok(())
119150
}
120151

152+
struct TomlMetadata(toml::Value);
153+
154+
impl Metadata for TomlMetadata {
155+
fn get(&self, key: &str) -> Option<Cow<str>> {
156+
self.0.get(key).map(|v| {
157+
v.as_str()
158+
.map(Into::into)
159+
.unwrap_or_else(|| v.to_string().into())
160+
})
161+
}
162+
}
163+
121164
#[cfg(test)]
122165
mod tests {
123166
extern crate tempdir;

0 commit comments

Comments
 (0)