Skip to content

Commit fccfc37

Browse files
authored
Add multi-database support to Nandi (#146)
* Add multi-database support to Nandi config (#139) * Support multi-db in Config Fix specs and paths Update comment to use correct class Update comment and remove unnecessary imports Make multi database interface private Fix database specs and config Fix delegation * Unify database config * Lint, update specs and delegation * Support multi-db in Nandi Lockfile (#142) * Support multi-db in Nandi Lockfile Fix bugs, lint Clean up specs Fix rubocop violations * Instantiate lockfile by database Fix rubocop violations * Add Nandi::Lockfile.for interface for managing multiple lock files Reduce diffs in lockfile Remove unnecessary spec * Add comments for lockfile .for method and default value for db_name in initialiser * Remove global nandilock file File mocking * Remove named argument from `Lockfile.get` method Remove named arg from safe migration enforcer * Refactor safe_migration_enforcer to support multi databases (#145) * Support validating migrations for multi-db mode * Do not re-order constants * Refactor safe migration enforcer * Use separate MigrationViolations class to store state * Support compiling migrations for multiple databases (#143) * Remove named argument from `Lockfile.get` method * Update compile generator args Pass file_name, database to Nandi.compile, CompiledMigration Update compiled_migration specs Remove unused function Lint changes Add compile generator spec and fix types * Update Nandi mocking in specs * Always cast db name to symbol Remove unnecessary call * Add default argument * Add ability to create safe migrations for multiple databases (#144) * Add multi-database support to generators * Update usage docs and add specs Remove update to compile spec Update specs * Clean up usage docs * Add default value to generator base class * Bump nandi to next major version * Update readme with multi-database functionality * Fix path require issues and standardise name handling Fix rubocop issues * Use alias instead of `config` * Always return a set in safe migration enforcer * Remove duplicate violations from enforcer
1 parent b5cfa72 commit fccfc37

33 files changed

+2255
-340
lines changed

README.md

Lines changed: 152 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,8 @@ Some schema changes need to be split across two migration files. Whenever you wa
336336

337337
For some of the most common cases, we provide a Rails generator that generates both files for you.
338338

339+
All generators support multi-database mode with the `--database` option (see Multi-Database Support section below).
340+
339341
### Not-null checks
340342

341343
To generate migration files for a not-null check, run this command:
@@ -394,12 +396,23 @@ rails generate nandi:foreign_key foos bar --name my_fk
394396
Nandi can be configured in various ways, typically in an initializer:
395397

396398
```rb
399+
# Single database configuration
397400
Nandi.configure do |config|
401+
config.migration_directory = "db/safe_migrations"
398402
config.lock_timeout = 1_000
399403
end
404+
405+
# Multi-database configuration (see Multi-Database Support section below)
406+
Nandi.configure do |config|
407+
config.register_database(:primary)
408+
config.register_database(:analytics,
409+
migration_directory: "db/analytics_safe_migrations",
410+
output_directory: "db/analytics_migrate"
411+
)
412+
end
400413
```
401414

402-
The configuration parameters are as follows.
415+
The configuration parameters are as follows for setting Nandi up for a single database.
403416

404417
### `access_exclusive_lock_timeout_limit` (Integer)
405418

@@ -419,11 +432,11 @@ The default lock timeout for migrations. Can be overridden by way of the `set_lo
419432

420433
### `migration_directory` (String)
421434

422-
The directory for Nandi migrations. Default: `db/safe_migrations`
435+
The directory for Nandi migrations. Default: `db/safe_migrations`.
423436

424437
### `output_directory` (String)
425438

426-
The directory for output files. Default: `db/migrate`
439+
The directory for output files. Default: `db/migrate`.
427440

428441
### `renderer` (Class)
429442

@@ -470,6 +483,142 @@ Parameters:
470483

471484
`klass` (Class) — The class to initialise with the arguments to the method. It should define a `template` instance method which will return a subclass of Cell::ViewModel from the Cells templating library and a `procedure` method that returns the name of the method. It may optionally define a `mixins` method, which will return an array of `Module`s to be mixed into any migration that uses this method.
472485

486+
## Multi-Database Support
487+
488+
Nandi 2.0+ supports managing migrations for multiple databases within a single Rails application.
489+
490+
**Note:** Single database configurations continue to work without any changes. Multi-database support is fully backward compatible.
491+
492+
### Configuring Multiple Databases
493+
494+
Instead of setting configuration values directly, register each database with its own configuration. If no values are specified, the existing defaults will be used.
495+
496+
**Database-specific options** (passed to `register_database`):
497+
These options can be set individually for each database. **All are optional** - if not specified, the standard Nandi defaults are used:
498+
499+
- `migration_directory`: Where Nandi migrations are stored (default: `"db/safe_migrations"` for primary, `"db/<name>_safe_migrations"` for others)
500+
- `output_directory`: Where compiled ActiveRecord migrations go (default: `"db/migrate"` for primary, `"db/<name>_migrate"` for others)
501+
- `lockfile_name`: Name of the lockfile for this database (default: `".nandilock.yml"` for primary, `".<name>_nandilock.yml"` for others)
502+
- `default`: Mark this database as the default when not named `:primary` (default: `false`)
503+
- `access_exclusive_lock_timeout`: Timeout for ACCESS EXCLUSIVE locks (default: 5,000ms)
504+
- `access_exclusive_statement_timeout`: Statement timeout for ACCESS EXCLUSIVE operations (default: 1,500ms)
505+
- `access_exclusive_lock_timeout_limit`: Maximum allowed lock timeout (default: 5,000ms)
506+
- `access_exclusive_statement_timeout_limit`: Maximum allowed statement timeout (default: 1,500ms)
507+
- `concurrent_lock_timeout_limit`: Minimum timeout for concurrent operations (default: 3,600,000ms / 1 hour)
508+
- `concurrent_statement_timeout_limit`: Minimum statement timeout for concurrent operations (default: 3,600,000ms / 1 hour)
509+
510+
**Global options** (set via config accessors):
511+
512+
These options apply to all databases:
513+
514+
- `config.lockfile_directory`: Directory where all lockfiles are stored (default: `"db"`)
515+
- `config.compile_files`: Filter for which files to compile (default: `"all"`)
516+
- `config.renderer`: Rendering backend (default: `Nandi::Renderers::ActiveRecord`)
517+
- `config.post_process { |migration| ... }`: Optional post-processing block for formatting
518+
519+
```rb
520+
# Minimal configuration - primary uses all defaults
521+
Nandi.configure do |config|
522+
config.register_database(:primary) # Uses all default paths and settings
523+
524+
config.register_database(:analytics)
525+
526+
# Global options (apply to all databases)
527+
config.lockfile_directory = "db"
528+
config.compile_files = "all"
529+
end
530+
531+
# Full example with both database-specific and global options
532+
Nandi.configure do |config|
533+
# Primary database (automatically becomes default)
534+
# If no values are specified, uses the standard defaults:
535+
# - migration_directory: "db/safe_migrations"
536+
# - output_directory: "db/migrate"
537+
# - lockfile_name: ".nandilock.yml"
538+
# - All timeout values use their defaults
539+
config.register_database(:primary,
540+
access_exclusive_lock_timeout: 5_000 # Only override what you need
541+
)
542+
543+
# Analytics database with custom paths and relaxed timeouts
544+
config.register_database(:analytics,
545+
migration_directory: "db/analytics_safe_migrations",
546+
output_directory: "db/analytics_migrate",
547+
lockfile_name: ".analytics_nandilock.yml",
548+
access_exclusive_lock_timeout: 30_000,
549+
access_exclusive_statement_timeout: 10_000,
550+
concurrent_statement_timeout_limit: 7_200_000
551+
)
552+
553+
# Global configuration options (apply to all databases)
554+
config.lockfile_directory = "db" # Where all lockfiles are stored
555+
config.compile_files = "all" # Filter for compilation
556+
config.renderer = Nandi::Renderers::ActiveRecord # Optional, this is the default
557+
558+
# Optional post-processing for all compiled migrations
559+
config.post_process do |migration|
560+
# Format, lint, etc.
561+
migration
562+
end
563+
end
564+
```
565+
566+
### Directory Structure
567+
568+
Each database maintains its own directory structure. The primary database uses the default paths if not specified:
569+
570+
```
571+
db/
572+
├── safe_migrations/ # Primary database (default path)
573+
│ └── 20250901_add_users.rb
574+
├── migrate/ # Primary database (default path)
575+
│ └── 20250901_add_users.rb
576+
├── .nandilock.yml # Primary database (default)
577+
578+
├── analytics_safe_migrations/ # Analytics database
579+
│ └── 20250902_add_events.rb
580+
├── analytics_migrate/
581+
│ └── 20250902_add_events.rb
582+
├── .analytics_nandilock.yml
583+
584+
├── reporting_safe_migrations/ # Reporting database
585+
│ └── 20250903_add_reports.rb
586+
├── reporting_migrate/
587+
│ └── 20250903_add_reports.rb
588+
└── .reporting_nandilock.yml
589+
```
590+
591+
### Using Generators with Multiple Databases
592+
593+
All Nandi generators accept a `--database` option to specify which database to target:
594+
595+
```bash
596+
# Generate for primary database (default)
597+
rails generate nandi:migration create_users_table
598+
599+
# Generate for analytics database
600+
rails generate nandi:migration create_events_table --database=analytics
601+
```
602+
603+
### Compiling Migrations
604+
605+
The compile generator can compile all databases or a specific one:
606+
607+
```bash
608+
# Compile all databases
609+
rails generate nandi:compile
610+
611+
# Compile specific database with filter
612+
rails generate nandi:compile --database=analytics --files=git-diff
613+
```
614+
615+
### Default Database
616+
617+
- If you register a database named `:primary`, it automatically becomes the default per rails conventions
618+
- Otherwise, mark a database as default with `default: true`
619+
- Generators without `--database` option use the default database
620+
- Single database configurations work without changes
621+
473622
## `.nandiignore`
474623

475624
To protect people from writing unsafe migrations, we provide a script [`nandi-enforce`](https://github.com/gocardless/nandi/blob/master/exe/nandi-enforce) that ensures all migrations in the specified directories are safe migrations generated by Nandi.

lib/generators/nandi/check_constraint/USAGE

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ Example:
99
db/safe_migrations/20190424123727_add_check_constraint_baz_or_quux_not_null_on_foos.rb
1010
db/safe_migrations/20190424123728_validate_check_constraint_baz_or_quux_not_null_on_foos.rb
1111

12+
If no --database option is specified, uses the default database.
13+
1214
Example:
1315
rails generate nandi:check_constraint foos baz_or_quux_not_null --validation-timeout 20000
1416

@@ -17,3 +19,10 @@ Example:
1719
db/safe_migrations/20190424123728_validate_check_constraint_baz_or_quux_not_null_on_foos.rb
1820

1921
The statement timeout in the second migration will be set to 20,000ms.
22+
23+
Multi-database examples:
24+
rails generate nandi:check_constraint users email_valid --database primary
25+
creates check constraint migrations in primary database directory
26+
27+
rails generate nandi:check_constraint events status_valid --database analytics
28+
creates check constraint migrations in analytics database directory

lib/generators/nandi/check_constraint/check_constraint_generator.rb

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
require "rails/generators"
44
require "nandi/formatting"
5+
require "nandi/multi_db_generator"
56

67
module Nandi
78
class CheckConstraintGenerator < Rails::Generators::Base
89
include Nandi::Formatting
10+
include Nandi::MultiDbGenerator
911

1012
argument :table, type: :string
1113
argument :name, type: :string
@@ -41,10 +43,6 @@ def validate_check_constraint
4143

4244
private
4345

44-
def base_path
45-
Nandi.config.migration_directory || "db/safe_migrations"
46-
end
47-
4846
def timestamp(offset = 0)
4947
(Time.now.utc + offset).strftime("%Y%m%d%H%M%S")
5048
end

lib/generators/nandi/compile/USAGE

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
Description:
2-
Takes all files from the db/nandi directory and turns
3-
them into ActiveRecordmigration files.
2+
Takes all files from the safe migration directories and turns
3+
them into ActiveRecord migration files. Supports both single
4+
and multi-database configurations.
5+
6+
If no --database option is specified, compiles for all configured databases
7+
(or the default database in single-database configurations).
48

59
Examples:
610
rails generate nandi:compile
711
compiles all migrations that have changed since last git commit
12+
(for all configured databases in multi-database setups)
813

914
rails generate nandi:compile --files all
1015
compiles all migrations ever
@@ -17,3 +22,10 @@ Examples:
1722

1823
rails generate nandi:compile --files >=20190101
1924
compiles all migrations from January 1st 2019 and after
25+
26+
Multi-database examples:
27+
rails generate nandi:compile --database primary
28+
compiles migrations only for the primary database
29+
30+
rails generate nandi:compile --database analytics
31+
compiles migrations only for the analytics database

lib/generators/nandi/compile/compile_generator.rb

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ module Nandi
1010
class CompileGenerator < Rails::Generators::Base
1111
source_root File.expand_path("templates", __dir__)
1212

13+
class_option :database,
14+
type: :string,
15+
default: nil,
16+
desc: "Database to compile. " \
17+
"If not specified, compiles for all databases"
18+
1319
class_option :files,
1420
type: :string,
1521
default: Nandi.config.compile_files,
@@ -22,41 +28,38 @@ class CompileGenerator < Rails::Generators::Base
2228
DESC
2329

2430
def compile_migration_files
25-
Nandi.compile(files: files) do |results|
26-
results.each do |result|
27-
Nandi::Lockfile.add(
28-
file_name: result.file_name,
29-
source_digest: result.source_digest,
30-
compiled_digest: result.compiled_digest,
31-
)
32-
33-
unless result.migration_unchanged?
34-
create_file result.output_path, result.body, force: true
31+
databases.each do |db_name|
32+
Nandi.compile(files: files(db_name), db_name: db_name) do |results|
33+
results.each do |result|
34+
Nandi::Lockfile.for(db_name).add(
35+
file_name: result.file_name,
36+
source_digest: result.source_digest,
37+
compiled_digest: result.compiled_digest,
38+
)
39+
unless result.migration_unchanged?
40+
create_file result.output_path, result.body, force: true
41+
end
3542
end
3643
end
44+
Nandi::Lockfile.for(db_name).persist!
3745
end
38-
39-
Nandi::Lockfile.persist!
4046
end
4147

4248
private
4349

44-
def safe_migrations_dir
45-
if Nandi.config.migration_directory.nil?
46-
Rails.root.join("db", "safe_migrations").to_s
47-
else
48-
File.expand_path(Nandi.config.migration_directory)
49-
end
50+
def databases
51+
return [options[:database].to_sym] if options[:database]
52+
53+
Nandi.config.databases.names
5054
end
5155

52-
def output_path
53-
Nandi.config.output_directory || "db/migrate"
56+
def safe_migrations_dir(db_name)
57+
File.expand_path(Nandi.config.migration_directory(db_name))
5458
end
5559

56-
def files
57-
safe_migration_files = Dir.chdir(safe_migrations_dir) { Dir["*.rb"] }
58-
FileMatcher.call(files: safe_migration_files, spec: options["files"]).
59-
map { |file| File.join(safe_migrations_dir, file) }
60+
def files(db_name)
61+
safe_migration_files = Dir.chdir(safe_migrations_dir(db_name)) { Dir["*.rb"] }
62+
FileMatcher.call(files: safe_migration_files, spec: options["files"])
6063
end
6164
end
6265
end

lib/generators/nandi/foreign_key/USAGE

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ Example:
1414
new foreign key constraint on that column against the id column of bars, and
1515
validate that constraint separately.
1616

17+
If no --database option is specified, uses the default database.
18+
1719
Example:
1820
rails generate nandi:foreign_key foos bars --type text
1921

@@ -45,3 +47,10 @@ Example:
4547

4648
Assumes that there is a column on table foos called special_bar_id that points to
4749
bars. Will create an FK constraint called my_fk
50+
51+
Multi-database examples:
52+
rails generate nandi:foreign_key posts users --database primary
53+
creates foreign key migrations in primary database directory
54+
55+
rails generate nandi:foreign_key events sessions --database analytics
56+
creates foreign key migrations in analytics database directory

lib/generators/nandi/foreign_key/foreign_key_generator.rb

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
require "rails/generators"
44
require "nandi/formatting"
5+
require "nandi/multi_db_generator"
56

67
module Nandi
78
class ForeignKeyGenerator < Rails::Generators::Base
89
include Nandi::Formatting
10+
include Nandi::MultiDbGenerator
911

1012
argument :table, type: :string
1113
argument :target, type: :string
@@ -68,10 +70,6 @@ def reference_name
6870
:"#{target.singularize}_id"
6971
end
7072

71-
def base_path
72-
Nandi.config.migration_directory || "db/safe_migrations"
73-
end
74-
7573
def timestamp(offset = 0)
7674
(Time.now.utc + offset).strftime("%Y%m%d%H%M%S")
7775
end

0 commit comments

Comments
 (0)