├── .gitignore ├── .rspec ├── .travis.yml ├── Gemfile ├── Guardfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── db_schema.gemspec ├── lib ├── db_schema.rb └── db_schema │ ├── awesome_print.rb │ ├── changes.rb │ ├── configuration.rb │ ├── dsl.rb │ ├── dsl │ └── migration.rb │ ├── migration.rb │ ├── migrator.rb │ ├── normalizer.rb │ ├── operations.rb │ ├── reader.rb │ ├── runner.rb │ ├── utils.rb │ ├── validator.rb │ └── version.rb └── spec ├── db_schema ├── changes_spec.rb ├── configuration_spec.rb ├── dsl │ └── migration_spec.rb ├── dsl_spec.rb ├── migrator_spec.rb ├── normalizer_spec.rb ├── reader_spec.rb ├── runner_spec.rb ├── utils_spec.rb └── validator_spec.rb ├── db_schema_spec.rb ├── spec_helper.rb └── support ├── database.yml └── db_cleaner.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | sudo: false 3 | language: ruby 4 | rvm: 5 | - 2.3.8 6 | - 2.4.5 7 | - 2.5.3 8 | - 2.6.0 9 | services: 10 | - postgresql 11 | addons: 12 | postgresql: 9.6 13 | before_script: 14 | - psql -c 'CREATE DATABASE db_schema_test;' -U postgres 15 | - psql -c 'CREATE DATABASE db_schema_test2;' -U postgres 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in db_schema.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard :rspec, cmd: 'bundle exec rspec', all_on_start: true do 2 | require 'guard/rspec/dsl' 3 | dsl = Guard::RSpec::Dsl.new(self) 4 | 5 | # RSpec files 6 | rspec = dsl.rspec 7 | watch(rspec.spec_helper) { rspec.spec_dir } 8 | watch(rspec.spec_support) { rspec.spec_dir } 9 | watch(rspec.spec_files) 10 | 11 | # Ruby files 12 | ruby = dsl.ruby 13 | dsl.watch_spec_files_for(ruby.lib_files) 14 | watch(%r{lib/db_schema/definitions\.rb}) { rspec.spec_dir } 15 | watch(%r{lib/db_schema/definitions/.*\.rb}) { rspec.spec_dir } 16 | watch(%r{lib/db_schema/operations\.rb}) { rspec.spec_dir } 17 | watch('lib/db_schema/utils.rb') { rspec.spec_dir } 18 | end 19 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Vsevolod Romashov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DbSchema [![Build Status](https://travis-ci.org/db-schema/core.svg?branch=master)](https://travis-ci.org/db-schema/core) [![Gem Version](https://badge.fury.io/rb/db_schema.svg)](https://badge.fury.io/rb/db_schema) [![Join the chat at https://gitter.im/7even/db_schema](https://badges.gitter.im/7even/db_schema.svg)](https://gitter.im/7even/db_schema?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 2 | 3 | DbSchema is an opinionated database schema management tool that lets you maintain your DB schema with a single ruby file. 4 | 5 | It works like this: 6 | 7 | * you create a `schema.rb` file where you describe the schema you want in a special DSL 8 | * you make your application load this file as early as possible during the application bootup in development and test environments 9 | * you create a rake task that loads your `schema.rb` and tell your favorite deployment tool to run it on each deploy 10 | * each time you need to change the schema you just change the `schema.rb` file and commit it to your VCS 11 | 12 | As a result you always have an up-to-date database schema. No need to run and rollback migrations, no need to even think about the extra step - DbSchema compares the schema you want with the schema your database has and applies all necessary changes to the latter. This operation is [idempotent](https://en.wikipedia.org/wiki/Idempotence) - if DbSchema sees that the database already has the requested schema it does nothing. 13 | 14 | *Currently DbSchema only supports PostgreSQL.* 15 | 16 | ## Reasons to use 17 | 18 | With DbSchema you almost never need to write migrations by hand and manage a collection of migration files. 19 | This gives you a list of important benefits: 20 | 21 | * no more `YouHaveABunchOfPendingMigrations` errors - all needed operations are computed from the differences between the schema definition and the actual database schema 22 | * no need to write separate :up and :down migrations - this is all handled automatically 23 | * there is no `structure.sql` with a database dump that constantly changes without reason 24 | 25 | But the main reason of DbSchema existence is the pain of switching 26 | between long-running VCS branches with different migrations 27 | without resetting the database. Have you ever switched 28 | to a different branch only to see something like this? 29 | 30 | ![](https://cloud.githubusercontent.com/assets/351591/17085038/7da81118-51d6-11e6-91d9-99885235d037.png) 31 | 32 | Yeah, you must remember the oldest `NO FILE` migration, 33 | switch back to the previous branch, 34 | roll back every migration up to that `NO FILE`, 35 | discard all changes in `schema.rb`/`structure.sql` (and model annotations if you have any), 36 | then switch the branch again and migrate these `down` migrations. 37 | If you already wrote some code to be committed to the new branch 38 | you need to make sure it won't get discarded so a simple `git reset --hard` won't do. 39 | Every migration or rollback loads the whole app, resulting in 10+ seconds wasted. 40 | And at the end of it all you are trying to recall why did you ever 41 | want to switch to that branch. 42 | 43 | DbSchema does not rely on migration files and/or `schema_migrations` table in the database 44 | so it seamlessly changes the schema to the one defined in the branch you switched to. 45 | There is no step 2. 46 | 47 | Of course if you are switching from a branch that defines table A to a branch 48 | that doesn't define table A then you lose that table with all the data in it. 49 | But you would lose it even with manual migrations. 50 | 51 | ## Installation 52 | 53 | Add these lines to your application's Gemfile: 54 | 55 | ``` ruby 56 | gem 'db_schema', '~> 0.5.0' 57 | gem 'db_schema-reader-postgres', '~> 0.2.0' 58 | ``` 59 | 60 | And then execute: 61 | 62 | ``` sh 63 | $ bundle 64 | ``` 65 | 66 | Or install it yourself as: 67 | 68 | ``` sh 69 | $ gem install db_schema db_schema-reader-postgres 70 | ``` 71 | 72 | The `db_schema-reader-postgres` [gem](https://github.com/db-schema/reader-postgres) is a PostgreSQL adapter 73 | for `DbSchema::Reader` (a module which is responsible for reading the current database schema). 74 | 75 | ## Upgrading to 0.5 76 | 77 | Version 0.5 introduced full support for serial fields and primary keys slightly changing the DSL for 78 | defining the primary key: 79 | 80 | ``` ruby 81 | db.table :users do |t| 82 | # before 0.5 83 | t.primary_key :id 84 | # since 0.5 85 | t.serial :id, primary_key: true 86 | end 87 | ``` 88 | 89 | So if you get an `Index "users_pkey" refers to a missing field "users.id"` error you should change 90 | your schema definition to the new syntax. 91 | 92 | ## Usage 93 | 94 | First you need to configure DbSchema so it knows how to connect to your database. This should happen 95 | in a file that is loaded during the application boot process - a Rails or Hanami initializer would do. 96 | 97 | DbSchema can be configured with a call to `DbSchema.configure`: 98 | 99 | ``` ruby 100 | # config/initializers/db_schema.rb 101 | DbSchema.configure( 102 | database: 'my_app_development' 103 | ) 104 | ``` 105 | 106 | There is also a Rails' `database.yml`-compatible `configure_from_yaml` method. DbSchema configuration 107 | is discussed in detail [here](https://github.com/db-schema/core/wiki/Configuration). 108 | 109 | After DbSchema is configured you can load your schema definition file: 110 | 111 | ``` ruby 112 | # config/initializers/db_schema.rb 113 | 114 | # ... 115 | load application_root.join('db/schema.rb') 116 | ``` 117 | 118 | This `db/schema.rb` file will contain a description of your database structure 119 | (you can choose any filename you want). When you load this file it instantly 120 | applies the described structure to your database. Be sure to keep this file 121 | under version control as it will be the single source of truth about 122 | the database structure. 123 | 124 | ``` ruby 125 | # db/schema.rb 126 | DbSchema.describe do |db| 127 | db.table :users do |t| 128 | t.serial :id, primary_key: true 129 | t.varchar :email, null: false, unique: true 130 | t.varchar :password_digest, length: 40 131 | t.timestamptz :created_at 132 | t.timestamptz :updated_at 133 | end 134 | end 135 | ``` 136 | 137 | Database schema definition DSL is documented [here](https://github.com/db-schema/core/wiki/Schema-definition-DSL). 138 | 139 | If you want to analyze your database structure in any way from your app (e.g. defining methods 140 | with `#define_method` for each enum value) you can use `DbSchema.current_schema` - it returns 141 | a cached copy of the database structure as a `DbSchema::Definitions::Schema` object which you 142 | can query in different ways. It is available after the schema was applied by DbSchema 143 | (`DbSchema.describe` remembers the current schema of the database and exposes it 144 | at `.current_schema`). Documentation for schema analysis DSL can be found 145 | [here](https://github.com/db-schema/core/wiki/Schema-analysis-DSL). 146 | 147 | ### Production setup 148 | 149 | In order to get an always-up-to-date database schema in development and test environments 150 | you need to load the schema definition when your application is starting up. But if you use 151 | an application server with multiple workers (puma in cluster mode, unicorn) in other environments 152 | (production, staging) you may get yourself into situation when different workers simultaneously 153 | run DbSchema code applying the same changes to your database. If this is the case you will need 154 | to disable loading the schema definition in those environments and do that from a rake task called 155 | from your deploy script: 156 | 157 | ``` ruby 158 | # config/initializers/db_schema.rb 159 | DbSchema.configure(url: ENV['DATABASE_URL']) 160 | 161 | if ENV['APP_ENV'] == 'development' || ENV['APP_ENV'] == 'test' 162 | load application_root.join('db/schema.rb') 163 | end 164 | 165 | # lib/tasks/db_schema.rake 166 | namespace :db do 167 | namespace :schema do 168 | desc 'Apply database schema' 169 | task apply: :environment do 170 | load application_root.join('db/schema.rb') 171 | end 172 | end 173 | end 174 | ``` 175 | 176 | Then you just call `rake db:schema:apply` from your deploy script before restarting the app. 177 | 178 | If your production setup doesn't include multiple application processes starting simultaneously 179 | (for example if you run one Puma process per docker container and replace containers 180 | successively on deploy) you can go the simple way and just 181 | `load application_root.join('db/schema.rb')` in any environment right from the initializer. 182 | The first puma process will apply the schema while the subsequent ones will see there's nothing 183 | left to do. 184 | 185 | ### How it works 186 | 187 | When you call `DbSchema.describe` with a block that describes the database structure for your 188 | application DbSchema compares this *desired* structure with the *actual* structure your 189 | database has at the moment. 190 | 191 | The database structure is a tree; it's top-level node is a `Schema` object that has several 192 | child nodes - tables, enums and extensions. `Table` objects in turn have child nodes describing 193 | everything that belongs to a table - fields, indexes etc. The full tree structure looks like this: 194 | 195 | * Schema 196 | * Table 197 | * Field 198 | * Index 199 | * Check constraint 200 | * Foreign key 201 | * Enum type 202 | * Extension 203 | 204 | DbSchema compares two structure trees by finding *objects with matching names* in both trees. 205 | *Desired* objects that don't have a match in the *actual* schema produce a **create** operation, 206 | while *actual* objects that don't have a counterpart in the *desired* schema generate a **drop** 207 | operation. 208 | 209 | Then each matching pair is compared by attributes and child objects: 210 | 211 | * if the objects differ in their attributes they make an **alter** operation if it is supported 212 | for that kind of object (that's tables, fields and enum types at the moment) or a pair of **drop** 213 | and **create** operations if it's not 214 | * if the objects differ in their child nodes then the process continues recursively for these 215 | two sets of child objects 216 | * if the objects are identical no operations take place on them 217 | 218 | Then DbSchema runs all these operations inside a transaction. 219 | 220 | For example if *desired* schema has tables `users`, `cities` and `posts`, and *actual* schema 221 | only has `users` and `posts` (where `posts` lack a couple of fields compared to the *desired* 222 | version), then the `cities` table will be created and new fields will be added to `posts`. 223 | 224 | The fact that objects are compared by name implies a very important detail: **you can't rename 225 | anything just by changing the name in the definition.** 226 | 227 | Imagine that you have a `foo` table in your schema definition and an identical table in the database. 228 | If you change it's name to `bar` in the definition and run your app DbSchema will see there 229 | is a `bar` table in the *desired* schema but no match in the database so a new `bar` table will be created; 230 | and since there is a `foo` table in the *actual* schema without a counterpart in the *desired* 231 | schema DbSchema will drop this table. Of course all data in the `foo` table will be lost. 232 | 233 | This can be solved with conditional migrations - a tool that allows you to make some changes to your database 234 | *before* the schema comparison described earlier takes control. A migration describes all required operations 235 | in an imperative manner (`rename_table`, `drop_index` etc) with a dedicated DSL. DbSchema doesn't store 236 | anything about migrations in the database though (as opposed to ActiveRecord or Sequel migrations); 237 | instead you have to provide some conditions required to run the migration (the goal here is to come up with 238 | conditions that a) will only trigger if the migration wasn't applied yet and b) are necessary for the 239 | migration to work) - like "rename the `users` 240 | table to `people` only if the database has a `users` table" (DbSchema also provides 241 | a [simple DSL](https://github.com/db-schema/core/wiki/Schema-analysis-DSL) for schema analysis). 242 | This way the migration won't be applied again and the whole DbSchema process stays idempotent. 243 | Also you don't have to keep these migrations forever - once a migration is applied to databases 244 | in all environments you can safely delete it (though you can give your teammates a week or two to keep up). 245 | 246 | Conditional migrations are described [here](https://github.com/db-schema/core/wiki/Conditional-Migrations). 247 | 248 | ## Known problems and limitations 249 | 250 | * array element type attributes are not supported 251 | * precision in all date/time types isn't supported 252 | * no support for databases other than PostgreSQL 253 | 254 | ## Development 255 | 256 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. To install this gem onto your local machine, run `bundle exec rake install`. 257 | 258 | ## Contributing 259 | 260 | Bug reports and pull requests are welcome on GitHub at [db-schema/core](https://github.com/db-schema/core). 261 | 262 | ## License 263 | 264 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 265 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task default: :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'db_schema' 5 | 6 | require 'pry' 7 | require 'awesome_print' 8 | AwesomePrint.pry! 9 | 10 | Pry.start 11 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | createdb db_schema_test 9 | -------------------------------------------------------------------------------- /db_schema.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'db_schema/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'db_schema' 7 | spec.version = DbSchema::VERSION 8 | spec.authors = ['Vsevolod Romashov'] 9 | spec.email = ['7@7vn.ru'] 10 | 11 | spec.summary = 'Declarative database schema definition.' 12 | spec.description = 'A database schema management tool that reads a "single-source-of-truth" schema definition from a ruby file and auto-migrates the database to conform to it.' 13 | spec.homepage = 'https://github.com/db-schema/core' 14 | spec.license = 'MIT' 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r(^spec/)) } 17 | spec.bindir = 'exe' 18 | spec.executables = spec.files.grep(%r(^exe/)) { |f| File.basename(f) } 19 | spec.require_paths = ['lib'] 20 | 21 | spec.add_runtime_dependency 'sequel' 22 | spec.add_runtime_dependency 'dry-equalizer', '~> 0.2' 23 | spec.add_runtime_dependency 'db_schema-definitions', '~> 0.2.0' 24 | 25 | spec.add_development_dependency 'bundler', '~> 1.11' 26 | spec.add_development_dependency 'rake', '~> 10.0' 27 | spec.add_development_dependency 'pry' 28 | spec.add_development_dependency 'awesome_print', '~> 1.7' 29 | 30 | spec.add_development_dependency 'rspec', '~> 3.0' 31 | spec.add_development_dependency 'guard-rspec' 32 | spec.add_development_dependency 'terminal-notifier' 33 | spec.add_development_dependency 'terminal-notifier-guard' 34 | 35 | spec.add_development_dependency 'db_schema-reader-postgres', '~> 0.2.0' 36 | end 37 | -------------------------------------------------------------------------------- /lib/db_schema.rb: -------------------------------------------------------------------------------- 1 | require 'sequel' 2 | require 'yaml' 3 | 4 | require 'db_schema/configuration' 5 | require 'db_schema/utils' 6 | require 'db_schema/definitions' 7 | require 'db_schema/migration' 8 | require 'db_schema/operations' 9 | require 'db_schema/awesome_print' 10 | require 'db_schema/dsl' 11 | require 'db_schema/validator' 12 | require 'db_schema/normalizer' 13 | require 'db_schema/reader' 14 | require 'db_schema/migrator' 15 | require 'db_schema/changes' 16 | require 'db_schema/runner' 17 | require 'db_schema/version' 18 | 19 | module DbSchema 20 | class << self 21 | attr_reader :current_schema 22 | 23 | def describe(&block) 24 | with_connection do |connection| 25 | desired = DSL.new(block) 26 | validate(desired.schema) 27 | Normalizer.new(desired.schema, connection).normalize_tables 28 | 29 | connection.transaction do 30 | actual_schema = run_migrations(desired.migrations, connection) 31 | changes = Changes.between(desired.schema, actual_schema) 32 | log_changes(changes) if configuration.log_changes? 33 | 34 | if configuration.dry_run? 35 | raise Sequel::Rollback 36 | else 37 | @current_schema = desired.schema 38 | return if changes.empty? 39 | end 40 | 41 | Runner.new(changes, connection).run! 42 | 43 | if configuration.post_check_enabled? 44 | perform_post_check(desired.schema, connection) 45 | end 46 | end 47 | end 48 | end 49 | 50 | def configure(params) 51 | @configuration = configuration.merge(params) 52 | end 53 | 54 | def configure_from_yaml(yaml_path, environment, **other_options) 55 | data = Utils.symbolize_keys(YAML.load_file(yaml_path)) 56 | filtered_data = Utils.filter_by_keys( 57 | data[environment.to_sym], 58 | *%i(adapter host port database username password) 59 | ) 60 | renamed_data = Utils.rename_keys(filtered_data, username: :user) 61 | 62 | configure(renamed_data.merge(other_options)) 63 | end 64 | 65 | def connection=(external_connection) 66 | @external_connection = external_connection 67 | end 68 | 69 | def configuration 70 | @configuration ||= Configuration.new 71 | end 72 | 73 | def reset! 74 | @external_connection = nil 75 | @configuration = nil 76 | @current_schema = nil 77 | end 78 | 79 | private 80 | def with_connection 81 | raise ArgumentError unless block_given? 82 | 83 | if @external_connection 84 | yield @external_connection 85 | else 86 | Sequel.connect( 87 | adapter: configuration.adapter, 88 | host: configuration.host, 89 | port: configuration.port, 90 | database: configuration.database, 91 | user: configuration.user, 92 | password: configuration.password 93 | ) do |db| 94 | db.extension :pg_enum 95 | db.extension :pg_array 96 | 97 | yield db 98 | end 99 | end 100 | end 101 | 102 | def validate(schema) 103 | validation_result = Validator.validate(schema) 104 | 105 | unless validation_result.valid? 106 | message = "Requested schema is invalid:\n\n" 107 | 108 | validation_result.errors.each do |error| 109 | message << "* #{error}\n" 110 | end 111 | 112 | raise InvalidSchemaError, message 113 | end 114 | end 115 | 116 | def run_migrations(migrations, connection) 117 | @current_schema = Reader.reader_for(connection).read_schema 118 | 119 | migrations.reduce(@current_schema) do |schema, migration| 120 | migrator = Migrator.new(migration) 121 | 122 | if migrator.applicable?(schema) 123 | log_migration(migration) if configuration.log_changes? 124 | migrator.run!(connection) 125 | Reader.reader_for(connection).read_schema 126 | else 127 | schema 128 | end 129 | end 130 | end 131 | 132 | def log_migration(migration) 133 | puts "DbSchema is running migration #{migration.name.ai}" 134 | end 135 | 136 | def log_changes(changes) 137 | return if changes.empty? 138 | 139 | puts 'DbSchema is applying these changes to the database:' 140 | if changes.respond_to?(:ai) 141 | puts changes.ai 142 | else 143 | puts changes.inspect 144 | end 145 | end 146 | 147 | def perform_post_check(desired_schema, connection) 148 | unapplied_changes = Changes.between(desired_schema, Reader.reader_for(connection).read_schema) 149 | return if unapplied_changes.empty? 150 | 151 | readable_changes = if unapplied_changes.respond_to?(:ai) 152 | unapplied_changes.ai 153 | else 154 | unapplied_changes.inspect 155 | end 156 | 157 | message = <<-ERROR 158 | Your database still differs from the expected schema after applying it; if you are 100% sure this is ok you can turn these checks off with DbSchema.configure(post_check: false). Here are the differences: 159 | 160 | #{readable_changes} 161 | ERROR 162 | 163 | raise SchemaMismatch, message 164 | end 165 | end 166 | 167 | class InvalidSchemaError < ArgumentError; end 168 | class SchemaMismatch < RuntimeError; end 169 | class UnsupportedOperation < ArgumentError; end 170 | end 171 | -------------------------------------------------------------------------------- /lib/db_schema/awesome_print.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require 'awesome_print' 3 | rescue LoadError 4 | end 5 | 6 | if defined?(AwesomePrint) 7 | module AwesomePrint 8 | module DbSchema 9 | def self.included(base) 10 | base.send :alias_method, :cast_without_dbschema, :cast 11 | base.send :alias_method, :cast, :cast_with_dbschema 12 | end 13 | 14 | def cast_with_dbschema(object, type) 15 | case object 16 | when ::DbSchema::Operations::CreateTable 17 | :dbschema_create_table 18 | when ::DbSchema::Operations::DropTable 19 | :dbschema_drop_table 20 | when ::DbSchema::Operations::AlterTable 21 | :dbschema_alter_table 22 | when ::DbSchema::Operations::CreateColumn 23 | :dbschema_create_column 24 | when ::DbSchema::Operations::ColumnOperation 25 | :dbschema_column_operation 26 | when ::DbSchema::Operations::RenameOperation 27 | :dbschema_rename 28 | when ::DbSchema::Operations::AlterColumnType 29 | :dbschema_alter_column_type 30 | when ::DbSchema::Operations::AlterColumnDefault 31 | :dbschema_alter_column_default 32 | when ::DbSchema::Operations::CreateIndex 33 | :dbschema_create_index 34 | when ::DbSchema::Operations::DropIndex 35 | :dbschema_drop_index 36 | when ::DbSchema::Operations::CreateCheckConstraint 37 | :dbschema_create_check_constraint 38 | when ::DbSchema::Operations::CreateForeignKey 39 | :dbschema_create_foreign_key 40 | when ::DbSchema::Operations::DropForeignKey 41 | :dbschema_drop_foreign_key 42 | when ::DbSchema::Operations::CreateEnum 43 | :dbschema_create_enum 44 | when ::DbSchema::Operations::AlterEnumValues 45 | :dbschema_alter_enum_values 46 | when ::DbSchema::Operations::CreateExtension 47 | :dbschema_create_extension 48 | else 49 | cast_without_dbschema(object, type) 50 | end 51 | end 52 | 53 | private 54 | def awesome_dbschema_create_table(object) 55 | data = ["fields: #{object.table.fields.ai}"] 56 | data << "indexes: #{object.table.indexes.ai}" if object.table.indexes.any? 57 | data << "checks: #{object.table.checks.ai}" if object.table.checks.any? 58 | 59 | data_string = indent_lines(data.join(', ')) 60 | "#" 61 | end 62 | 63 | def awesome_dbschema_drop_table(object) 64 | "#" 65 | end 66 | 67 | def awesome_dbschema_alter_table(object) 68 | "#" 69 | end 70 | 71 | def awesome_dbschema_create_column(object) 72 | "#" 73 | end 74 | 75 | def awesome_dbschema_drop_column(object) 76 | "#" 77 | end 78 | 79 | def awesome_dbschema_rename(object) 80 | "#<#{object.class} #{object.old_name.ai} => #{object.new_name.ai}>" 81 | end 82 | 83 | def awesome_dbschema_alter_column_type(object) 84 | attributes = object.new_attributes.map do |k, v| 85 | key = colorize("#{k}:", :symbol) 86 | "#{key} #{v.ai}" 87 | end.unshift(nil).join(', ') 88 | 89 | "#" 90 | end 91 | 92 | def awesome_dbschema_alter_column_default(object) 93 | new_default = if object.new_default.is_a?(Symbol) 94 | colorize(object.new_default.to_s, :string) 95 | else 96 | object.new_default.ai 97 | end 98 | 99 | "#" 100 | end 101 | 102 | def awesome_dbschema_create_index(object) 103 | columns = format_dbschema_fields(object.index.columns) 104 | using = ' using ' + colorize(object.index.type.to_s, :symbol) unless object.index.btree? 105 | 106 | data = [nil] 107 | data << colorize('primary key', :nilclass) if object.index.primary? 108 | data << colorize('unique', :nilclass) if object.index.unique? 109 | data << colorize('condition: ', :symbol) + object.index.condition.ai unless object.index.condition.nil? 110 | 111 | "#<#{object.class} #{object.index.name.ai} on #{columns}#{using}#{data.join(', ')}>" 112 | end 113 | 114 | def awesome_dbschema_drop_index(object) 115 | data = [object.name.ai] 116 | data << colorize('primary key', :nilclass) if object.primary? 117 | 118 | "#<#{object.class} #{data.join(' ')}>" 119 | end 120 | 121 | def awesome_dbschema_create_check_constraint(object) 122 | "#<#{object.class} #{object.check.name.ai} #{object.check.condition.ai}>" 123 | end 124 | 125 | def awesome_dbschema_create_foreign_key(object) 126 | "#" 127 | end 128 | 129 | def awesome_dbschema_drop_foreign_key(object) 130 | "#" 131 | end 132 | 133 | def awesome_dbschema_create_enum(object) 134 | values = object.enum.values.map do |value| 135 | colorize(value.to_s, :string) 136 | end.join(', ') 137 | 138 | "#<#{object.class} #{object.enum.name.ai} (#{values})>" 139 | end 140 | 141 | def awesome_dbschema_column_operation(object) 142 | "#<#{object.class} #{object.name.ai}>" 143 | end 144 | 145 | def awesome_dbschema_alter_enum_values(object) 146 | values = object.new_values.map do |value| 147 | colorize(value.to_s, :string) 148 | end.join(', ') 149 | 150 | "#" 151 | end 152 | 153 | def awesome_dbschema_create_extension(object) 154 | "#<#{object.class} #{object.extension.name.ai}>" 155 | end 156 | 157 | def format_dbschema_fields(fields) 158 | if fields.one? 159 | fields.first.ai 160 | else 161 | '[' + fields.map(&:ai).join(', ') + ']' 162 | end 163 | end 164 | 165 | def indent_lines(text, indent_level = 4) 166 | text.gsub(/(? (table) do 23 | fkey_operations = table.foreign_keys.map do |fkey| 24 | Operations::CreateForeignKey.new(table.name, fkey) 25 | end 26 | 27 | [Operations::CreateTable.new(table), *fkey_operations] 28 | end, 29 | drop: -> (table) do 30 | fkey_operations = table.foreign_keys.map do |fkey| 31 | Operations::DropForeignKey.new(table.name, fkey.name) 32 | end 33 | 34 | [Operations::DropTable.new(table.name), *fkey_operations] 35 | end, 36 | change: -> (desired, actual) do 37 | fkey_operations = foreign_key_changes(desired, actual) 38 | 39 | alter_table_operations = [ 40 | field_changes(desired, actual), 41 | index_changes(desired, actual), 42 | check_changes(desired, actual) 43 | ].reduce(:+) 44 | 45 | if alter_table_operations.any? 46 | alter_table = Operations::AlterTable.new( 47 | desired.name, 48 | sort_alter_table_changes(alter_table_operations) 49 | ) 50 | 51 | [alter_table, *fkey_operations] 52 | else 53 | fkey_operations 54 | end 55 | end 56 | ) 57 | end 58 | 59 | def field_changes(desired_table, actual_table) 60 | compare_collections( 61 | desired_table.fields, 62 | actual_table.fields, 63 | create: -> (field) { Operations::CreateColumn.new(field) }, 64 | drop: -> (field) { Operations::DropColumn.new(field.name) }, 65 | change: -> (desired, actual) do 66 | [].tap do |operations| 67 | if (actual.type != desired.type) || (actual.attributes != desired.attributes) 68 | operations << Operations::AlterColumnType.new( 69 | actual.name, 70 | old_type: actual.type, 71 | new_type: desired.type, 72 | **desired.attributes 73 | ) 74 | end 75 | 76 | if desired.null? && !actual.null? 77 | operations << Operations::AllowNull.new(actual.name) 78 | end 79 | 80 | if actual.null? && !desired.null? 81 | operations << Operations::DisallowNull.new(actual.name) 82 | end 83 | 84 | if actual.default != desired.default 85 | operations << Operations::AlterColumnDefault.new(actual.name, new_default: desired.default) 86 | end 87 | end 88 | end 89 | ) 90 | end 91 | 92 | def index_changes(desired_table, actual_table) 93 | compare_collections( 94 | desired_table.indexes, 95 | actual_table.indexes, 96 | create: -> (index) { Operations::CreateIndex.new(index) }, 97 | drop: -> (index) { Operations::DropIndex.new(index.name, index.primary?) }, 98 | change: -> (desired, actual) do 99 | [ 100 | Operations::DropIndex.new(actual.name, actual.primary?), 101 | Operations::CreateIndex.new(desired) 102 | ] 103 | end 104 | ) 105 | end 106 | 107 | def check_changes(desired_table, actual_table) 108 | compare_collections( 109 | desired_table.checks, 110 | actual_table.checks, 111 | create: -> (check) { Operations::CreateCheckConstraint.new(check) }, 112 | drop: -> (check) { Operations::DropCheckConstraint.new(check.name) }, 113 | change: -> (desired, actual) do 114 | [ 115 | Operations::DropCheckConstraint.new(actual.name), 116 | Operations::CreateCheckConstraint.new(desired) 117 | ] 118 | end 119 | ) 120 | end 121 | 122 | def foreign_key_changes(desired_table, actual_table) 123 | compare_collections( 124 | desired_table.foreign_keys, 125 | actual_table.foreign_keys, 126 | create: -> (foreign_key) { Operations::CreateForeignKey.new(actual_table.name, foreign_key) }, 127 | drop: -> (foreign_key) { Operations::DropForeignKey.new(actual_table.name, foreign_key.name) }, 128 | change: -> (desired, actual) do 129 | [ 130 | Operations::DropForeignKey.new(actual_table.name, actual.name), 131 | Operations::CreateForeignKey.new(actual_table.name, desired) 132 | ] 133 | end 134 | ) 135 | end 136 | 137 | def enum_changes(desired_schema, actual_schema) 138 | compare_collections( 139 | desired_schema.enums, 140 | actual_schema.enums, 141 | create: -> (enum) { Operations::CreateEnum.new(enum) }, 142 | drop: -> (enum) { Operations::DropEnum.new(enum.name) }, 143 | change: -> (desired, actual) do 144 | fields = actual_schema.tables.flat_map do |table| 145 | table.fields.select do |field| 146 | if field.array? 147 | field.attributes[:element_type] == actual.name 148 | else 149 | field.type == actual.name 150 | end 151 | end.map do |field| 152 | if desired_field = desired_schema[table.name][field.name] 153 | new_default = desired_field.default 154 | end 155 | 156 | { 157 | table_name: table.name, 158 | field_name: field.name, 159 | new_default: new_default, 160 | array: field.array? 161 | } 162 | end 163 | end 164 | 165 | Operations::AlterEnumValues.new(actual.name, desired.values, fields) 166 | end 167 | ) 168 | end 169 | 170 | def extension_changes(desired_schema, actual_schema) 171 | compare_collections( 172 | desired_schema.extensions, 173 | actual_schema.extensions, 174 | create: -> (extension) { Operations::CreateExtension.new(extension) }, 175 | drop: -> (extension) { Operations::DropExtension.new(extension.name) } 176 | ) 177 | end 178 | 179 | def compare_collections(desired, actual, create:, drop:, change: -> (*) {}) 180 | desired_hash = Utils.to_hash(desired, :name) 181 | actual_hash = Utils.to_hash(actual, :name) 182 | 183 | (desired_hash.keys + actual_hash.keys).uniq.flat_map do |name| 184 | if desired_hash.key?(name) && !actual_hash.key?(name) 185 | create.(desired_hash[name]) 186 | elsif actual_hash.key?(name) && !desired_hash.key?(name) 187 | drop.(actual_hash[name]) 188 | elsif actual_hash[name] != desired_hash[name] 189 | change.(desired_hash[name], actual_hash[name]) 190 | end 191 | end.compact 192 | end 193 | 194 | def sort_all_changes(changes) 195 | Utils.sort_by_class( 196 | changes, 197 | [ 198 | Operations::CreateExtension, 199 | Operations::DropForeignKey, 200 | Operations::AlterEnumValues, 201 | Operations::CreateEnum, 202 | Operations::CreateTable, 203 | Operations::AlterTable, 204 | Operations::DropTable, 205 | Operations::DropEnum, 206 | Operations::CreateForeignKey, 207 | Operations::DropExtension 208 | ] 209 | ) 210 | end 211 | 212 | def sort_alter_table_changes(changes) 213 | Utils.sort_by_class( 214 | changes, 215 | [ 216 | Operations::DropCheckConstraint, 217 | Operations::DropIndex, 218 | Operations::DropColumn, 219 | Operations::AlterColumnType, 220 | Operations::AllowNull, 221 | Operations::DisallowNull, 222 | Operations::AlterColumnDefault, 223 | Operations::CreateColumn, 224 | Operations::CreateIndex, 225 | Operations::CreateCheckConstraint 226 | ] 227 | ) 228 | end 229 | end 230 | end 231 | end 232 | -------------------------------------------------------------------------------- /lib/db_schema/configuration.rb: -------------------------------------------------------------------------------- 1 | require 'dry/equalizer' 2 | 3 | module DbSchema 4 | class Configuration 5 | include Dry::Equalizer(:params) 6 | 7 | DEFAULT_PARAMS = { 8 | adapter: 'postgres', 9 | host: 'localhost', 10 | port: 5432, 11 | database: nil, 12 | user: nil, 13 | password: '', 14 | log_changes: true, 15 | dry_run: false, 16 | post_check: true 17 | }.freeze 18 | 19 | def initialize(params = DEFAULT_PARAMS) 20 | @params = params 21 | end 22 | 23 | def merge(new_params) 24 | params = [ 25 | @params, 26 | Configuration.params_from_url(new_params[:url]), 27 | Utils.filter_by_keys(new_params, *DEFAULT_PARAMS.keys) 28 | ].reduce(:merge) 29 | 30 | Configuration.new(params) 31 | end 32 | 33 | [:adapter, :host, :port, :database, :user, :password].each do |param_name| 34 | define_method(param_name) do 35 | @params[param_name] 36 | end 37 | end 38 | 39 | def log_changes? 40 | @params[:log_changes] 41 | end 42 | 43 | def dry_run? 44 | @params[:dry_run] 45 | end 46 | 47 | def post_check_enabled? 48 | @params[:post_check] 49 | end 50 | 51 | class << self 52 | def params_from_url(url_string) 53 | return {} if url_string.nil? 54 | url = URI.parse(url_string) 55 | 56 | Utils.remove_nil_values( 57 | adapter: url.scheme, 58 | host: url.host, 59 | port: url.port, 60 | database: url.path.sub(/\A\//, ''), 61 | user: url.user, 62 | password: url.password 63 | ) 64 | end 65 | end 66 | 67 | protected 68 | attr_reader :params 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/db_schema/dsl.rb: -------------------------------------------------------------------------------- 1 | require_relative 'dsl/migration' 2 | 3 | module DbSchema 4 | class DSL 5 | attr_reader :schema, :migrations 6 | 7 | def initialize(block) 8 | @schema = Definitions::Schema.new 9 | @migrations = [] 10 | 11 | block.call(self) 12 | end 13 | 14 | def table(name, &block) 15 | table_yielder = TableYielder.new(name, block) 16 | 17 | @schema.tables << Definitions::Table.new( 18 | name, 19 | fields: prepare_fields(table_yielder), 20 | indexes: table_yielder.indexes, 21 | checks: table_yielder.checks, 22 | foreign_keys: table_yielder.foreign_keys 23 | ) 24 | end 25 | 26 | def enum(name, values) 27 | @schema.enums << Definitions::Enum.new(name.to_sym, values.map(&:to_sym)) 28 | end 29 | 30 | def extension(name) 31 | @schema.extensions << Definitions::Extension.new(name.to_sym) 32 | end 33 | 34 | def migrate(name, &block) 35 | migrations << Migration.new(name, block).migration 36 | end 37 | 38 | private 39 | def prepare_fields(table_yielder) 40 | primary_key = table_yielder.indexes.find(&:primary?) 41 | return table_yielder.fields if primary_key.nil? 42 | 43 | table_yielder.fields.map do |field| 44 | if primary_key.columns.map(&:name).include?(field.name) 45 | field.with_null(false) 46 | else 47 | field 48 | end 49 | end 50 | end 51 | 52 | class TableYielder 53 | attr_reader :table_name 54 | 55 | def initialize(table_name, block) 56 | @table_name = table_name 57 | block.call(self) 58 | end 59 | 60 | DbSchema::Definitions::Field.registry.keys.each do |type| 61 | next if type == :array 62 | 63 | define_method(type) do |name, **options| 64 | field(name, type, options) 65 | end 66 | end 67 | 68 | def array(name, of:, **options) 69 | field(name, :array, element_type: of, **options) 70 | end 71 | 72 | %i(smallserial serial bigserial).each do |serial_type| 73 | define_method(serial_type) do |name, **options| 74 | allowed_options = Utils.filter_by_keys(options, :primary_key, :unique, :index, :references, :check) 75 | field(name, serial_type, null: false, **allowed_options) 76 | end 77 | end 78 | 79 | def method_missing(method_name, name, *args, &block) 80 | field(name, method_name, args.first || {}) 81 | end 82 | 83 | def primary_key(*columns, name: nil) 84 | index(*columns, name: name, primary: true, unique: true) 85 | end 86 | 87 | def index(*columns, **index_options) 88 | indexes << TableYielder.build_index( 89 | columns, 90 | table_name: table_name, 91 | **index_options 92 | ) 93 | end 94 | 95 | def check(name, condition) 96 | checks << Definitions::CheckConstraint.new(name: name, condition: condition) 97 | end 98 | 99 | def foreign_key(*fkey_fields, **fkey_options) 100 | foreign_keys << TableYielder.build_foreign_key( 101 | fkey_fields, 102 | table_name: table_name, 103 | **fkey_options 104 | ) 105 | end 106 | 107 | def field(name, type, primary_key: false, unique: false, index: false, references: nil, check: nil, **options) 108 | fields << Definitions::Field.build(name, type, options) 109 | 110 | if primary_key 111 | primary_key(name) 112 | end 113 | 114 | if unique 115 | index(name, unique: true) 116 | elsif index 117 | index(name) 118 | end 119 | 120 | if references 121 | foreign_key(name, references: references) 122 | end 123 | 124 | if check 125 | check("#{table_name}_#{name}_check", check) 126 | end 127 | end 128 | 129 | def fields 130 | @fields ||= [] 131 | end 132 | 133 | def indexes 134 | @indexes ||= [] 135 | end 136 | 137 | def checks 138 | @checks ||= [] 139 | end 140 | 141 | def foreign_keys 142 | @foreign_keys ||= [] 143 | end 144 | 145 | class << self 146 | def build_index(columns, table_name:, name: nil, primary: false, unique: false, using: :btree, where: nil, **ordered_fields) 147 | if columns.last.is_a?(Hash) 148 | *ascending_columns, ordered_expressions = columns 149 | else 150 | ascending_columns = columns 151 | ordered_expressions = {} 152 | end 153 | 154 | columns_data = ascending_columns.each_with_object({}) do |column_name, columns| 155 | columns[column_name] = :asc 156 | end.merge(ordered_fields).merge(ordered_expressions) 157 | 158 | index_columns = columns_data.map do |column_name, column_order_options| 159 | options = case column_order_options 160 | when :asc 161 | {} 162 | when :desc 163 | { order: :desc } 164 | when :asc_nulls_first 165 | { nulls: :first } 166 | when :desc_nulls_last 167 | { order: :desc, nulls: :last } 168 | else 169 | raise ArgumentError, 'Only :asc, :desc, :asc_nulls_first and :desc_nulls_last options are supported.' 170 | end 171 | 172 | if column_name.is_a?(String) 173 | Definitions::Index::Expression.new(column_name, **options) 174 | else 175 | Definitions::Index::TableField.new(column_name, **options) 176 | end 177 | end 178 | 179 | index_name = name || if primary 180 | "#{table_name}_pkey" 181 | else 182 | "#{table_name}_#{index_columns.map(&:index_name_segment).join('_')}_index" 183 | end 184 | 185 | Definitions::Index.new( 186 | name: index_name, 187 | columns: index_columns, 188 | primary: primary, 189 | unique: unique, 190 | type: using, 191 | condition: where 192 | ) 193 | end 194 | 195 | def build_foreign_key(fkey_fields, table_name:, references:, name: nil, on_update: :no_action, on_delete: :no_action, deferrable: false) 196 | fkey_name = name || :"#{table_name}_#{fkey_fields.first}_fkey" 197 | 198 | if references.is_a?(Array) 199 | # [:table, :field] 200 | referenced_table, *referenced_keys = references 201 | 202 | Definitions::ForeignKey.new( 203 | name: fkey_name, 204 | fields: fkey_fields, 205 | table: referenced_table, 206 | keys: referenced_keys, 207 | on_delete: on_delete, 208 | on_update: on_update, 209 | deferrable: deferrable 210 | ) 211 | else 212 | # :table 213 | Definitions::ForeignKey.new( 214 | name: fkey_name, 215 | fields: fkey_fields, 216 | table: references, 217 | on_delete: on_delete, 218 | on_update: on_update, 219 | deferrable: deferrable 220 | ) 221 | end 222 | end 223 | end 224 | end 225 | end 226 | end 227 | -------------------------------------------------------------------------------- /lib/db_schema/dsl/migration.rb: -------------------------------------------------------------------------------- 1 | module DbSchema 2 | class DSL 3 | class Migration 4 | attr_reader :migration 5 | 6 | def initialize(name, block) 7 | @migration = DbSchema::Migration.new(name) 8 | block.call(self) 9 | end 10 | 11 | def apply_if(&block) 12 | migration.conditions[:apply] << block 13 | end 14 | 15 | def skip_if(&block) 16 | migration.conditions[:skip] << block 17 | end 18 | 19 | def run(&block) 20 | migration.body = block 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/db_schema/migration.rb: -------------------------------------------------------------------------------- 1 | module DbSchema 2 | class Migration 3 | include Dry::Equalizer(:name, :conditions, :body) 4 | attr_reader :name, :conditions 5 | attr_accessor :body 6 | 7 | def initialize(name) 8 | @name = name 9 | @conditions = { apply: [], skip: [] } 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/db_schema/migrator.rb: -------------------------------------------------------------------------------- 1 | module DbSchema 2 | class Migrator 3 | attr_reader :migration 4 | 5 | def initialize(migration) 6 | @migration = migration 7 | end 8 | 9 | def applicable?(schema) 10 | migration.conditions[:apply].all? do |condition| 11 | condition.call(schema) 12 | end && migration.conditions[:skip].none? do |condition| 13 | condition.call(schema) 14 | end 15 | end 16 | 17 | def run!(connection) 18 | migration.body.call(BodyYielder.new(connection), connection) unless migration.body.nil? 19 | end 20 | 21 | class BodyYielder 22 | attr_reader :connection 23 | 24 | def initialize(connection) 25 | @connection = connection 26 | end 27 | 28 | def create_table(name, &block) 29 | table_yielder = DSL::TableYielder.new(name, block) 30 | 31 | table = Definitions::Table.new( 32 | name, 33 | fields: table_yielder.fields, 34 | indexes: table_yielder.indexes, 35 | checks: table_yielder.checks, 36 | foreign_keys: table_yielder.foreign_keys 37 | ) 38 | 39 | run Operations::CreateTable.new(table) 40 | 41 | table.foreign_keys.each do |fkey| 42 | run Operations::CreateForeignKey.new(table.name, fkey) 43 | end 44 | end 45 | 46 | def drop_table(name) 47 | run Operations::DropTable.new(name) 48 | end 49 | 50 | def rename_table(from, to:) 51 | run Operations::RenameTable.new(old_name: from, new_name: to) 52 | end 53 | 54 | def alter_table(name, &block) 55 | run AlterTableYielder.new(name).run(block) 56 | end 57 | 58 | class AlterTableYielder 59 | attr_reader :alter_table, :fkey_operations 60 | 61 | def initialize(table_name) 62 | @alter_table = Operations::AlterTable.new(table_name) 63 | @fkey_operations = [] 64 | end 65 | 66 | def run(block) 67 | block.call(self) 68 | 69 | [alter_table, *fkey_operations] 70 | end 71 | 72 | def add_column(name, type, **options) 73 | alter_table.changes << Operations::CreateColumn.new( 74 | Definitions::Field.build(name, type, options) 75 | ) 76 | end 77 | 78 | def drop_column(name) 79 | alter_table.changes << Operations::DropColumn.new(name) 80 | end 81 | 82 | def rename_column(from, to:) 83 | alter_table.changes << Operations::RenameColumn.new(old_name: from, new_name: to) 84 | end 85 | 86 | def alter_column_type(name, new_type, using: nil, **new_attributes) 87 | alter_table.changes << Operations::AlterColumnType.new( 88 | name, 89 | old_type: nil, 90 | new_type: new_type, 91 | using: using, 92 | **new_attributes 93 | ) 94 | end 95 | 96 | def allow_null(name) 97 | alter_table.changes << Operations::AllowNull.new(name) 98 | end 99 | 100 | def disallow_null(name) 101 | alter_table.changes << Operations::DisallowNull.new(name) 102 | end 103 | 104 | def alter_column_default(name, new_default) 105 | alter_table.changes << Operations::AlterColumnDefault.new(name, new_default: new_default) 106 | end 107 | 108 | def add_primary_key(*columns) 109 | alter_table.changes << Operations::CreateIndex.new( 110 | DSL::TableYielder.build_index(columns, table_name: alter_table.table_name, primary: true) 111 | ) 112 | end 113 | 114 | def drop_primary_key 115 | alter_table.changes << Operations::DropIndex.new(:"#{alter_table.table_name}_pkey", true) 116 | end 117 | 118 | def add_index(*columns, **index_options) 119 | alter_table.changes << Operations::CreateIndex.new( 120 | DSL::TableYielder.build_index( 121 | columns, 122 | table_name: alter_table.table_name, 123 | **index_options 124 | ) 125 | ) 126 | end 127 | 128 | def drop_index(name) 129 | alter_table.changes << Operations::DropIndex.new(name, false) 130 | end 131 | 132 | def add_check(name, condition) 133 | alter_table.changes << Operations::CreateCheckConstraint.new( 134 | Definitions::CheckConstraint.new(name: name, condition: condition) 135 | ) 136 | end 137 | 138 | def drop_check(name) 139 | alter_table.changes << Operations::DropCheckConstraint.new(name) 140 | end 141 | 142 | def add_foreign_key(*fkey_fields, **fkey_options) 143 | fkey_operations << Operations::CreateForeignKey.new( 144 | alter_table.table_name, 145 | DSL::TableYielder.build_foreign_key( 146 | fkey_fields, 147 | table_name: alter_table.table_name, 148 | **fkey_options 149 | ) 150 | ) 151 | end 152 | 153 | def drop_foreign_key(fkey_name) 154 | fkey_operations << Operations::DropForeignKey.new( 155 | alter_table.table_name, 156 | fkey_name 157 | ) 158 | end 159 | end 160 | 161 | def create_enum(name, values) 162 | run Operations::CreateEnum.new(Definitions::Enum.new(name, values)) 163 | end 164 | 165 | def drop_enum(name) 166 | run Operations::DropEnum.new(name) 167 | end 168 | 169 | def rename_enum(from, to:) 170 | run Operations::RenameEnum.new(old_name: from, new_name: to) 171 | end 172 | 173 | def create_extension(name) 174 | run Operations::CreateExtension.new(Definitions::Extension.new(name)) 175 | end 176 | 177 | def drop_extension(name) 178 | run Operations::DropExtension.new(name) 179 | end 180 | 181 | def execute(query) 182 | run Operations::ExecuteQuery.new(query) 183 | end 184 | 185 | private 186 | def run(operation) 187 | Runner.new(Array(operation), connection).run! 188 | end 189 | end 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /lib/db_schema/normalizer.rb: -------------------------------------------------------------------------------- 1 | require 'digest/md5' 2 | 3 | module DbSchema 4 | class Normalizer 5 | attr_reader :schema, :connection 6 | 7 | def initialize(schema, connection) 8 | @schema = schema 9 | @connection = connection 10 | end 11 | 12 | def normalize_tables 13 | connection.transaction do 14 | create_extensions! 15 | create_enums! 16 | create_temporary_tables! 17 | normalized_tables = read_temporary_tables 18 | 19 | schema.tables = schema.tables.map do |table| 20 | if table.has_expressions? 21 | normalized_tables.fetch(table.name) 22 | else 23 | table 24 | end 25 | end 26 | 27 | raise Sequel::Rollback 28 | end 29 | end 30 | 31 | private 32 | def create_extensions! 33 | operations = (schema.extensions - Reader.reader_for(connection).read_extensions).map do |extension| 34 | Operations::CreateExtension.new(extension) 35 | end 36 | 37 | Runner.new(operations, connection).run! 38 | end 39 | 40 | def create_enums! 41 | operations = schema.enums.map do |enum| 42 | Operations::CreateEnum.new(enum.with_name(append_hash(enum.name))) 43 | end 44 | 45 | Runner.new(operations, connection).run! 46 | end 47 | 48 | def create_temporary_tables! 49 | schema.tables.select(&:has_expressions?).each do |table| 50 | temporary_table_name = append_hash(table.name) 51 | 52 | operation = Operations::CreateTable.new( 53 | table.with_name(temporary_table_name) 54 | .with_fields(rename_types(table.fields)) 55 | .with_indexes(rename_indexes(table.indexes)) 56 | .with_checks(rename_types_in_checks(table.checks)) 57 | ) 58 | 59 | Runner.new([operation], connection).run! 60 | end 61 | end 62 | 63 | def read_temporary_tables 64 | all_tables = Reader.reader_for(connection).read_tables 65 | 66 | schema.tables.select(&:has_expressions?).reduce({}) do |normalized_tables, table| 67 | temporary_table = all_tables.find do |t| 68 | t.name == append_hash(table.name).to_sym 69 | end || raise 70 | 71 | normalized_tables.merge( 72 | table.name => temporary_table.with_name(table.name) 73 | .with_fields(rename_types_back(temporary_table.fields)) 74 | .with_indexes(rename_indexes_back(temporary_table.indexes)) 75 | .with_checks(rename_types_in_checks_back(temporary_table.checks)) 76 | .with_foreign_keys(table.foreign_keys) 77 | ) 78 | end 79 | end 80 | 81 | def rename_types(fields) 82 | fields.map do |field| 83 | new_default = if field.default_is_expression? 84 | rename_all_types_in(field.default.to_s).to_sym 85 | else 86 | field.default 87 | end 88 | 89 | if field.custom? 90 | field.with_type(append_hash(field.type)) 91 | elsif field.array? && field.custom_element_type? 92 | field.with_attribute(:element_type, append_hash(field.element_type.type).to_sym) 93 | else 94 | field 95 | end.with_default(new_default) 96 | end 97 | end 98 | 99 | def rename_types_back(fields) 100 | fields.map do |field| 101 | new_default = if field.default_is_expression? 102 | rename_all_types_back_in(field.default.to_s).to_sym 103 | else 104 | field.default 105 | end 106 | 107 | if field.custom? 108 | field.with_type(remove_hash(field.type)) 109 | elsif field.array? && field.custom_element_type? 110 | field.with_attribute(:element_type, remove_hash(field.element_type.type).to_sym) 111 | else 112 | field 113 | end.with_default(new_default) 114 | end 115 | end 116 | 117 | def rename_indexes(indexes) 118 | indexes.map do |index| 119 | index 120 | .with_name(append_hash(index.name)) 121 | .with_condition(rename_all_types_in(index.condition)) 122 | end 123 | end 124 | 125 | def rename_indexes_back(indexes) 126 | indexes.map do |index| 127 | index 128 | .with_name(remove_hash(index.name)) 129 | .with_condition(rename_all_types_back_in(index.condition)) 130 | end 131 | end 132 | 133 | def rename_types_in_checks(checks) 134 | checks.map do |check| 135 | check.with_condition(rename_all_types_in(check.condition)) 136 | end 137 | end 138 | 139 | def rename_types_in_checks_back(checks) 140 | checks.map do |check| 141 | check.with_condition(rename_all_types_back_in(check.condition)) 142 | end 143 | end 144 | 145 | def append_hash(name) 146 | "#{name}_#{hash}" 147 | end 148 | 149 | def remove_hash(name) 150 | name.to_s.sub(/_#{Regexp.escape(hash)}$/, '').to_sym 151 | end 152 | 153 | def rename_all_types_in(string) 154 | return string unless string.is_a?(String) 155 | 156 | enum_renaming.reduce(string) do |new_string, (from, to)| 157 | new_string.gsub(from, to) 158 | end 159 | end 160 | 161 | def rename_all_types_back_in(string) 162 | return string unless string.is_a?(String) 163 | 164 | enum_renaming.invert.reduce(string) do |new_string, (from, to)| 165 | new_string.gsub(from, to) 166 | end 167 | end 168 | 169 | def enum_renaming 170 | schema.enums.reduce({}) do |hash, enum| 171 | hash.merge("::#{enum.name}" => "::#{append_hash(enum.name)}") 172 | end 173 | end 174 | 175 | def hash 176 | @hash ||= begin 177 | names = schema.tables.flat_map do |table| 178 | [table.name] + table.fields.map(&:name) + table.indexes.map(&:name) + table.checks.map(&:name) 179 | end 180 | 181 | Digest::MD5.hexdigest(names.join(','))[0..9] 182 | end 183 | end 184 | end 185 | end 186 | -------------------------------------------------------------------------------- /lib/db_schema/operations.rb: -------------------------------------------------------------------------------- 1 | module DbSchema 2 | module Operations 3 | # Abstract base class for rename operations. 4 | class RenameOperation 5 | include Dry::Equalizer(:old_name, :new_name) 6 | attr_reader :old_name, :new_name 7 | 8 | def initialize(old_name:, new_name:) 9 | @old_name = old_name 10 | @new_name = new_name 11 | end 12 | end 13 | 14 | class CreateTable 15 | include Dry::Equalizer(:table) 16 | attr_reader :table 17 | 18 | def initialize(table) 19 | @table = table 20 | end 21 | end 22 | 23 | class DropTable 24 | include Dry::Equalizer(:name) 25 | attr_reader :name 26 | 27 | def initialize(name) 28 | @name = name 29 | end 30 | end 31 | 32 | class RenameTable < RenameOperation 33 | end 34 | 35 | class AlterTable 36 | include Dry::Equalizer(:table_name, :changes) 37 | attr_reader :table_name, :changes 38 | 39 | def initialize(table_name, changes = []) 40 | @table_name = table_name 41 | @changes = changes 42 | end 43 | end 44 | 45 | # Abstract base class for single-column toggle operations. 46 | class ColumnOperation 47 | include Dry::Equalizer(:name) 48 | attr_reader :name 49 | 50 | def initialize(name) 51 | @name = name 52 | end 53 | end 54 | 55 | class CreateColumn 56 | include Dry::Equalizer(:field) 57 | attr_reader :field 58 | 59 | def initialize(field) 60 | @field = field 61 | end 62 | 63 | def name 64 | field.name 65 | end 66 | 67 | def type 68 | field.type 69 | end 70 | 71 | def options 72 | field.options 73 | end 74 | end 75 | 76 | class DropColumn < ColumnOperation 77 | end 78 | 79 | class RenameColumn < RenameOperation 80 | end 81 | 82 | class AlterColumnType 83 | SERIAL_TYPES = [:smallserial, :serial, :bigserial].freeze 84 | 85 | include Dry::Equalizer(:name, :old_type, :new_type, :using, :new_attributes) 86 | attr_reader :name, :old_type, :new_type, :using, :new_attributes 87 | 88 | def initialize(name, old_type:, new_type:, using: nil, **new_attributes) 89 | @name = name 90 | @old_type = old_type 91 | @new_type = new_type 92 | @using = using 93 | @new_attributes = new_attributes 94 | end 95 | 96 | def from_serial? 97 | SERIAL_TYPES.include?(old_type) 98 | end 99 | 100 | def to_serial? 101 | SERIAL_TYPES.include?(new_type) 102 | end 103 | end 104 | 105 | class AllowNull < ColumnOperation 106 | end 107 | 108 | class DisallowNull < ColumnOperation 109 | end 110 | 111 | class AlterColumnDefault 112 | include Dry::Equalizer(:name, :new_default) 113 | attr_reader :name, :new_default 114 | 115 | def initialize(name, new_default:) 116 | @name = name 117 | @new_default = new_default 118 | end 119 | end 120 | 121 | class CreateIndex 122 | include Dry::Equalizer(:index) 123 | attr_reader :index 124 | 125 | def initialize(index) 126 | @index = index 127 | end 128 | 129 | def primary? 130 | index.primary? 131 | end 132 | 133 | def name 134 | index.name 135 | end 136 | 137 | def columns 138 | index.columns 139 | end 140 | end 141 | 142 | class DropIndex 143 | include Dry::Equalizer(:name, :primary?) 144 | attr_reader :name 145 | 146 | def initialize(name, primary) 147 | @name = name 148 | @primary = primary 149 | end 150 | 151 | def primary? 152 | @primary 153 | end 154 | end 155 | 156 | class CreateCheckConstraint 157 | include Dry::Equalizer(:check) 158 | attr_reader :check 159 | 160 | def initialize(check) 161 | @check = check 162 | end 163 | end 164 | 165 | class DropCheckConstraint < ColumnOperation 166 | end 167 | 168 | class CreateForeignKey 169 | include Dry::Equalizer(:table_name, :foreign_key) 170 | attr_reader :table_name, :foreign_key 171 | 172 | def initialize(table_name, foreign_key) 173 | @table_name = table_name 174 | @foreign_key = foreign_key 175 | end 176 | end 177 | 178 | class DropForeignKey 179 | include Dry::Equalizer(:table_name, :fkey_name) 180 | attr_reader :table_name, :fkey_name 181 | 182 | def initialize(table_name, fkey_name) 183 | @table_name = table_name 184 | @fkey_name = fkey_name 185 | end 186 | end 187 | 188 | class CreateEnum 189 | include Dry::Equalizer(:enum) 190 | attr_reader :enum 191 | 192 | def initialize(enum) 193 | @enum = enum 194 | end 195 | end 196 | 197 | class DropEnum < ColumnOperation 198 | end 199 | 200 | class RenameEnum < RenameOperation 201 | end 202 | 203 | class AlterEnumValues 204 | include Dry::Equalizer(:enum_name, :new_values, :enum_fields) 205 | attr_reader :enum_name, :new_values, :enum_fields 206 | 207 | def initialize(enum_name, new_values, enum_fields) 208 | @enum_name = enum_name 209 | @new_values = new_values 210 | @enum_fields = enum_fields 211 | end 212 | end 213 | 214 | class CreateExtension 215 | include Dry::Equalizer(:extension) 216 | attr_reader :extension 217 | 218 | def initialize(extension) 219 | @extension = extension 220 | end 221 | end 222 | 223 | class DropExtension < ColumnOperation 224 | end 225 | 226 | class ExecuteQuery 227 | include Dry::Equalizer(:query) 228 | attr_reader :query 229 | 230 | def initialize(query) 231 | @query = query 232 | end 233 | end 234 | end 235 | end 236 | -------------------------------------------------------------------------------- /lib/db_schema/reader.rb: -------------------------------------------------------------------------------- 1 | module DbSchema 2 | module Reader 3 | class << self 4 | def reader_for(connection) 5 | case connection.adapter_scheme 6 | when :postgres 7 | unless defined?(Reader::Postgres) 8 | raise 'You need the \'db_schema-reader-postgres\' gem in order to work with PostgreSQL database structure.' 9 | end 10 | 11 | Reader::Postgres.new(connection) 12 | else 13 | raise NotImplementedError, "DbSchema::Reader does not support #{connection.adapter_scheme}." 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/db_schema/runner.rb: -------------------------------------------------------------------------------- 1 | module DbSchema 2 | class Runner 3 | attr_reader :changes, :connection 4 | 5 | def initialize(changes, connection) 6 | @changes = changes 7 | @connection = connection 8 | end 9 | 10 | def run! 11 | changes.each do |change| 12 | case change 13 | when Operations::CreateTable 14 | create_table(change) 15 | when Operations::DropTable 16 | drop_table(change) 17 | when Operations::RenameTable 18 | rename_table(change) 19 | when Operations::AlterTable 20 | alter_table(change) 21 | when Operations::CreateForeignKey 22 | create_foreign_key(change) 23 | when Operations::DropForeignKey 24 | drop_foreign_key(change) 25 | when Operations::CreateEnum 26 | create_enum(change) 27 | when Operations::DropEnum 28 | drop_enum(change) 29 | when Operations::RenameEnum 30 | rename_enum(change) 31 | when Operations::AlterEnumValues 32 | alter_enum_values(change) 33 | when Operations::CreateExtension 34 | create_extension(change) 35 | when Operations::DropExtension 36 | drop_extension(change) 37 | when Operations::ExecuteQuery 38 | execute_query(change) 39 | end 40 | end 41 | end 42 | 43 | private 44 | def create_table(change) 45 | connection.create_table(change.table.name) do 46 | change.table.fields.each do |field| 47 | options = Runner.map_options(field.class.type, field.options) 48 | column(field.name, field.type.capitalize, options) 49 | end 50 | 51 | change.table.indexes.each do |index| 52 | if index.primary? 53 | primary_key(index.columns.map(&:name), name: index.name) 54 | else 55 | index( 56 | index.columns_to_sequel, 57 | name: index.name, 58 | unique: index.unique?, 59 | type: index.type, 60 | where: index.condition 61 | ) 62 | end 63 | end 64 | 65 | change.table.checks.each do |check| 66 | constraint(check.name, check.condition) 67 | end 68 | end 69 | end 70 | 71 | def drop_table(change) 72 | connection.drop_table(change.name) 73 | end 74 | 75 | def rename_table(change) 76 | connection.rename_table(change.old_name, change.new_name) 77 | end 78 | 79 | def alter_table(change) 80 | connection.alter_table(change.table_name) do 81 | change.changes.each do |element| 82 | case element 83 | when Operations::CreateColumn 84 | options = Runner.map_options(element.type, element.options) 85 | add_column(element.name, element.type.capitalize, options) 86 | when Operations::DropColumn 87 | drop_column(element.name) 88 | when Operations::RenameColumn 89 | rename_column(element.old_name, element.new_name) 90 | when Operations::AlterColumnType 91 | if element.from_serial? 92 | raise NotImplementedError, 'Changing a SERIAL column to another type is not supported' 93 | end 94 | 95 | if element.to_serial? 96 | raise NotImplementedError, 'Changing a column type to SERIAL is not supported' 97 | end 98 | 99 | attributes = Runner.map_options(element.new_type, element.new_attributes) 100 | set_column_type(element.name, element.new_type.capitalize, using: element.using, **attributes) 101 | when Operations::AllowNull 102 | set_column_allow_null(element.name) 103 | when Operations::DisallowNull 104 | set_column_not_null(element.name) 105 | when Operations::AlterColumnDefault 106 | set_column_default(element.name, Runner.default_to_sequel(element.new_default)) 107 | when Operations::CreateIndex 108 | if element.primary? 109 | add_primary_key(element.columns.map(&:name), name: element.name) 110 | else 111 | add_index( 112 | element.index.columns_to_sequel, 113 | name: element.index.name, 114 | unique: element.index.unique?, 115 | type: element.index.type, 116 | where: element.index.condition 117 | ) 118 | end 119 | when Operations::DropIndex 120 | if element.primary? 121 | drop_constraint(element.name) 122 | else 123 | drop_index([], name: element.name) 124 | end 125 | when Operations::CreateCheckConstraint 126 | add_constraint(element.check.name, element.check.condition) 127 | when Operations::DropCheckConstraint 128 | drop_constraint(element.name) 129 | end 130 | end 131 | end 132 | end 133 | 134 | def create_foreign_key(change) 135 | connection.alter_table(change.table_name) do 136 | add_foreign_key(change.foreign_key.fields, change.foreign_key.table, change.foreign_key.options) 137 | end 138 | end 139 | 140 | def drop_foreign_key(change) 141 | connection.alter_table(change.table_name) do 142 | drop_foreign_key([], name: change.fkey_name) 143 | end 144 | end 145 | 146 | def create_enum(change) 147 | connection.create_enum(change.enum.name, change.enum.values) 148 | end 149 | 150 | def drop_enum(change) 151 | connection.drop_enum(change.name) 152 | end 153 | 154 | def rename_enum(change) 155 | old_name = connection.quote_identifier(change.old_name) 156 | new_name = connection.quote_identifier(change.new_name) 157 | 158 | connection.run(%Q(ALTER TYPE #{old_name} RENAME TO #{new_name})) 159 | end 160 | 161 | def alter_enum_values(change) 162 | change.enum_fields.each do |field_data| 163 | connection.alter_table(field_data[:table_name]) do 164 | set_column_type(field_data[:field_name], :VARCHAR) 165 | set_column_default(field_data[:field_name], nil) 166 | end 167 | end 168 | 169 | connection.drop_enum(change.enum_name) 170 | connection.create_enum(change.enum_name, change.new_values) 171 | 172 | change.enum_fields.each do |field_data| 173 | connection.alter_table(field_data[:table_name]) do 174 | field_type = if field_data[:array] 175 | "#{change.enum_name}[]" 176 | else 177 | change.enum_name 178 | end 179 | 180 | set_column_type( 181 | field_data[:field_name], 182 | field_type, 183 | using: "#{field_data[:field_name]}::#{field_type}" 184 | ) 185 | 186 | set_column_default(field_data[:field_name], field_data[:new_default]) unless field_data[:new_default].nil? 187 | end 188 | end 189 | end 190 | 191 | def create_extension(change) 192 | connection.run(%Q(CREATE EXTENSION #{connection.quote_identifier(change.extension.name)})) 193 | end 194 | 195 | def drop_extension(change) 196 | connection.run(%Q(DROP EXTENSION #{connection.quote_identifier(change.name)})) 197 | end 198 | 199 | def execute_query(change) 200 | connection.run(change.query) 201 | end 202 | 203 | class << self 204 | def map_options(type, options) 205 | mapping = case type 206 | when :char, :varchar, :bit, :varbit 207 | Utils.rename_keys(options, length: :size) 208 | when :numeric 209 | Utils.rename_keys(options) do |new_options| 210 | precision, scale = Utils.delete_at(new_options, :precision, :scale) 211 | 212 | if precision 213 | if scale 214 | new_options[:size] = [precision, scale] 215 | else 216 | new_options[:size] = precision 217 | end 218 | end 219 | end 220 | when :interval 221 | Utils.rename_keys(options, precision: :size) do |new_options| 222 | new_options[:type] = "INTERVAL #{new_options.delete(:fields).upcase}" 223 | end 224 | when :array 225 | Utils.rename_keys(options) do |new_options| 226 | new_options[:type] = "#{new_options.delete(:element_type)}[]" 227 | end 228 | else 229 | options 230 | end 231 | 232 | if mapping.key?(:default) 233 | mapping.merge(default: default_to_sequel(mapping[:default])) 234 | else 235 | mapping 236 | end 237 | end 238 | 239 | def default_to_sequel(default) 240 | if default.is_a?(Symbol) 241 | Sequel.lit(default.to_s) 242 | else 243 | default 244 | end 245 | end 246 | end 247 | end 248 | end 249 | -------------------------------------------------------------------------------- /lib/db_schema/utils.rb: -------------------------------------------------------------------------------- 1 | module DbSchema 2 | module Utils 3 | class << self 4 | def rename_keys(hash, mapping = {}) 5 | hash.reduce({}) do |final_hash, (key, value)| 6 | new_key = mapping.fetch(key, key) 7 | final_hash.merge(new_key => value) 8 | end.tap do |final_hash| 9 | yield(final_hash) if block_given? 10 | end 11 | end 12 | 13 | def filter_by_keys(hash, *needed_keys) 14 | hash.reduce({}) do |final_hash, (key, value)| 15 | if needed_keys.include?(key) 16 | final_hash.merge(key => value) 17 | else 18 | final_hash 19 | end 20 | end 21 | end 22 | 23 | def delete_at(hash, *keys) 24 | keys.map do |key| 25 | hash.delete(key) 26 | end 27 | end 28 | 29 | def symbolize_keys(hash) 30 | return hash unless hash.is_a?(Hash) 31 | 32 | hash.reduce({}) do |new_hash, (key, value)| 33 | new_hash.merge(key.to_sym => symbolize_keys(value)) 34 | end 35 | end 36 | 37 | def remove_nil_values(hash) 38 | hash.reduce({}) do |new_hash, (key, value)| 39 | if value.nil? 40 | new_hash 41 | else 42 | new_hash.merge(key => value) 43 | end 44 | end 45 | end 46 | 47 | def to_hash(array, attribute) 48 | array.reduce({}) do |hash, object| 49 | hash.merge(object.public_send(attribute) => object) 50 | end 51 | end 52 | 53 | def sort_by_class(array, sorted_classes) 54 | sorted_classes.flat_map do |klass| 55 | array.select { |object| object.is_a?(klass) } 56 | end 57 | end 58 | 59 | def filter_by_class(array, klass) 60 | array.select do |element| 61 | element.is_a?(klass) 62 | end 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/db_schema/validator.rb: -------------------------------------------------------------------------------- 1 | require 'sequel/extensions/pg_array' 2 | 3 | module DbSchema 4 | module Validator 5 | class << self 6 | def validate(schema) 7 | table_errors = schema.tables.each_with_object([]) do |table, errors| 8 | primary_keys_count = table.indexes.select(&:primary?).count 9 | if primary_keys_count > 1 10 | error_message = %(Table "#{table.name}" has #{primary_keys_count} primary keys) 11 | errors << error_message 12 | end 13 | 14 | table.fields.each do |field| 15 | if field.custom? 16 | type = schema.enums.find { |enum| enum.name == field.type } 17 | 18 | if type.nil? 19 | error_message = %(Field "#{table.name}.#{field.name}" has unknown type "#{field.type}") 20 | errors << error_message 21 | elsif !field.default.nil? && !type.values.include?(field.default.to_sym) 22 | errors << %(Field "#{table.name}.#{field.name}" has invalid default value "#{field.default}" (valid values are #{type.values.map(&:to_s)})) 23 | end 24 | elsif field.array? && field.custom_element_type? 25 | type = schema.enums.find { |enum| enum.name == field.element_type.type } 26 | 27 | if type.nil? 28 | error_message = %(Array field "#{table.name}.#{field.name}" has unknown element type "#{field.element_type.type}") 29 | errors << error_message 30 | elsif !field.default.nil? 31 | default_array = Sequel::Postgres::PGArray::Parser.new(field.default).parse.map(&:to_sym) 32 | invalid_values = default_array - type.values 33 | 34 | if invalid_values.any? 35 | error_message = %(Array field "#{table.name}.#{field.name}" has invalid default value #{default_array.map(&:to_s)} (valid values are #{type.values.map(&:to_s)})) 36 | errors << error_message 37 | end 38 | end 39 | end 40 | end 41 | 42 | field_names = table.fields.map(&:name) 43 | 44 | table.indexes.each do |index| 45 | index.columns.reject(&:expression?).map(&:name).each do |field_name| 46 | unless field_names.include?(field_name) 47 | error_message = %(Index "#{index.name}" refers to a missing field "#{table.name}.#{field_name}") 48 | errors << error_message 49 | end 50 | end 51 | end 52 | 53 | table.foreign_keys.each do |fkey| 54 | fkey.fields.each do |field_name| 55 | unless field_names.include?(field_name) 56 | error_message = %(Foreign key "#{fkey.name}" constrains a missing field "#{table.name}.#{field_name}") 57 | errors << error_message 58 | end 59 | end 60 | 61 | if referenced_table = schema.tables.find { |table| table.name == fkey.table } 62 | if fkey.references_primary_key? 63 | unless referenced_table.indexes.any?(&:primary?) 64 | error_message = %(Foreign key "#{fkey.name}" refers to primary key of table "#{fkey.table}" which does not have a primary key) 65 | errors << error_message 66 | end 67 | else 68 | referenced_table_field_names = referenced_table.fields.map(&:name) 69 | 70 | fkey.keys.each do |key| 71 | unless referenced_table_field_names.include?(key) 72 | error_message = %(Foreign key "#{fkey.name}" refers to a missing field "#{fkey.table}.#{key}") 73 | errors << error_message 74 | end 75 | end 76 | end 77 | else 78 | error_message = %(Foreign key "#{fkey.name}" refers to a missing table "#{fkey.table}") 79 | errors << error_message 80 | end 81 | end 82 | end 83 | 84 | enum_errors = schema.enums.each_with_object([]) do |enum, errors| 85 | if enum.values.empty? 86 | error_message = %(Enum "#{enum.name}" contains no values) 87 | errors << error_message 88 | end 89 | end 90 | 91 | Result.new(table_errors + enum_errors) 92 | end 93 | end 94 | 95 | class Result 96 | attr_reader :errors 97 | 98 | def initialize(errors) 99 | @errors = errors 100 | end 101 | 102 | def valid? 103 | errors.empty? 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/db_schema/version.rb: -------------------------------------------------------------------------------- 1 | module DbSchema 2 | VERSION = '0.5' 3 | end 4 | -------------------------------------------------------------------------------- /spec/db_schema/changes_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe DbSchema::Changes do 2 | describe '.between' do 3 | context 'with tables being added and removed' do 4 | let(:users_fields) do 5 | [ 6 | DbSchema::Definitions::Field::Serial.new(:id), 7 | DbSchema::Definitions::Field::Varchar.new(:name, length: 20), 8 | DbSchema::Definitions::Field::Varchar.new(:email), 9 | DbSchema::Definitions::Field::Integer.new(:city_id) 10 | ] 11 | end 12 | 13 | let(:users_indexes) do 14 | [ 15 | DbSchema::Definitions::Index.new( 16 | name: :users_pkey, 17 | columns: [ 18 | DbSchema::Definitions::Index::TableField.new(:id) 19 | ], 20 | primary: true 21 | ) 22 | ] 23 | end 24 | 25 | let(:users_checks) do 26 | [ 27 | DbSchema::Definitions::CheckConstraint.new( 28 | name: :name_or_email, 29 | condition: 'name IS NOT NULL OR email IS NOT NULL' 30 | ) 31 | ] 32 | end 33 | 34 | let(:users_foreign_keys) do 35 | [ 36 | DbSchema::Definitions::ForeignKey.new( 37 | name: :users_city_id_fkey, 38 | fields: [:city_id], 39 | table: :cities 40 | ) 41 | ] 42 | end 43 | 44 | let(:posts_fields) do 45 | [ 46 | DbSchema::Definitions::Field::Integer.new(:id), 47 | DbSchema::Definitions::Field::Varchar.new(:title) 48 | ] 49 | end 50 | 51 | let(:posts_foreign_keys) do 52 | [ 53 | DbSchema::Definitions::ForeignKey.new( 54 | name: :posts_city_id_fkey, 55 | fields: [:city_id], 56 | table: :cities 57 | ) 58 | ] 59 | end 60 | 61 | let(:cities_fields) do 62 | [ 63 | DbSchema::Definitions::Field::Integer.new(:id), 64 | DbSchema::Definitions::Field::Varchar.new(:name, null: false), 65 | DbSchema::Definitions::Field::Integer.new(:country_id, null: false) 66 | ] 67 | end 68 | 69 | let(:desired_schema) do 70 | DbSchema::Definitions::Schema.new( 71 | tables: [ 72 | DbSchema::Definitions::Table.new( 73 | :users, 74 | fields: users_fields, 75 | indexes: users_indexes, 76 | checks: users_checks, 77 | foreign_keys: users_foreign_keys 78 | ), 79 | DbSchema::Definitions::Table.new(:cities, fields: cities_fields) 80 | ] 81 | ) 82 | end 83 | 84 | let(:actual_schema) do 85 | DbSchema::Definitions::Schema.new( 86 | tables: [ 87 | DbSchema::Definitions::Table.new( 88 | :posts, 89 | fields: posts_fields, 90 | foreign_keys: posts_foreign_keys 91 | ), 92 | DbSchema::Definitions::Table.new(:cities, fields: cities_fields) 93 | ] 94 | ) 95 | end 96 | 97 | it 'returns changes between two schemas' do 98 | changes = DbSchema::Changes.between(desired_schema, actual_schema) 99 | 100 | expect(changes).to eq( 101 | [ 102 | DbSchema::Operations::DropForeignKey.new(:posts, posts_foreign_keys.first.name), 103 | DbSchema::Operations::CreateTable.new( 104 | DbSchema::Definitions::Table.new( 105 | :users, 106 | fields: users_fields, 107 | indexes: users_indexes, 108 | checks: users_checks, 109 | foreign_keys: users_foreign_keys 110 | ) 111 | ), 112 | DbSchema::Operations::DropTable.new(:posts), 113 | DbSchema::Operations::CreateForeignKey.new(:users, users_foreign_keys.first) 114 | ] 115 | ) 116 | end 117 | 118 | it 'ignores matching tables' do 119 | changes = DbSchema::Changes.between(desired_schema, actual_schema) 120 | 121 | expect(changes.count).to eq(4) 122 | end 123 | end 124 | 125 | context 'with table changed' do 126 | let(:desired_schema) do 127 | fields = [ 128 | DbSchema::Definitions::Field::Serial.new(:id), 129 | DbSchema::Definitions::Field::Varchar.new(:name, length: 60), 130 | DbSchema::Definitions::Field::Varchar.new(:email, null: false), 131 | DbSchema::Definitions::Field::Varchar.new(:type, null: false, default: 'guest'), 132 | DbSchema::Definitions::Field::Integer.new(:city_id), 133 | DbSchema::Definitions::Field::Integer.new(:country_id), 134 | DbSchema::Definitions::Field::Integer.new(:group_id), 135 | DbSchema::Definitions::Field::Custom.class_for(:user_status).new(:status) 136 | ] 137 | 138 | indexes = [ 139 | DbSchema::Definitions::Index.new( 140 | name: :users_pkey, 141 | columns: [DbSchema::Definitions::Index::TableField.new(:id)], 142 | primary: true 143 | ), 144 | DbSchema::Definitions::Index.new( 145 | name: :users_name_index, 146 | columns: [DbSchema::Definitions::Index::Expression.new('lower(name)')], 147 | unique: true, 148 | condition: 'email IS NOT NULL' 149 | ), 150 | DbSchema::Definitions::Index.new( 151 | name: :users_email_index, 152 | columns: [DbSchema::Definitions::Index::TableField.new(:email, order: :desc)], 153 | type: :hash, 154 | unique: true 155 | ) 156 | ] 157 | 158 | checks = [ 159 | DbSchema::Definitions::CheckConstraint.new( 160 | name: :min_name_length_check, 161 | condition: 'char_length(name) > 5' 162 | ), 163 | DbSchema::Definitions::CheckConstraint.new( 164 | name: :location_check, 165 | condition: 'city_id IS NOT NULL OR country_id IS NOT NULL' 166 | ) 167 | ] 168 | 169 | foreign_keys = [ 170 | DbSchema::Definitions::ForeignKey.new( 171 | name: :users_city_id_fkey, 172 | fields: [:city_id], 173 | table: :cities 174 | ), 175 | DbSchema::Definitions::ForeignKey.new( 176 | name: :users_group_id_fkey, 177 | fields: [:group_id], 178 | table: :groups, 179 | on_delete: :cascade 180 | ) 181 | ] 182 | 183 | DbSchema::Definitions::Schema.new( 184 | tables: [ 185 | DbSchema::Definitions::Table.new( 186 | :users, 187 | fields: fields, 188 | indexes: indexes, 189 | checks: checks, 190 | foreign_keys: foreign_keys 191 | ) 192 | ] 193 | ) 194 | end 195 | 196 | let(:actual_schema) do 197 | fields = [ 198 | DbSchema::Definitions::Field::Serial.new(:id), 199 | DbSchema::Definitions::Field::Varchar.new(:name), 200 | DbSchema::Definitions::Field::Integer.new(:age), 201 | DbSchema::Definitions::Field::Integer.new(:type), 202 | DbSchema::Definitions::Field::Integer.new(:city_id), 203 | DbSchema::Definitions::Field::Integer.new(:country_id), 204 | DbSchema::Definitions::Field::Integer.new(:group_id), 205 | DbSchema::Definitions::Field::Integer.new(:status) 206 | ] 207 | 208 | indexes = [ 209 | DbSchema::Definitions::Index.new( 210 | name: :users_name_index, 211 | columns: [DbSchema::Definitions::Index::TableField.new(:name)] 212 | ), 213 | DbSchema::Definitions::Index.new( 214 | name: :users_type_index, 215 | columns: [DbSchema::Definitions::Index::TableField.new(:type)] 216 | ) 217 | ] 218 | 219 | checks = [ 220 | DbSchema::Definitions::CheckConstraint.new( 221 | name: :min_age_check, 222 | condition: 'age >= 18' 223 | ), 224 | DbSchema::Definitions::CheckConstraint.new( 225 | name: :location_check, 226 | condition: 'city_id IS NOT NULL AND country_id IS NOT NULL' 227 | ) 228 | ] 229 | 230 | foreign_keys = [ 231 | DbSchema::Definitions::ForeignKey.new( 232 | name: :users_country_id_fkey, 233 | fields: [:country_id], 234 | table: :countries 235 | ), 236 | DbSchema::Definitions::ForeignKey.new( 237 | name: :users_group_id_fkey, 238 | fields: [:group_id], 239 | table: :groups, 240 | on_delete: :set_null 241 | ) 242 | ] 243 | 244 | DbSchema::Definitions::Schema.new( 245 | tables: [ 246 | DbSchema::Definitions::Table.new( 247 | :users, 248 | fields: fields, 249 | indexes: indexes, 250 | checks: checks, 251 | foreign_keys: foreign_keys 252 | ) 253 | ] 254 | ) 255 | end 256 | 257 | it 'returns changes between two schemas' do 258 | changes = DbSchema::Changes.between(desired_schema, actual_schema) 259 | 260 | drop_group_id, drop_country_id, alter_table, create_city_id, create_group_id = changes 261 | expect(alter_table).to be_a(DbSchema::Operations::AlterTable) 262 | 263 | expect(alter_table.changes).to eq([ 264 | DbSchema::Operations::DropCheckConstraint.new(:location_check), 265 | DbSchema::Operations::DropCheckConstraint.new(:min_age_check), 266 | DbSchema::Operations::DropIndex.new(:users_name_index, false), 267 | DbSchema::Operations::DropIndex.new(:users_type_index, false), 268 | DbSchema::Operations::DropColumn.new(:age), 269 | DbSchema::Operations::AlterColumnType.new(:name, old_type: :varchar, new_type: :varchar, length: 60), 270 | DbSchema::Operations::AlterColumnType.new(:type, old_type: :integer, new_type: :varchar), 271 | DbSchema::Operations::AlterColumnType.new(:status, old_type: :integer, new_type: :user_status), 272 | DbSchema::Operations::DisallowNull.new(:type), 273 | DbSchema::Operations::AlterColumnDefault.new(:type, new_default: 'guest'), 274 | DbSchema::Operations::CreateColumn.new(DbSchema::Definitions::Field::Varchar.new(:email, null: false)), 275 | DbSchema::Operations::CreateIndex.new( 276 | DbSchema::Definitions::Index.new( 277 | name: :users_pkey, 278 | columns: [DbSchema::Definitions::Index::TableField.new(:id)], 279 | primary: true 280 | ) 281 | ), 282 | DbSchema::Operations::CreateIndex.new( 283 | DbSchema::Definitions::Index.new( 284 | name: :users_name_index, 285 | columns: [DbSchema::Definitions::Index::Expression.new('lower(name)')], 286 | unique: true, 287 | condition: 'email IS NOT NULL' 288 | ) 289 | ), 290 | DbSchema::Operations::CreateIndex.new( 291 | DbSchema::Definitions::Index.new( 292 | name: :users_email_index, 293 | columns: [DbSchema::Definitions::Index::TableField.new(:email, order: :desc)], 294 | type: :hash, 295 | unique: true 296 | ) 297 | ), 298 | DbSchema::Operations::CreateCheckConstraint.new( 299 | DbSchema::Definitions::CheckConstraint.new( 300 | name: :min_name_length_check, 301 | condition: 'char_length(name) > 5' 302 | ) 303 | ), 304 | DbSchema::Operations::CreateCheckConstraint.new( 305 | DbSchema::Definitions::CheckConstraint.new( 306 | name: :location_check, 307 | condition: 'city_id IS NOT NULL OR country_id IS NOT NULL' 308 | ) 309 | ) 310 | ]) 311 | 312 | expect(drop_group_id).to eq( 313 | DbSchema::Operations::DropForeignKey.new(:users, :users_group_id_fkey) 314 | ) 315 | expect(drop_country_id).to eq( 316 | DbSchema::Operations::DropForeignKey.new(:users, :users_country_id_fkey) 317 | ) 318 | 319 | expect(create_city_id).to eq( 320 | DbSchema::Operations::CreateForeignKey.new( 321 | :users, 322 | DbSchema::Definitions::ForeignKey.new( 323 | name: :users_city_id_fkey, 324 | fields: [:city_id], 325 | table: :cities 326 | ) 327 | ) 328 | ) 329 | expect(create_group_id).to eq( 330 | DbSchema::Operations::CreateForeignKey.new( 331 | :users, 332 | DbSchema::Definitions::ForeignKey.new( 333 | name: :users_group_id_fkey, 334 | fields: [:group_id], 335 | table: :groups, 336 | on_delete: :cascade 337 | ) 338 | ), 339 | ) 340 | end 341 | 342 | context 'with primary key removed' do 343 | before(:each) do 344 | actual_schema.table(:users).indexes.unshift(desired_schema.table(:users).indexes.shift) 345 | end 346 | 347 | it 'returns changes between two schemas' do 348 | changes = DbSchema::Changes.between(desired_schema, actual_schema) 349 | expect(changes.count).to eq(5) 350 | 351 | alter_table = changes[2] 352 | expect(alter_table).to be_a(DbSchema::Operations::AlterTable) 353 | expect(alter_table.changes).to include(DbSchema::Operations::DropIndex.new(:users_pkey, true)) 354 | end 355 | end 356 | 357 | context 'with just foreign keys changed' do 358 | let(:posts_fields) do 359 | [ 360 | DbSchema::Definitions::Field::Serial.new(:id), 361 | DbSchema::Definitions::Field::Varchar.new(:title), 362 | DbSchema::Definitions::Field::Integer.new(:user_id, null: false), 363 | DbSchema::Definitions::Field::Integer.new(:category_id, null: false) 364 | ] 365 | end 366 | 367 | let(:desired_schema) do 368 | DbSchema::Definitions::Schema.new( 369 | tables: [ 370 | DbSchema::Definitions::Table.new( 371 | :posts, 372 | fields: posts_fields, 373 | foreign_keys: [ 374 | DbSchema::Definitions::ForeignKey.new( 375 | name: :posts_user_id_fkey, 376 | fields: [:user_id], 377 | table: :users 378 | ) 379 | ] 380 | ) 381 | ] 382 | ) 383 | end 384 | 385 | let(:actual_schema) do 386 | DbSchema::Definitions::Schema.new( 387 | tables: [ 388 | DbSchema::Definitions::Table.new( 389 | :posts, 390 | fields: posts_fields, 391 | foreign_keys: [ 392 | DbSchema::Definitions::ForeignKey.new( 393 | name: :posts_category_id_fkey, 394 | fields: [:category_id], 395 | table: :categories 396 | ) 397 | ] 398 | ) 399 | ] 400 | ) 401 | end 402 | 403 | it 'returns only foreign key operations' do 404 | changes = DbSchema::Changes.between(desired_schema, actual_schema) 405 | 406 | expect(changes.count).to eq(2) 407 | expect(changes.map(&:class)).to eq([ 408 | DbSchema::Operations::DropForeignKey, 409 | DbSchema::Operations::CreateForeignKey 410 | ]) 411 | end 412 | end 413 | end 414 | 415 | context 'with enums added and removed' do 416 | let(:desired_schema) do 417 | DbSchema::Definitions::Schema.new( 418 | enums: [ 419 | DbSchema::Definitions::Enum.new(:happiness, %i(good ok bad)) 420 | ] 421 | ) 422 | end 423 | 424 | let(:actual_schema) do 425 | DbSchema::Definitions::Schema.new( 426 | enums: [ 427 | DbSchema::Definitions::Enum.new(:skill, %i(beginner advanced expert)) 428 | ] 429 | ) 430 | end 431 | 432 | it 'returns changes between schemas' do 433 | changes = DbSchema::Changes.between(desired_schema, actual_schema) 434 | 435 | expect(changes.count).to eq(2) 436 | expect(changes).to include( 437 | DbSchema::Operations::CreateEnum.new( 438 | DbSchema::Definitions::Enum.new(:happiness, %i(good ok bad)) 439 | ) 440 | ) 441 | expect(changes).to include( 442 | DbSchema::Operations::DropEnum.new(:skill) 443 | ) 444 | end 445 | end 446 | 447 | context 'with enums changed' do 448 | let(:desired_values) { %i(happy good ok moderate bad) } 449 | let(:actual_values) { %i(good moderate ok bad unhappy) } 450 | 451 | let(:desired_schema) do 452 | DbSchema::Definitions::Schema.new( 453 | enums: [ 454 | DbSchema::Definitions::Enum.new(:happiness, desired_values) 455 | ] 456 | ) 457 | end 458 | 459 | let(:actual_schema) do 460 | DbSchema::Definitions::Schema.new( 461 | enums: [ 462 | DbSchema::Definitions::Enum.new(:happiness, actual_values) 463 | ] 464 | ) 465 | end 466 | 467 | it 'returns a Operations::AlterEnumValues' do 468 | changes = DbSchema::Changes.between(desired_schema, actual_schema) 469 | 470 | expect(changes).to eq([ 471 | DbSchema::Operations::AlterEnumValues.new(:happiness, desired_values, []) 472 | ]) 473 | end 474 | 475 | context 'when the enum is used in a column' do 476 | let(:desired_schema) do 477 | DbSchema::Definitions::Schema.new( 478 | tables: [ 479 | DbSchema::Definitions::Table.new(:people, 480 | fields: [ 481 | DbSchema::Definitions::Field::Custom.class_for(:happiness).new(:happiness, default: 'happy') 482 | ] 483 | ) 484 | ], 485 | enums: [ 486 | DbSchema::Definitions::Enum.new(:happiness, desired_values) 487 | ] 488 | ) 489 | end 490 | 491 | let(:actual_schema) do 492 | DbSchema::Definitions::Schema.new( 493 | tables: [ 494 | DbSchema::Definitions::Table.new(:people, 495 | fields: [ 496 | DbSchema::Definitions::Field::Custom.class_for(:happiness).new(:happiness, default: 'unhappy') 497 | ] 498 | ) 499 | ], 500 | enums: [ 501 | DbSchema::Definitions::Enum.new(:happiness, actual_values) 502 | ] 503 | ) 504 | end 505 | 506 | it 'returns a Operations::AlterEnumValues with existing enum fields' do 507 | changes = DbSchema::Changes.between(desired_schema, actual_schema) 508 | 509 | expect(changes).to eq([ 510 | DbSchema::Operations::AlterEnumValues.new( 511 | :happiness, 512 | desired_values, 513 | [ 514 | { 515 | table_name: :people, 516 | field_name: :happiness, 517 | new_default: 'happy', 518 | array: false 519 | } 520 | ] 521 | ), 522 | DbSchema::Operations::AlterTable.new( 523 | :people, 524 | [ 525 | DbSchema::Operations::AlterColumnDefault.new(:happiness, new_default: 'happy') 526 | ] 527 | ) 528 | ]) 529 | end 530 | 531 | context 'in an enum array' do 532 | let(:desired_schema) do 533 | DbSchema::Definitions::Schema.new( 534 | tables: [ 535 | DbSchema::Definitions::Table.new(:users, 536 | fields: [ 537 | DbSchema::Definitions::Field::Array.new(:roles, element_type: :user_role, default: '{}') 538 | ] 539 | ) 540 | ], 541 | enums: [ 542 | DbSchema::Definitions::Enum.new(:user_role, [:user, :admin]) 543 | ] 544 | ) 545 | end 546 | 547 | let(:actual_schema) do 548 | DbSchema::Definitions::Schema.new( 549 | tables: [ 550 | DbSchema::Definitions::Table.new(:users, 551 | fields: [ 552 | DbSchema::Definitions::Field::Array.new(:roles, element_type: :user_role, default: '{}') 553 | ] 554 | ) 555 | ], 556 | enums: [ 557 | DbSchema::Definitions::Enum.new(:user_role, [:guest, :user, :admin]) 558 | ] 559 | ) 560 | end 561 | 562 | it 'returns a Operations::AlterEnumValues with existing enum array fields' do 563 | changes = DbSchema::Changes.between(desired_schema, actual_schema) 564 | 565 | expect(changes).to eq([ 566 | DbSchema::Operations::AlterEnumValues.new( 567 | :user_role, 568 | [:user, :admin], 569 | [ 570 | { 571 | table_name: :users, 572 | field_name: :roles, 573 | new_default: '{}', 574 | array: true 575 | } 576 | ] 577 | ) 578 | ]) 579 | end 580 | end 581 | end 582 | end 583 | 584 | context 'with extensions added and removed' do 585 | let(:desired_schema) do 586 | DbSchema::Definitions::Schema.new( 587 | extensions: [ 588 | DbSchema::Definitions::Extension.new(:ltree) 589 | ] 590 | ) 591 | end 592 | 593 | let(:actual_schema) do 594 | DbSchema::Definitions::Schema.new( 595 | extensions: [ 596 | DbSchema::Definitions::Extension.new(:hstore) 597 | ] 598 | ) 599 | end 600 | 601 | it 'returns changes between schemas' do 602 | changes = DbSchema::Changes.between(desired_schema, actual_schema) 603 | 604 | expect(changes.count).to eq(2) 605 | expect(changes).to include( 606 | DbSchema::Operations::CreateExtension.new( 607 | DbSchema::Definitions::Extension.new(:ltree) 608 | ) 609 | ) 610 | expect(changes).to include( 611 | DbSchema::Operations::DropExtension.new(:hstore) 612 | ) 613 | end 614 | end 615 | end 616 | end 617 | -------------------------------------------------------------------------------- /spec/db_schema/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe DbSchema::Configuration do 2 | subject { DbSchema::Configuration.new } 3 | 4 | describe '#initialize' do 5 | it 'creates a default configuration' do 6 | expect(subject.adapter).to eq('postgres') 7 | expect(subject.host).to eq('localhost') 8 | expect(subject.port).to eq(5432) 9 | expect(subject.database).to be_nil 10 | expect(subject.user).to be_nil 11 | expect(subject.password).to eq('') 12 | 13 | expect(subject.log_changes?).to be_truthy 14 | expect(subject.dry_run?).to be_falsy 15 | expect(subject.post_check_enabled?).to be_truthy 16 | end 17 | end 18 | 19 | describe '#merge' do 20 | it 'returns a new configuration filled with passed in values and defaults' do 21 | configuration = subject.merge(database: 'db_schema_test', user: '7even') 22 | 23 | expect(configuration.database).to eq('db_schema_test') 24 | expect(configuration.user).to eq('7even') 25 | expect(configuration.password).to eq('') 26 | end 27 | 28 | context 'with a :url option' do 29 | let(:url) { 'postgresql://user:password@some_host/db_schema' } 30 | 31 | it "parses the URL and takes it's non-nil attributes" do 32 | configuration = subject.merge(url: url) 33 | 34 | expect(configuration.host).to eq('some_host') 35 | expect(configuration.port).to eq(5432) 36 | expect(configuration.database).to eq('db_schema') 37 | expect(configuration.user).to eq('user') 38 | expect(configuration.password).to eq('password') 39 | end 40 | end 41 | 42 | context 'when called several times' do 43 | it 'merges all params together' do 44 | configuration = subject.merge(database: 'db_schema_test').merge(user: '7even') 45 | 46 | expect(configuration.database).to eq('db_schema_test') 47 | expect(configuration.user).to eq('7even') 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/db_schema/dsl/migration_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe DbSchema::DSL::Migration do 2 | describe '#migration' do 3 | let(:schema) do 4 | schema_block = -> (db) do 5 | db.table :people do |t| 6 | t.primary_key :id 7 | end 8 | end 9 | 10 | DbSchema::DSL.new(schema_block).schema 11 | end 12 | 13 | let(:migration_block) do 14 | -> (migration) do 15 | migration.apply_if do |schema| 16 | schema.has_table?(:people) 17 | end 18 | 19 | migration.skip_if do |schema| 20 | schema.has_table?(:users) 21 | end 22 | 23 | migration.run do |migrator| 24 | migrator.create_table :users do |t| 25 | t.primary_key :id 26 | t.varchar :first_name 27 | t.varchar :last_name 28 | t.integer :city_id, null: false, references: :cities 29 | end 30 | 31 | migrator.drop_table :people 32 | 33 | migrator.rename_table :comments, to: :messages 34 | 35 | migrator.alter_table :messages do |t| 36 | t.add_column :title, :varchar, null: false 37 | t.drop_column :updated_at 38 | t.rename_column :body, to: :text 39 | t.alter_column_type :created_at, :timestamptz 40 | t.alter_column_type :read, :boolean, using: 'read::boolean' 41 | t.allow_null :text 42 | t.disallow_null :created_at 43 | t.alter_column_default :created_at, :'now()' 44 | 45 | t.add_index :user_id 46 | t.drop_index :messages_created_at_index 47 | 48 | t.add_check :title_length, 'char_length(title) >= 5' 49 | t.drop_check :text_length 50 | 51 | t.add_foreign_key :user_id, references: :users 52 | t.drop_foreign_key :messages_section_id_fkey 53 | end 54 | 55 | migrator.create_enum :user_role, %i(guest user admin) 56 | migrator.drop_enum :user_mood 57 | 58 | migrator.create_extension :ltree 59 | migrator.drop_extension :hstore 60 | 61 | migrator.execute 'UPDATE messages SET read = "t"' 62 | end 63 | end 64 | end 65 | 66 | subject { DbSchema::DSL::Migration.new('Migration name', migration_block) } 67 | 68 | it 'returns the migration object' do 69 | migration = subject.migration 70 | 71 | expect(migration.name).to eq('Migration name') 72 | 73 | expect(migration.conditions[:apply].count).to eq(1) 74 | expect(migration.conditions[:apply].first.call(schema)).to eq(true) 75 | expect(migration.conditions[:skip].count).to eq(1) 76 | expect(migration.conditions[:skip].first.call(schema)).to eq(false) 77 | 78 | expect(migration.body).to be_a(Proc) 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/db_schema/dsl_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe DbSchema::DSL do 2 | let(:schema_block) do 3 | -> (db) do 4 | db.enum :user_status, %i(user moderator admin) 5 | 6 | db.extension :hstore 7 | 8 | db.table :users do |t| 9 | t.serial :id, null: false, default: 1, primary_key: true 10 | t.varchar :name, null: false, unique: true, check: 'char_length(name) > 0' 11 | t.varchar :email, default: 'mail@example.com' 12 | t.char :sex, index: true 13 | t.integer :city_id, references: :cities 14 | t.array :strings, of: :varchar 15 | t.user_status :status, null: false 16 | t.array :previous_statuses, of: :user_status 17 | t.happiness :mood, index: true 18 | t.timestamptz :created_at, default: :'now()' 19 | 20 | t.index :email, name: :users_email_idx, unique: true, where: 'email IS NOT NULL' 21 | t.index :strings, using: :gin 22 | t.index 'lower(email)' 23 | 24 | t.check :valid_sex, "sex IN ('M', 'F')" 25 | end 26 | 27 | db.enum :happiness, [:sad, :ok, :good, :happy] 28 | 29 | db.table :cities do |t| 30 | t.serial :id, primary_key: true 31 | t.varchar :name, null: false 32 | end 33 | 34 | db.table :posts do |t| 35 | t.serial :id 36 | t.varchar :title 37 | t.integer :user_id 38 | t.varchar :user_name 39 | t.integer :col1 40 | t.integer :col2 41 | t.integer :col3 42 | t.integer :col4 43 | 44 | t.primary_key :id, name: :my_pkey 45 | 46 | t.index :user_id 47 | t.index col1: :asc, col2: :desc, col3: :asc_nulls_first, col4: :desc_nulls_last 48 | t.index 'col2 - col1' => :desc, 'col3 + col4' => :asc_nulls_first 49 | 50 | t.foreign_key :user_id, references: :users, on_delete: :set_null, deferrable: true 51 | t.foreign_key :user_name, references: [:users, :name], name: :user_name_fkey, on_update: :cascade 52 | end 53 | 54 | db.table :points do |t| 55 | t.decimal :lat 56 | t.decimal :lng 57 | 58 | t.primary_key :lat, :lng 59 | end 60 | 61 | db.migrate 'Rename people to users' do |migration| 62 | migration.apply_if do |schema| 63 | schema.has_table?(:people) 64 | end 65 | 66 | migration.run do |migrator| 67 | migrator.rename_table :people, to: :users 68 | end 69 | end 70 | 71 | db.migrate 'Join first_name & last_name into name' do |migration| 72 | migration.apply_if do |schema| 73 | schema.table(:users).has_field?(:first_name) 74 | end 75 | 76 | migration.apply_if do |schema| 77 | schema.table(:users).has_field?(:last_name) 78 | end 79 | 80 | migration.skip_if do |schema| 81 | schema.table(:users).has_field?(:name) 82 | end 83 | 84 | migration.run do |migrator| 85 | migrator.alter_table(:users) do |t| 86 | t.add_column :name, :varchar 87 | t.execute "UPDATE users SET name = first_name || ' ' || last_name" 88 | t.disallow_null :name 89 | t.drop_column :first_name 90 | t.drop_column :last_name 91 | end 92 | end 93 | end 94 | end 95 | end 96 | 97 | subject { DbSchema::DSL.new(schema_block) } 98 | 99 | describe '#schema' do 100 | let(:schema) { subject.schema } 101 | 102 | it 'returns fields' do 103 | users = schema.table(:users) 104 | posts = schema.table(:posts) 105 | cities = schema.table(:cities) 106 | points = schema.table(:points) 107 | 108 | expect(users.fields.count).to eq(10) 109 | expect(posts.fields.count).to eq(8) 110 | expect(cities.fields.count).to eq(2) 111 | 112 | expect(users.field(:id).type).to eq(:serial) 113 | expect(users.field(:id)).not_to be_null 114 | expect(users.field(:id).default).to be_nil 115 | 116 | expect(users.field(:name).type).to eq(:varchar) 117 | expect(users.field(:name)).not_to be_null 118 | 119 | expect(users.field(:email).type).to eq(:varchar) 120 | expect(users.field(:email).default).to eq('mail@example.com') 121 | 122 | expect(users.field(:sex).type).to eq(:char) 123 | expect(users.field(:sex).options[:length]).to eq(1) 124 | 125 | expect(users.field(:city_id).type).to eq(:integer) 126 | 127 | expect(users.field(:strings)).to be_array 128 | expect(users.field(:strings).options[:element_type]).to eq(:varchar) 129 | 130 | expect(users.field(:status)).to be_custom 131 | expect(users.field(:status).type).to eq(:user_status) 132 | expect(users.field(:status)).not_to be_null 133 | 134 | expect(users.field(:previous_statuses)).to be_array 135 | expect(users.field(:previous_statuses).options[:element_type]).to eq(:user_status) 136 | 137 | expect(users.field(:mood)).to be_custom 138 | expect(users.field(:mood).type).to eq(:happiness) 139 | 140 | expect(users.field(:created_at).type).to eq(:timestamptz) 141 | expect(users.field(:created_at).default).to eq(:'now()') 142 | 143 | expect(points.field(:lat)).not_to be_null 144 | expect(points.field(:lng)).not_to be_null 145 | end 146 | 147 | it 'returns indexes' do 148 | users = schema.table(:users) 149 | posts = schema.table(:posts) 150 | points = schema.table(:points) 151 | 152 | expect(users.indexes.count).to eq(7) 153 | expect(posts.indexes.count).to eq(4) 154 | expect(points.indexes.count).to eq(1) 155 | 156 | expect(users.index(:users_pkey).columns).to eq([ 157 | DbSchema::Definitions::Index::TableField.new(:id) 158 | ]) 159 | expect(users.index(:users_pkey)).to be_primary 160 | expect(users.index(:users_pkey)).to be_unique 161 | 162 | expect(users.index(:users_name_index).columns).to eq([ 163 | DbSchema::Definitions::Index::TableField.new(:name) 164 | ]) 165 | expect(users.index(:users_name_index)).to be_unique 166 | 167 | expect(users.index(:users_sex_index).columns).to eq([ 168 | DbSchema::Definitions::Index::TableField.new(:sex) 169 | ]) 170 | expect(users.index(:users_sex_index)).not_to be_unique 171 | 172 | expect(users.index(:users_mood_index).columns).to eq([ 173 | DbSchema::Definitions::Index::TableField.new(:mood) 174 | ]) 175 | expect(users.index(:users_mood_index)).not_to be_unique 176 | 177 | expect(users.index(:users_email_idx).columns).to eq([ 178 | DbSchema::Definitions::Index::TableField.new(:email) 179 | ]) 180 | expect(users.index(:users_email_idx)).to be_unique 181 | expect(users.index(:users_email_idx)).to be_btree 182 | expect(users.index(:users_email_idx).condition).to eq('email IS NOT NULL') 183 | 184 | expect(users.index(:users_strings_index).type).to eq(:gin) 185 | 186 | expect(users.index(:users_lower_email_index).columns).to eq([ 187 | DbSchema::Definitions::Index::Expression.new('lower(email)') 188 | ]) 189 | 190 | expect(posts.index(:my_pkey).columns).to eq([ 191 | DbSchema::Definitions::Index::TableField.new(:id) 192 | ]) 193 | expect(posts.index(:my_pkey)).to be_primary 194 | expect(posts.index(:my_pkey)).to be_unique 195 | 196 | expect(posts.index(:posts_user_id_index).columns).to eq([ 197 | DbSchema::Definitions::Index::TableField.new(:user_id) 198 | ]) 199 | expect(posts.index(:posts_user_id_index)).not_to be_unique 200 | 201 | expect(posts.index(:posts_col1_col2_col3_col4_index).columns).to eq([ 202 | DbSchema::Definitions::Index::TableField.new(:col1), 203 | DbSchema::Definitions::Index::TableField.new(:col2, order: :desc), 204 | DbSchema::Definitions::Index::TableField.new(:col3, nulls: :first), 205 | DbSchema::Definitions::Index::TableField.new(:col4, order: :desc, nulls: :last) 206 | ]) 207 | 208 | expect(posts.index(:posts_col2_col1_col3_col4_index).columns).to eq([ 209 | DbSchema::Definitions::Index::Expression.new('col2 - col1', order: :desc), 210 | DbSchema::Definitions::Index::Expression.new('col3 + col4', nulls: :first) 211 | ]) 212 | 213 | expect(points.index(:points_pkey).columns).to eq([ 214 | DbSchema::Definitions::Index::TableField.new(:lat), 215 | DbSchema::Definitions::Index::TableField.new(:lng) 216 | ]) 217 | expect(points.index(:points_pkey)).to be_primary 218 | expect(points.index(:points_pkey)).to be_unique 219 | end 220 | 221 | it 'returns check constraints' do 222 | users = schema.table(:users) 223 | expect(users.checks.count).to eq(2) 224 | 225 | expect(users.check(:users_name_check).condition).to eq('char_length(name) > 0') 226 | expect(users.check(:valid_sex).condition).to eq("sex IN ('M', 'F')") 227 | end 228 | 229 | it 'returns foreign keys' do 230 | users = schema.table(:users) 231 | posts = schema.table(:posts) 232 | 233 | expect(users.foreign_keys.count).to eq(1) 234 | expect(users.foreign_key(:users_city_id_fkey).fields).to eq([:city_id]) 235 | expect(users.foreign_key(:users_city_id_fkey).table).to eq(:cities) 236 | expect(users.foreign_key(:users_city_id_fkey).references_primary_key?).to eq(true) 237 | 238 | expect(posts.foreign_keys.count).to eq(2) 239 | expect(posts.foreign_key(:posts_user_id_fkey).fields).to eq([:user_id]) 240 | expect(posts.foreign_key(:posts_user_id_fkey).table).to eq(:users) 241 | expect(posts.foreign_key(:posts_user_id_fkey).references_primary_key?).to eq(true) 242 | expect(posts.foreign_key(:posts_user_id_fkey).on_delete).to eq(:set_null) 243 | expect(posts.foreign_key(:posts_user_id_fkey).on_update).to eq(:no_action) 244 | expect(posts.foreign_key(:posts_user_id_fkey)).to be_deferrable 245 | expect(posts.foreign_key(:user_name_fkey).fields).to eq([:user_name]) 246 | expect(posts.foreign_key(:user_name_fkey).table).to eq(:users) 247 | expect(posts.foreign_key(:user_name_fkey).keys).to eq([:name]) 248 | expect(posts.foreign_key(:user_name_fkey).on_delete).to eq(:no_action) 249 | expect(posts.foreign_key(:user_name_fkey).on_update).to eq(:cascade) 250 | expect(posts.foreign_key(:user_name_fkey)).not_to be_deferrable 251 | end 252 | 253 | it 'returns enum types' do 254 | expect(schema.enums.count).to eq(2) 255 | 256 | expect(schema.enum(:happiness).values).to eq(%i(sad ok good happy)) 257 | expect(schema.enum(:user_status).values).to eq(%i(user moderator admin)) 258 | end 259 | 260 | it 'returns extensions' do 261 | expect(schema.extensions.count).to eq(1) 262 | expect(schema).to have_extension(:hstore) 263 | end 264 | end 265 | 266 | describe '#migrations' do 267 | it 'returns all conditional migrations' do 268 | migrations = subject.migrations 269 | expect(migrations.count).to eq(2) 270 | 271 | rename_people_to_users, join_names = migrations 272 | 273 | expect(rename_people_to_users.name).to eq('Rename people to users') 274 | expect(rename_people_to_users.conditions[:apply].count).to eq(1) 275 | expect(rename_people_to_users.conditions[:skip]).to be_empty 276 | expect(rename_people_to_users.body).to be_a(Proc) 277 | 278 | expect(join_names.name).to eq('Join first_name & last_name into name') 279 | expect(join_names.conditions[:apply].count).to eq(2) 280 | expect(join_names.conditions[:skip].count).to eq(1) 281 | expect(join_names.body).to be_a(Proc) 282 | end 283 | end 284 | end 285 | -------------------------------------------------------------------------------- /spec/db_schema/migrator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe DbSchema::Migrator do 2 | let(:database) do 3 | Sequel.connect(adapter: 'postgres', database: 'db_schema_test').tap do |db| 4 | db.extension :pg_enum 5 | db.extension :pg_array 6 | end 7 | end 8 | 9 | before(:each) do 10 | DbSchema::Runner.new( 11 | [ 12 | DbSchema::Operations::CreateTable.new( 13 | DbSchema::Definitions::Table.new( 14 | :people, 15 | fields: [ 16 | DbSchema::Definitions::Field::Serial.new(:id), 17 | DbSchema::Definitions::Field::Varchar.new(:name, null: false), 18 | DbSchema::Definitions::Field::Varchar.new(:phone), 19 | DbSchema::Definitions::Field::Timestamptz.new(:created_at) 20 | ], 21 | indexes: [ 22 | DbSchema::Definitions::Index.new( 23 | name: :people_pkey, 24 | columns: [DbSchema::Definitions::Index::TableField.new(:id)], 25 | primary: true 26 | ), 27 | DbSchema::Definitions::Index.new( 28 | name: :people_phone_index, 29 | columns: [DbSchema::Definitions::Index::TableField.new(:phone)], 30 | unique: true, 31 | condition: 'phone IS NOT NULL' 32 | ) 33 | ], 34 | checks: [ 35 | DbSchema::Definitions::CheckConstraint.new( 36 | name: :phone_format, 37 | condition: %q(phone ~ '\A\+\d{11}\Z') 38 | ) 39 | ] 40 | ) 41 | ) 42 | ], 43 | database 44 | ).run! 45 | end 46 | 47 | let(:reader) { DbSchema::Reader.reader_for(database) } 48 | let(:schema) { reader.read_schema } 49 | let(:migration) { DbSchema::Migration.new('Migration name') } 50 | 51 | subject { DbSchema::Migrator.new(migration) } 52 | 53 | describe '#applicable?' do 54 | context 'with a schema satisfying all conditions' do 55 | before(:each) do 56 | migration.conditions[:apply] << -> (schema) do 57 | schema.has_table?(:people) 58 | end 59 | 60 | migration.conditions[:skip] << -> (schema) do 61 | schema.has_table?(:users) 62 | end 63 | end 64 | 65 | it 'returns true' do 66 | expect(subject).to be_applicable(schema) 67 | end 68 | end 69 | 70 | context 'with a schema failing some conditions' do 71 | before(:each) do 72 | migration.conditions[:apply] << -> (schema) do 73 | schema.has_table?(:posts) 74 | end 75 | 76 | migration.conditions[:skip] << -> (schema) do 77 | !schema.table(:people).field(:name).null? 78 | end 79 | end 80 | 81 | it 'returns false' do 82 | expect(subject).not_to be_applicable(schema) 83 | end 84 | end 85 | end 86 | 87 | describe '#run!' do 88 | before(:each) do 89 | migration.body = body 90 | end 91 | 92 | context 'with a create_table' do 93 | let(:body) do 94 | -> (migrator, db) do 95 | migrator.create_table :posts do |t| 96 | t.serial :id, primary_key: true 97 | t.varchar :title, null: false 98 | t.text :body 99 | end 100 | end 101 | end 102 | 103 | it 'creates the table' do 104 | subject.run!(database) 105 | 106 | expect(schema).to have_table(:posts) 107 | end 108 | end 109 | 110 | context 'with a drop_table' do 111 | let(:body) do 112 | -> (migrator, db) do 113 | migrator.drop_table :people 114 | end 115 | end 116 | 117 | it 'drops the table' do 118 | subject.run!(database) 119 | 120 | expect(schema).not_to have_table(:people) 121 | end 122 | end 123 | 124 | context 'with a rename_table' do 125 | let(:body) do 126 | -> (migrator, db) do 127 | migrator.rename_table :people, to: :users 128 | end 129 | end 130 | 131 | it 'renames the table' do 132 | subject.run!(database) 133 | 134 | expect(schema).not_to have_table(:people) 135 | expect(schema).to have_table(:users) 136 | end 137 | end 138 | 139 | context 'with an alter_table' do 140 | context 'and an add_column' do 141 | let(:body) do 142 | -> (migrator, db) do 143 | migrator.alter_table :people do |t| 144 | t.add_column :email, :varchar, null: false 145 | end 146 | end 147 | end 148 | 149 | it 'adds the column' do 150 | subject.run!(database) 151 | 152 | expect(schema.table(:people)).to have_field(:email) 153 | email = schema.table(:people).field(:email) 154 | 155 | expect(email.name).to eq(:email) 156 | expect(email).to be_a(DbSchema::Definitions::Field::Varchar) 157 | expect(email).not_to be_null 158 | end 159 | end 160 | 161 | context 'and a drop_column' do 162 | let(:body) do 163 | -> (migrator, db) do 164 | migrator.alter_table :people do |t| 165 | t.drop_column :name 166 | end 167 | end 168 | end 169 | 170 | it 'drops the column' do 171 | subject.run!(database) 172 | 173 | expect(schema.table(:people)).not_to have_field(:name) 174 | end 175 | end 176 | 177 | context 'and a rename_column' do 178 | let(:body) do 179 | -> (migrator, db) do 180 | migrator.alter_table :people do |t| 181 | t.rename_column :name, to: :first_name 182 | end 183 | end 184 | end 185 | 186 | it 'renames the column' do 187 | subject.run!(database) 188 | 189 | expect(schema.table(:people)).not_to have_field(:name) 190 | expect(schema.table(:people)).to have_field(:first_name) 191 | end 192 | end 193 | 194 | context 'and an alter_column_type' do 195 | let(:body) do 196 | -> (migrator, db) do 197 | migrator.alter_table :people do |t| 198 | t.alter_column_type :name, :text 199 | end 200 | end 201 | end 202 | 203 | it 'changes the column type' do 204 | subject.run!(database) 205 | 206 | expect(schema.table(:people).field(:name)).to be_a(DbSchema::Definitions::Field::Text) 207 | end 208 | 209 | context 'changing a column to a serial type' do 210 | let(:body) do 211 | -> (migrator, db) do 212 | migrator.alter_table :people do |t| 213 | t.alter_column_type :name, :serial 214 | end 215 | end 216 | end 217 | 218 | it 'raises an ArgumentError' do 219 | expect { 220 | subject.run!(database) 221 | }.to raise_error(NotImplementedError) 222 | end 223 | end 224 | end 225 | 226 | context 'and an allow_null' do 227 | let(:body) do 228 | -> (migrator, db) do 229 | migrator.alter_table :people do |t| 230 | t.allow_null :name 231 | end 232 | end 233 | end 234 | 235 | it 'removes the NOT NULL constraint from the column' do 236 | subject.run!(database) 237 | 238 | expect(schema.table(:people).field(:name)).to be_null 239 | end 240 | end 241 | 242 | context 'and a disallow_null' do 243 | let(:body) do 244 | -> (migrator, db) do 245 | migrator.alter_table :people do |t| 246 | t.disallow_null :created_at 247 | end 248 | end 249 | end 250 | 251 | it 'adds the NOT NULL constraint to the column' do 252 | subject.run!(database) 253 | 254 | expect(schema.table(:people).field(:created_at)).not_to be_null 255 | end 256 | end 257 | 258 | context 'and an alter_column_default' do 259 | let(:body) do 260 | -> (migrator, db) do 261 | migrator.alter_table :people do |t| 262 | t.alter_column_default :created_at, :'now()' 263 | end 264 | end 265 | end 266 | 267 | it 'changes the default value of the column' do 268 | subject.run!(database) 269 | 270 | expect(schema.table(:people).field(:created_at).default).to eq(:'now()') 271 | end 272 | end 273 | 274 | context 'and an add_primary_key' do 275 | before(:each) do 276 | DbSchema::Runner.new( 277 | [ 278 | DbSchema::Operations::AlterTable.new( 279 | :people, 280 | [DbSchema::Operations::DropIndex.new(:people_pkey, true)] 281 | ) 282 | ], 283 | database 284 | ).run! 285 | end 286 | 287 | let(:body) do 288 | -> (migrator, db) do 289 | migrator.alter_table :people do |t| 290 | t.add_primary_key :id 291 | end 292 | end 293 | end 294 | 295 | it 'adds the primary key' do 296 | subject.run!(database) 297 | 298 | expect(schema.table(:people)).to have_primary_key 299 | end 300 | end 301 | 302 | context 'and a drop_primary_key' do 303 | let(:body) do 304 | -> (migrator, db) do 305 | migrator.alter_table :people do |t| 306 | t.drop_primary_key 307 | end 308 | end 309 | end 310 | 311 | it 'drops the primary key' do 312 | subject.run!(database) 313 | 314 | expect(schema.table(:people)).not_to have_primary_key 315 | end 316 | end 317 | 318 | context 'and an add_index' do 319 | let(:body) do 320 | -> (migrator, db) do 321 | migrator.alter_table :people do |t| 322 | t.add_index :name 323 | end 324 | end 325 | end 326 | 327 | it 'adds the index' do 328 | subject.run!(database) 329 | 330 | expect(schema.table(:people)).to have_index_on(:name) 331 | end 332 | end 333 | 334 | context 'and a drop_index' do 335 | let(:body) do 336 | -> (migrator, db) do 337 | migrator.alter_table :people do |t| 338 | t.drop_index :people_phone_index 339 | end 340 | end 341 | end 342 | 343 | it 'drops the index' do 344 | subject.run!(database) 345 | 346 | expect(schema.table(:people)).not_to have_index_on(:phone) 347 | end 348 | end 349 | 350 | context 'and an add_check' do 351 | let(:body) do 352 | -> (migrator, db) do 353 | migrator.alter_table :people do |t| 354 | t.add_check :name_length, 'character_length(name::text) > 4' 355 | end 356 | end 357 | end 358 | 359 | it 'adds the check constraint' do 360 | subject.run!(database) 361 | 362 | expect(schema.table(:people)).to have_check(:name_length) 363 | expect(schema.table(:people).check(:name_length).condition).to eq('character_length(name::text) > 4') 364 | end 365 | end 366 | 367 | context 'and a drop_check' do 368 | let(:body) do 369 | -> (migrator, db) do 370 | migrator.alter_table :people do |t| 371 | t.drop_check :phone_format 372 | end 373 | end 374 | end 375 | 376 | it 'drops the check constraint' do 377 | subject.run!(database) 378 | 379 | expect(schema.table(:people)).not_to have_check(:phone_format) 380 | end 381 | end 382 | 383 | context 'and an add_foreign_key' do 384 | before(:each) do 385 | DbSchema::Runner.new( 386 | [ 387 | DbSchema::Operations::CreateTable.new( 388 | DbSchema::Definitions::Table.new( 389 | :posts, 390 | fields: [ 391 | DbSchema::Definitions::Field::Integer.new(:id, primary_key: true), 392 | DbSchema::Definitions::Field::Varchar.new(:title, null: false), 393 | DbSchema::Definitions::Field::Integer.new(:person_id, null: false) 394 | ] 395 | ) 396 | ) 397 | ], 398 | database 399 | ).run! 400 | end 401 | 402 | let(:body) do 403 | -> (migrator, db) do 404 | migrator.alter_table :posts do |t| 405 | t.add_foreign_key :person_id, references: :people 406 | end 407 | end 408 | end 409 | 410 | it 'adds the foreign key' do 411 | subject.run!(database) 412 | 413 | expect(schema.table(:posts)).to have_foreign_key_to(:people) 414 | end 415 | end 416 | 417 | context 'and a drop_foreign_key' do 418 | before(:each) do 419 | DbSchema::Runner.new( 420 | [ 421 | DbSchema::Operations::CreateTable.new( 422 | DbSchema::Definitions::Table.new( 423 | :posts, 424 | fields: [ 425 | DbSchema::Definitions::Field::Integer.new(:id, primary_key: true), 426 | DbSchema::Definitions::Field::Varchar.new(:title, null: false), 427 | DbSchema::Definitions::Field::Integer.new(:person_id, null: false) 428 | ] 429 | ) 430 | ), 431 | DbSchema::Operations::CreateForeignKey.new( 432 | :posts, 433 | DbSchema::Definitions::ForeignKey.new( 434 | name: :posts_person_id_fkey, 435 | fields: [:person_id], 436 | table: :people 437 | ) 438 | ) 439 | ], 440 | database 441 | ).run! 442 | end 443 | 444 | let(:body) do 445 | -> (migrator, db) do 446 | migrator.alter_table :posts do |t| 447 | t.drop_foreign_key :posts_person_id_fkey 448 | end 449 | end 450 | end 451 | 452 | it 'drops the foreign key' do 453 | subject.run!(database) 454 | 455 | expect(schema.table(:posts)).not_to have_foreign_key_to(:people) 456 | end 457 | end 458 | end 459 | 460 | context 'with a create_enum' do 461 | let(:body) do 462 | -> (migrator, db) do 463 | migrator.create_enum :user_role, %i(guest user admin) 464 | end 465 | end 466 | 467 | it 'adds the enum' do 468 | subject.run!(database) 469 | 470 | expect(schema).to have_enum(:user_role) 471 | expect(schema.enum(:user_role).values).to eq(%i(guest user admin)) 472 | end 473 | end 474 | 475 | context 'with a drop_enum' do 476 | before(:each) do 477 | DbSchema::Runner.new( 478 | [ 479 | DbSchema::Operations::CreateEnum.new( 480 | DbSchema::Definitions::Enum.new(:user_role, %i(guest user admin)) 481 | ) 482 | ], 483 | database 484 | ).run! 485 | end 486 | 487 | let(:body) do 488 | -> (migrator, db) do 489 | migrator.drop_enum :user_role 490 | end 491 | end 492 | 493 | it 'drops the enum' do 494 | subject.run!(database) 495 | 496 | expect(schema).not_to have_enum(:user_role) 497 | end 498 | end 499 | 500 | context 'with a rename_enum' do 501 | before(:each) do 502 | DbSchema::Runner.new( 503 | [ 504 | DbSchema::Operations::CreateEnum.new( 505 | DbSchema::Definitions::Enum.new(:role, %i(guest user admin)) 506 | ) 507 | ], 508 | database 509 | ).run! 510 | end 511 | 512 | let(:body) do 513 | -> (migrator, db) do 514 | migrator.rename_enum :role, to: :user_role 515 | end 516 | end 517 | 518 | it 'renames the enum' do 519 | subject.run!(database) 520 | 521 | expect(schema).not_to have_enum(:role) 522 | expect(schema).to have_enum(:user_role) 523 | end 524 | end 525 | 526 | context 'with a create_extension' do 527 | let(:body) do 528 | -> (migrator, db) do 529 | migrator.create_extension :hstore 530 | end 531 | end 532 | 533 | it 'enables the extension' do 534 | subject.run!(database) 535 | 536 | expect(schema).to have_extension(:hstore) 537 | end 538 | end 539 | 540 | context 'with a drop_extension' do 541 | before(:each) do 542 | DbSchema::Runner.new( 543 | [ 544 | DbSchema::Operations::CreateExtension.new( 545 | DbSchema::Definitions::Extension.new(:hstore) 546 | ) 547 | ], 548 | database 549 | ).run! 550 | end 551 | 552 | let(:body) do 553 | -> (migrator, db) do 554 | migrator.drop_extension :hstore 555 | end 556 | end 557 | 558 | it 'drops the extension' do 559 | subject.run!(database) 560 | 561 | expect(schema).not_to have_extension(:hstore) 562 | end 563 | end 564 | 565 | context 'with an execute' do 566 | before(:each) do 567 | database[:people].insert(name: 'John Smith') 568 | end 569 | 570 | let(:body) do 571 | -> (migrator, db) do 572 | migrator.execute "UPDATE people SET phone = '+79012345678'" 573 | end 574 | end 575 | 576 | it 'executes the query' do 577 | subject.run!(database) 578 | 579 | person = database['SELECT * FROM people'].first 580 | expect(person[:phone]).to eq('+79012345678') 581 | end 582 | end 583 | 584 | context 'with an arbitrary code block' do 585 | let(:body) do 586 | -> (migrator, db) do 587 | db[:people].insert(name: 'John Smith') 588 | end 589 | end 590 | 591 | it 'executes the code' do 592 | subject.run!(database) 593 | 594 | people = database['SELECT * FROM people'] 595 | expect(people.count).to eq(1) 596 | end 597 | end 598 | 599 | context 'without a body' do 600 | let(:body) { nil } 601 | 602 | it "doesn't change the schema" do 603 | expect { 604 | subject.run!(database) 605 | }.not_to change { reader.read_schema } 606 | end 607 | end 608 | end 609 | 610 | after(:each) do 611 | clean! 612 | end 613 | end 614 | -------------------------------------------------------------------------------- /spec/db_schema/normalizer_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe DbSchema::Normalizer do 2 | let(:database) do 3 | Sequel.connect(adapter: 'postgres', database: 'db_schema_test').tap do |db| 4 | db.extension :pg_enum 5 | db.extension :pg_array 6 | end 7 | end 8 | 9 | let(:enums) do 10 | [ 11 | DbSchema::Definitions::Enum.new(:happiness, %i(good ok bad)), 12 | DbSchema::Definitions::Enum.new(:user_status, %i(guest registered)), 13 | DbSchema::Definitions::Enum.new(:user_role, %i(user)) 14 | ] 15 | end 16 | 17 | let(:extensions) do 18 | [ 19 | DbSchema::Definitions::Extension.new(:ltree), 20 | DbSchema::Definitions::Extension.new(:hstore) 21 | ] 22 | end 23 | 24 | let(:raw_table) do 25 | DbSchema::Definitions::Table.new( 26 | :users, 27 | fields: [ 28 | DbSchema::Definitions::Field::Serial.new(:id), 29 | DbSchema::Definitions::Field::Varchar.new(:name, null: false), 30 | DbSchema::Definitions::Field::Integer.new(:group_id), 31 | DbSchema::Definitions::Field::Integer.new(:age, default: :'18 + 5'), 32 | DbSchema::Definitions::Field::Hstore.new(:data), 33 | DbSchema::Definitions::Field::Custom.class_for(:happiness).new(:happiness, default: field_default), 34 | DbSchema::Definitions::Field::Array.new(:roles, element_type: :user_role, default: '{user}'), 35 | DbSchema::Definitions::Field::Ltree.new(:path), 36 | DbSchema::Definitions::Field::Custom.class_for(:user_status).new(:user_status) 37 | ], 38 | indexes: [ 39 | DbSchema::Definitions::Index.new( 40 | name: :users_pkey, 41 | columns: [ 42 | DbSchema::Definitions::Index::TableField.new(:id) 43 | ], 44 | primary: true 45 | ), 46 | DbSchema::Definitions::Index.new( 47 | name: :lower_name_index, 48 | columns: [ 49 | DbSchema::Definitions::Index::Expression.new('lower(name)') 50 | ], 51 | condition: index_condition 52 | ) 53 | ], 54 | checks: [ 55 | DbSchema::Definitions::CheckConstraint.new(name: :name_length, condition: check_condition) 56 | ], 57 | foreign_keys: [ 58 | DbSchema::Definitions::ForeignKey.new(name: :users_group_id_fkey, fields: [:group_id], table: :groups) 59 | ] 60 | ) 61 | end 62 | 63 | describe '.normalize_tables' do 64 | let(:field_default) { 'ok' } 65 | let(:index_condition) { 'age != 18' } 66 | let(:check_condition) { 'char_length(name) > 4' } 67 | 68 | let(:schema) do 69 | DbSchema::Definitions::Schema.new( 70 | tables: [raw_table], 71 | enums: enums, 72 | extensions: extensions 73 | ) 74 | end 75 | 76 | before(:each) do 77 | add_hstore = DbSchema::Operations::CreateExtension.new( 78 | DbSchema::Definitions::Extension.new(:hstore) 79 | ) 80 | add_happiness = DbSchema::Operations::CreateEnum.new( 81 | DbSchema::Definitions::Enum.new(:happiness, %i(good bad)) 82 | ) 83 | add_role = DbSchema::Operations::CreateEnum.new( 84 | DbSchema::Definitions::Enum.new(:user_role, %i(admin)) 85 | ) 86 | 87 | fields = raw_table.fields.take(5) 88 | fields << DbSchema::Definitions::Field::Custom.class_for(:happiness).new(:happiness) 89 | fields << DbSchema::Definitions::Field::Array.new(:roles, element_type: :user_role, default: '{admin}') 90 | 91 | create_table = DbSchema::Operations::CreateTable.new( 92 | DbSchema::Definitions::Table.new( 93 | :users, 94 | fields: fields, 95 | indexes: raw_table.indexes, 96 | checks: raw_table.checks 97 | ) 98 | ) 99 | 100 | DbSchema::Runner.new([add_hstore, add_happiness, add_role, create_table], database).run! 101 | end 102 | 103 | it 'normalizes all tables in the schema passed in' do 104 | DbSchema::Normalizer.new(schema, database).normalize_tables 105 | 106 | expect(schema.tables.count).to eq(1) 107 | users = schema.table(:users) 108 | 109 | expect(users.field(:id).type).to eq(:serial) 110 | expect(users.field(:age).default).to eq(:'(18 + 5)') 111 | expect(users.field(:happiness).type).to eq(:happiness) 112 | expect(users.field(:roles)).to be_array 113 | expect(users.field(:roles).attributes[:element_type]).to eq(:user_role) 114 | expect(users.field(:roles).default).to eq('{user}') 115 | expect(users.primary_key.name).to eq(:users_pkey) 116 | expect(users.primary_key.columns).to eq([DbSchema::Definitions::Index::TableField.new(:id)]) 117 | expect(users.index(:lower_name_index).columns.first.name).to eq('lower(name::text)') 118 | expect(users.index(:lower_name_index).condition).to eq('age <> 18') 119 | expect(users.check(:name_length).condition).to eq('char_length(name::text) > 4') 120 | expect(users.foreign_key(:users_group_id_fkey).fields).to eq([:group_id]) 121 | expect(users.foreign_key(:users_group_id_fkey).table).to eq(:groups) 122 | end 123 | 124 | it 'rolls back all temporary tables' do 125 | expect { 126 | DbSchema::Normalizer.new(schema, database).normalize_tables 127 | }.not_to change { DbSchema::Reader.reader_for(database).read_tables.count } 128 | end 129 | 130 | context 'with enums used inside expressions' do 131 | let(:field_default) { :"('ok'::text)::happiness" } 132 | let(:index_condition) { "happiness = 'good'::happiness" } 133 | let(:check_condition) { "char_length(name::text) > 4 OR happiness = 'good'::happiness" } 134 | 135 | it 'keeps the original type names inside expressions' do 136 | DbSchema::Normalizer.new(schema, database).normalize_tables 137 | 138 | users = schema.table(:users) 139 | expect(users.field(:happiness).default).to eq(field_default) 140 | expect(users.index(:lower_name_index).condition).to eq(index_condition) 141 | expect(users.check(:name_length).condition).to eq(check_condition) 142 | end 143 | end 144 | 145 | after(:each) do 146 | drop_table = DbSchema::Operations::DropTable.new(:users) 147 | drop_happiness = DbSchema::Operations::DropEnum.new(:happiness) 148 | drop_role = DbSchema::Operations::DropEnum.new(:user_role) 149 | drop_hstore = DbSchema::Operations::DropExtension.new(:hstore) 150 | 151 | DbSchema::Runner.new([drop_table, drop_happiness, drop_role, drop_hstore], database).run! 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /spec/db_schema/reader_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe DbSchema::Reader do 2 | let(:database) do 3 | Sequel.connect(adapter: 'postgres', database: 'db_schema_test').tap do |db| 4 | db.extension :pg_enum 5 | db.extension :pg_array 6 | end 7 | end 8 | 9 | describe '.reader_for' do 10 | it 'returns a reader for a given connection' do 11 | reader = subject.reader_for(database) 12 | expect(reader.read_schema).to eq(DbSchema::Definitions::Schema.new) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/db_schema/runner_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe DbSchema::Runner do 2 | let(:database) do 3 | Sequel.connect(adapter: 'postgres', database: 'db_schema_test').tap do |db| 4 | db.extension :pg_enum 5 | db.extension :pg_array 6 | end 7 | end 8 | 9 | describe '#run!' do 10 | before(:each) do 11 | database.create_table :people do 12 | primary_key :id 13 | column :name, :Varchar 14 | column :address, :Varchar, null: false, size: 150 15 | column :country_name, :Varchar 16 | column :created_at, :Timestamptz 17 | 18 | index :address 19 | 20 | constraint :address_length, Sequel.function(:char_length, :address) => 3..50 21 | end 22 | 23 | database.create_table :countries do 24 | primary_key :id 25 | column :name, :varchar, null: false 26 | 27 | index :name, unique: true 28 | end 29 | end 30 | 31 | let(:users_fields) do 32 | [ 33 | DbSchema::Definitions::Field::Serial.new(:id), 34 | DbSchema::Definitions::Field::Varchar.new(:name, null: false, length: 50), 35 | DbSchema::Definitions::Field::Varchar.new(:email, default: 'mail@example.com'), 36 | DbSchema::Definitions::Field::Integer.new(:country_id, null: false), 37 | DbSchema::Definitions::Field::Timestamp.new(:created_at, null: false, default: :'now()'), 38 | DbSchema::Definitions::Field::Interval.new(:period, fields: :second), 39 | DbSchema::Definitions::Field::Bit.new(:some_bit), 40 | DbSchema::Definitions::Field::Bit.new(:some_bits, length: 7), 41 | DbSchema::Definitions::Field::Varbit.new(:some_varbit, length: 250), 42 | DbSchema::Definitions::Field::Array.new(:names, element_type: :varchar) 43 | ] 44 | end 45 | 46 | let(:users_indexes) do 47 | [ 48 | DbSchema::Definitions::Index.new( 49 | name: :users_pk, 50 | columns: [DbSchema::Definitions::Index::TableField.new(:id)], 51 | primary: true 52 | ), 53 | DbSchema::Definitions::Index.new( 54 | name: :index_users_on_name, 55 | columns: [DbSchema::Definitions::Index::Expression.new('lower(name)')] 56 | ), 57 | DbSchema::Definitions::Index.new( 58 | name: :index_users_on_email, 59 | columns: [DbSchema::Definitions::Index::TableField.new(:email, order: :desc, nulls: :last)], 60 | unique: true, 61 | condition: 'email IS NOT NULL' 62 | ), 63 | DbSchema::Definitions::Index.new( 64 | name: :users_names_index, 65 | columns: [DbSchema::Definitions::Index::TableField.new(:names)], 66 | type: :gin 67 | ) 68 | ] 69 | end 70 | 71 | let(:users_checks) { 72 | [ 73 | DbSchema::Definitions::CheckConstraint.new( 74 | name: :min_name_length, 75 | condition: 'character_length(name::text) > 4' 76 | ) 77 | ] 78 | } 79 | 80 | let(:users_foreign_keys) do 81 | [ 82 | DbSchema::Definitions::ForeignKey.new( 83 | name: :users_country_id_fkey, 84 | fields: [:country_id], 85 | table: :countries, 86 | on_delete: :set_null 87 | ) 88 | ] 89 | end 90 | 91 | let(:schema) { DbSchema::Reader.reader_for(database).read_schema } 92 | 93 | subject { DbSchema::Runner.new(changes, database) } 94 | 95 | context 'with CreateTable & DropTable' do 96 | let(:changes) do 97 | [ 98 | DbSchema::Operations::CreateTable.new( 99 | DbSchema::Definitions::Table.new( 100 | :users, 101 | fields: users_fields, 102 | indexes: users_indexes, 103 | checks: users_checks 104 | ) 105 | ), 106 | DbSchema::Operations::DropTable.new(:people) 107 | ] 108 | end 109 | 110 | context 'with a serial primary key' do 111 | it 'applies all the changes' do 112 | subject.run! 113 | 114 | expect(schema).not_to have_table(:people) 115 | expect(schema).to have_table(:users) 116 | 117 | users = schema.table(:users) 118 | 119 | expect(users.field(:id).type).to eq(:serial) 120 | expect(users.field(:name).type).to eq(:varchar) 121 | expect(users.field(:name).options[:length]).to eq(50) 122 | expect(users.field(:name)).not_to be_null 123 | expect(users.field(:email).type).to eq(:varchar) 124 | expect(users.field(:email).default).to eq('mail@example.com') 125 | expect(users.field(:created_at).type).to eq(:timestamp) 126 | expect(users.field(:created_at)).not_to be_null 127 | expect(users.field(:created_at).default).to eq(:'now()') 128 | expect(users.field(:period).type).to eq(:interval) 129 | expect(users.field(:period).options[:fields]).to eq(:second) 130 | expect(users.field(:some_bit).type).to eq(:bit) 131 | expect(users.field(:some_bit).options[:length]).to eq(1) 132 | expect(users.field(:some_bits).type).to eq(:bit) 133 | expect(users.field(:some_bits).options[:length]).to eq(7) 134 | expect(users.field(:some_varbit).type).to eq(:varbit) 135 | expect(users.field(:some_varbit).options[:length]).to eq(250) 136 | expect(users.field(:names)).to be_array 137 | expect(users.field(:names).options[:element_type]).to eq(:varchar) 138 | 139 | expect(users.primary_key.name).to eq(:users_pk) 140 | expect(users.primary_key.columns).to eq([DbSchema::Definitions::Index::TableField.new(:id)]) 141 | expect(users.index(:index_users_on_name).columns).to eq([DbSchema::Definitions::Index::Expression.new('lower(name::text)')]) 142 | expect(users.index(:index_users_on_name)).not_to be_unique 143 | expect(users.index(:index_users_on_name).type).to eq(:btree) 144 | expect(users.index(:index_users_on_email).columns).to eq([DbSchema::Definitions::Index::TableField.new(:email, order: :desc, nulls: :last)]) 145 | expect(users.index(:index_users_on_email)).to be_unique 146 | expect(users.index(:index_users_on_email).type).to eq(:btree) 147 | expect(users.index(:index_users_on_email).condition).to eq('email IS NOT NULL') 148 | expect(users.index(:users_names_index).columns).to eq([DbSchema::Definitions::Index::TableField.new(:names)]) 149 | expect(users.index(:users_names_index).type).to eq(:gin) 150 | 151 | expect(users.checks.count).to eq(1) 152 | expect(users.check(:min_name_length).condition).to eq('character_length(name::text) > 4') 153 | end 154 | end 155 | 156 | context 'with a non-serial primary key' do 157 | before(:each) do 158 | users_fields.shift 159 | users_fields.unshift(DbSchema::Definitions::Field::UUID.new(:id, primary_key: true)) 160 | end 161 | 162 | it 'creates a table with a correct primary key type' do 163 | subject.run! 164 | 165 | expect(schema.table(:users).primary_key.columns.map(&:name)).to eq([:id]) 166 | 167 | id = schema.table(:users).field(:id) 168 | expect(id.type).to eq(:uuid) 169 | end 170 | 171 | context 'that has type attributes' do 172 | before(:each) do 173 | users_fields.shift 174 | users_fields.unshift( 175 | DbSchema::Definitions::Field::Varchar.new(:id, length: 255, primary_key: true) 176 | ) 177 | end 178 | 179 | it 'creates a table with primary key having correct type and attributes' do 180 | subject.run! 181 | 182 | expect(schema.table(:users).primary_key.columns.map(&:name)).to eq([:id]) 183 | 184 | id = schema.table(:users).field(:id) 185 | expect(id.type).to eq(:varchar) 186 | expect(id.options[:length]).to eq(255) 187 | end 188 | end 189 | end 190 | end 191 | 192 | context 'with RenameTable' do 193 | let(:changes) do 194 | [DbSchema::Operations::RenameTable.new(old_name: :people, new_name: :users)] 195 | end 196 | 197 | it 'applies all the changes' do 198 | subject.run! 199 | 200 | expect(schema).not_to have_table(:people) 201 | expect(schema).to have_table(:users) 202 | end 203 | end 204 | 205 | context 'with AlterTable' do 206 | let(:changes) do 207 | [DbSchema::Operations::AlterTable.new(:people, table_changes)] 208 | end 209 | 210 | context 'containing CreateColumn & DropColumn' do 211 | let(:table_changes) do 212 | [ 213 | DbSchema::Operations::CreateColumn.new(DbSchema::Definitions::Field::Varchar.new(:first_name)), 214 | DbSchema::Operations::CreateColumn.new( 215 | DbSchema::Definitions::Field::Varchar.new(:last_name, length: 30, null: false) 216 | ), 217 | DbSchema::Operations::CreateColumn.new(DbSchema::Definitions::Field::Integer.new(:age, null: false)), 218 | DbSchema::Operations::DropColumn.new(:name), 219 | DbSchema::Operations::DropColumn.new(:id), 220 | DbSchema::Operations::CreateColumn.new( 221 | DbSchema::Definitions::Field::Serial.new(:new_id) 222 | ), 223 | DbSchema::Operations::CreateColumn.new( 224 | DbSchema::Definitions::Field::Timestamp.new(:updated_at, null: false, default: :'now()') 225 | ), 226 | DbSchema::Operations::CreateIndex.new( 227 | DbSchema::Definitions::Index.new( 228 | name: :people_pkey, 229 | columns: [DbSchema::Definitions::Index::TableField.new(:new_id)], 230 | primary: true 231 | ) 232 | ) 233 | ] 234 | end 235 | 236 | it 'applies all the changes' do 237 | subject.run! 238 | 239 | people = schema.table(:people) 240 | expect(people.primary_key.columns.map(&:name)).to eq([:new_id]) 241 | expect(people).to have_field(:address) 242 | expect(people.field(:created_at).type).to eq(:timestamptz) 243 | expect(people.field(:first_name).type).to eq(:varchar) 244 | expect(people.field(:last_name).type).to eq(:varchar) 245 | expect(people.field(:last_name).options[:length]).to eq(30) 246 | expect(people.field(:last_name)).not_to be_null 247 | expect(people.field(:age).type).to eq(:integer) 248 | expect(people.field(:age)).not_to be_null 249 | expect(people.field(:new_id).type).to eq(:serial) 250 | expect(people.field(:updated_at).type).to eq(:timestamp) 251 | expect(people.field(:updated_at).default).to eq(:'now()') 252 | end 253 | 254 | context 'with a new non-serial primary key with attributes' do 255 | let(:table_changes) do 256 | [ 257 | DbSchema::Operations::DropColumn.new(:id), 258 | DbSchema::Operations::DropColumn.new(:name), 259 | DbSchema::Operations::CreateColumn.new( 260 | DbSchema::Definitions::Field::Varchar.new(:name, length: 255) 261 | ), 262 | DbSchema::Operations::CreateIndex.new( 263 | DbSchema::Definitions::Index.new( 264 | name: :people_pkey, 265 | columns: [DbSchema::Definitions::Index::TableField.new(:name)], 266 | primary: true 267 | ) 268 | ) 269 | ] 270 | end 271 | 272 | it 'creates a primary key with provided type and attributes' do 273 | subject.run! 274 | 275 | expect(schema.table(:people).primary_key.columns.map(&:name)).to eq([:name]) 276 | name = schema.table(:people).field(:name) 277 | expect(name.type).to eq(:varchar) 278 | expect(name.options[:length]).to eq(255) 279 | end 280 | end 281 | end 282 | 283 | context 'containing RenameColumn' do 284 | let(:table_changes) do 285 | [ 286 | DbSchema::Operations::RenameColumn.new(old_name: :name, new_name: :full_name) 287 | ] 288 | end 289 | 290 | it 'applies all the changes' do 291 | subject.run! 292 | 293 | expect(schema.table(:people)).to have_field(:full_name) 294 | expect(schema.table(:people).field(:full_name).type).to eq(:varchar) 295 | end 296 | end 297 | 298 | context 'containing AlterColumnType' do 299 | let(:table_changes) do 300 | [ 301 | DbSchema::Operations::AlterColumnType.new(:name, old_type: :varchar, new_type: :text) 302 | ] 303 | end 304 | 305 | it 'applies all the changes' do 306 | subject.run! 307 | 308 | expect(schema.table(:people).field(:name).type).to eq(:text) 309 | end 310 | 311 | context 'that changes field attributes' do 312 | let(:table_changes) do 313 | [ 314 | DbSchema::Operations::AlterColumnType.new(:address, old_type: :varchar, new_type: :varchar), 315 | DbSchema::Operations::AlterColumnType.new(:country_name, old_type: :varchar, new_type: :varchar, length: 40), 316 | DbSchema::Operations::AlterColumnType.new(:created_at, old_type: :timestamptz, new_type: :timestamp) 317 | ] 318 | end 319 | 320 | it 'applies all the changes' do 321 | subject.run! 322 | 323 | people = schema.table(:people) 324 | 325 | expect(people.field(:address).type).to eq(:varchar) 326 | expect(people.field(:address).options[:length]).to be_nil 327 | expect(people.field(:country_name).type).to eq(:varchar) 328 | expect(people.field(:country_name).options[:length]).to eq(40) 329 | expect(people.field(:created_at).type).to eq(:timestamp) 330 | end 331 | end 332 | 333 | context 'with a :using option' do 334 | let(:table_changes) do 335 | [ 336 | DbSchema::Operations::AlterColumnType.new(:name, old_type: :varchar, new_type: :integer, using: 'name::integer') 337 | ] 338 | end 339 | 340 | it 'applies all the changes' do 341 | subject.run! 342 | 343 | expect(schema.table(:people).field(:name).type).to eq(:integer) 344 | end 345 | end 346 | 347 | context 'changing a serial field to a non-serial' do 348 | let(:table_changes) do 349 | [ 350 | DbSchema::Operations::AlterColumnType.new(:id, old_type: :serial, new_type: :integer) 351 | ] 352 | end 353 | 354 | it 'raises a NotImplementedError' do 355 | expect { 356 | subject.run! 357 | }.to raise_error(NotImplementedError) 358 | end 359 | end 360 | 361 | context 'changing a non-serial field to a serial' do 362 | let(:table_changes) do 363 | [ 364 | DbSchema::Operations::AlterColumnType.new(:name, old_type: :varchar, new_type: :serial) 365 | ] 366 | end 367 | 368 | it 'raises a NotImplementedError' do 369 | expect { 370 | subject.run! 371 | }.to raise_error(NotImplementedError) 372 | end 373 | end 374 | end 375 | 376 | context 'containing AllowNull & DisallowNull' do 377 | let(:table_changes) do 378 | [ 379 | DbSchema::Operations::AllowNull.new(:address), 380 | DbSchema::Operations::DisallowNull.new(:name) 381 | ] 382 | end 383 | 384 | it 'applies all the changes' do 385 | subject.run! 386 | 387 | expect(schema.table(:people).field(:name)).not_to be_null 388 | expect(schema.table(:people).field(:address)).to be_null 389 | end 390 | end 391 | 392 | context 'containing AlterColumnDefault' do 393 | let(:table_changes) do 394 | [ 395 | DbSchema::Operations::AlterColumnDefault.new(:name, new_default: 'John Smith') 396 | ] 397 | end 398 | 399 | it 'applies all the changes' do 400 | subject.run! 401 | 402 | expect(schema.table(:people).field(:name).default).to eq('John Smith') 403 | end 404 | 405 | context 'with an expression' do 406 | let(:table_changes) do 407 | [ 408 | DbSchema::Operations::AlterColumnDefault.new(:created_at, new_default: :'now()') 409 | ] 410 | end 411 | 412 | it 'applies all the changes' do 413 | subject.run! 414 | 415 | expect(schema.table(:people).field(:created_at).default).to eq(:'now()') 416 | end 417 | end 418 | end 419 | 420 | context 'containing CreateIndex & DropIndex' do 421 | let(:table_changes) do 422 | [ 423 | DbSchema::Operations::CreateColumn.new( 424 | DbSchema::Definitions::Field::Array.new(:interests, element_type: :varchar) 425 | ), 426 | DbSchema::Operations::CreateIndex.new( 427 | DbSchema::Definitions::Index.new( 428 | name: :people_name_index, 429 | columns: [DbSchema::Definitions::Index::Expression.new('lower(name)', order: :desc)], 430 | condition: 'name IS NOT NULL' 431 | ) 432 | ), 433 | DbSchema::Operations::DropIndex.new(:people_address_index, false), 434 | DbSchema::Operations::CreateIndex.new( 435 | DbSchema::Definitions::Index.new( 436 | name: :people_interests_index, 437 | columns: [DbSchema::Definitions::Index::TableField.new(:interests)], 438 | type: :gin 439 | ) 440 | ), 441 | DbSchema::Operations::DropIndex.new(:people_pkey, true), 442 | DbSchema::Operations::CreateIndex.new( 443 | DbSchema::Definitions::Index.new( 444 | name: :people_pk, 445 | columns: [DbSchema::Definitions::Index::TableField.new(:name)], 446 | primary: true 447 | ) 448 | ) 449 | ] 450 | end 451 | 452 | it 'applies all the changes' do 453 | subject.run! 454 | 455 | expect(schema.table(:people)).not_to have_index(:people_address_index) 456 | expect(schema.table(:people)).to have_index(:people_name_index) 457 | expect(schema.table(:people)).to have_index(:people_interests_index) 458 | 459 | name_index = schema.table(:people).index(:people_name_index) 460 | expect(name_index.columns).to eq([ 461 | DbSchema::Definitions::Index::Expression.new('lower(name::text)', order: :desc) 462 | ]) 463 | expect(name_index).not_to be_unique 464 | expect(name_index.type).to eq(:btree) 465 | expect(name_index.condition).to eq('name IS NOT NULL') 466 | 467 | interests_index = schema.table(:people).index(:people_interests_index) 468 | # non-BTree indexes don't support index ordering 469 | expect(interests_index.columns).to eq([ 470 | DbSchema::Definitions::Index::TableField.new(:interests) 471 | ]) 472 | expect(interests_index.type).to eq(:gin) 473 | 474 | people_pkey = schema.table(:people).primary_key 475 | expect(people_pkey.name).to eq(:people_pk) 476 | expect(people_pkey.columns.map(&:name)).to eq([:name]) 477 | end 478 | end 479 | 480 | context 'containing CreateCheckConstraint & DropCheckConstraint' do 481 | let(:table_changes) do 482 | [ 483 | DbSchema::Operations::DropCheckConstraint.new(:address_length), 484 | DbSchema::Operations::CreateCheckConstraint.new( 485 | DbSchema::Definitions::CheckConstraint.new( 486 | name: :min_address_length, 487 | condition: 'character_length(address) >= 10' 488 | ) 489 | ) 490 | ] 491 | end 492 | 493 | it 'applies all the changes' do 494 | subject.run! 495 | 496 | expect(schema.table(:people)).not_to have_check(:address_length) 497 | expect(schema.table(:people)).to have_check(:min_address_length) 498 | expect(schema.table(:people).check(:min_address_length).condition).to eq('character_length(address::text) >= 10') 499 | end 500 | end 501 | end 502 | 503 | context 'with CreateForeignKey & DropForeignKey' do 504 | before(:each) do 505 | database.create_table :cities do 506 | primary_key :id 507 | column :name, :varchar, null: false 508 | 509 | index :name, unique: true 510 | end 511 | 512 | database.alter_table :people do 513 | add_column :city_name, :varchar 514 | add_column :city_id, :integer 515 | 516 | add_foreign_key [:city_name], :cities, key: :name 517 | end 518 | end 519 | 520 | let(:changes) do 521 | [ 522 | DbSchema::Operations::DropForeignKey.new(:people, :people_city_name_fkey), 523 | DbSchema::Operations::CreateForeignKey.new( 524 | :people, 525 | DbSchema::Definitions::ForeignKey.new( 526 | name: :people_city_id_fkey, 527 | fields: [:city_id], 528 | table: :cities, 529 | on_delete: :set_null 530 | ) 531 | ), 532 | DbSchema::Operations::CreateForeignKey.new( 533 | :people, 534 | DbSchema::Definitions::ForeignKey.new( 535 | name: :people_country_name_fkey, 536 | fields: [:country_name], 537 | table: :countries, 538 | keys: [:name], 539 | on_update: :cascade 540 | ) 541 | ) 542 | ] 543 | end 544 | 545 | it 'applies all the changes' do 546 | subject.run! 547 | 548 | people = schema.table(:people) 549 | expect(people).not_to have_foreign_key(:people_city_name_fkey) 550 | expect(people).to have_foreign_key(:people_city_id_fkey) 551 | expect(people).to have_foreign_key(:people_country_name_fkey) 552 | 553 | expect(people.foreign_key(:people_city_id_fkey).fields).to eq([:city_id]) 554 | expect(people.foreign_key(:people_city_id_fkey).table).to eq(:cities) 555 | expect(people.foreign_key(:people_city_id_fkey).on_delete).to eq(:set_null) 556 | expect(people.foreign_key(:people_city_id_fkey).on_update).to eq(:no_action) 557 | 558 | expect(people.foreign_key(:people_country_name_fkey).fields).to eq([:country_name]) 559 | expect(people.foreign_key(:people_country_name_fkey).table).to eq(:countries) 560 | expect(people.foreign_key(:people_country_name_fkey).keys).to eq([:name]) 561 | expect(people.foreign_key(:people_country_name_fkey).on_delete).to eq(:no_action) 562 | expect(people.foreign_key(:people_country_name_fkey).on_update).to eq(:cascade) 563 | end 564 | end 565 | 566 | context 'with CreateEnum & DropEnum' do 567 | before(:each) do 568 | database.create_enum :status, %i(registered confirmed_email subscriber) 569 | end 570 | 571 | let(:changes) do 572 | [ 573 | DbSchema::Operations::CreateEnum.new( 574 | DbSchema::Definitions::Enum.new(:happiness, %i(happy ok sad)) 575 | ), 576 | DbSchema::Operations::DropEnum.new(:status) 577 | ] 578 | end 579 | 580 | it 'applies all the changes' do 581 | subject.run! 582 | 583 | expect(schema).to have_enum(:happiness) 584 | expect(schema.enum(:happiness).values).to eq(%i(happy ok sad)) 585 | expect(schema).not_to have_enum(:status) 586 | end 587 | end 588 | 589 | context 'with RenameEnum' do 590 | before(:each) do 591 | database.create_enum :status, %i(registered confirmed_email subscriber) 592 | end 593 | 594 | let(:changes) do 595 | [ 596 | DbSchema::Operations::RenameEnum.new(old_name: :status, new_name: :'user status') 597 | ] 598 | end 599 | 600 | it 'applies all the changes' do 601 | subject.run! 602 | 603 | expect(schema).not_to have_enum(:status) 604 | expect(schema).to have_enum(:'user status') 605 | end 606 | end 607 | 608 | context 'with AlterEnumValues' do 609 | before(:each) do 610 | database.create_enum :happiness, %i(good ok bad) 611 | end 612 | 613 | let(:changes) do 614 | [ 615 | DbSchema::Operations::AlterEnumValues.new( 616 | :happiness, 617 | %i(happy ok sad), 618 | fields 619 | ) 620 | ] 621 | end 622 | 623 | let(:fields) { [] } 624 | 625 | it 'replaces the enum with a new one' do 626 | subject.run! 627 | 628 | expect(schema).to have_enum(:happiness) 629 | expect(schema.enum(:happiness).values).to eq(%i(happy ok sad)) 630 | end 631 | 632 | context 'with existing fields of this enum type' do 633 | before(:each) do 634 | database.create_table :users do 635 | primary_key :id 636 | column :happiness, :happiness, default: 'ok' 637 | end 638 | end 639 | 640 | let(:fields) do 641 | [ 642 | { table_name: :users, field_name: :happiness, new_default: 'ok', array: false } 643 | ] 644 | end 645 | 646 | it 'converts existing fields to the new type' do 647 | subject.run! 648 | 649 | field = schema.table(:users).field(:happiness) 650 | expect(field.type).to eq(:happiness) 651 | expect(field.default).to eq('ok') 652 | end 653 | end 654 | 655 | context 'with existing fields as arrays of this enum type' do 656 | let(:changes) do 657 | [ 658 | DbSchema::Operations::AlterEnumValues.new( 659 | :user_role, 660 | [:user, :admin], 661 | [ 662 | { table_name: :users, field_name: :roles, new_default: '{"user"}', array: true } 663 | ] 664 | ) 665 | ] 666 | end 667 | 668 | before(:each) do 669 | database.create_enum(:user_role, [:guest, :user, :admin]) 670 | 671 | database.create_table :users do 672 | primary_key :id 673 | column :roles, 'user_role[]' 674 | end 675 | end 676 | 677 | it 'converts existing fields to the new type' do 678 | subject.run! 679 | 680 | field = schema.table(:users).field(:roles) 681 | expect(field).to be_array 682 | expect(field.attributes[:element_type]).to eq(:user_role) 683 | expect(field.default).to eq('{user}') 684 | end 685 | end 686 | end 687 | 688 | context 'with CreateExtension & DropExtension' do 689 | before(:each) do 690 | database.run('CREATE EXTENSION hstore') 691 | end 692 | 693 | let(:changes) do 694 | [ 695 | DbSchema::Operations::CreateExtension.new( 696 | DbSchema::Definitions::Extension.new(:ltree) 697 | ), 698 | DbSchema::Operations::CreateExtension.new( 699 | DbSchema::Definitions::Extension.new(:'uuid-ossp') 700 | ), 701 | DbSchema::Operations::DropExtension.new(:hstore) 702 | ] 703 | end 704 | 705 | it 'applies all the changes' do 706 | subject.run! 707 | 708 | expect(schema).to have_extension(:ltree) 709 | expect(schema).to have_extension(:'uuid-ossp') 710 | expect(schema).not_to have_extension(:hstore) 711 | end 712 | end 713 | 714 | context 'with ExecuteQuery' do 715 | let(:changes) do 716 | [ 717 | DbSchema::Operations::ExecuteQuery.new('ALTER TABLE people RENAME TO users') 718 | ] 719 | end 720 | 721 | it 'runs the query' do 722 | subject.run! 723 | 724 | expect(schema).not_to have_table(:people) 725 | expect(schema).to have_table(:users) 726 | end 727 | end 728 | 729 | after(:each) do 730 | clean! 731 | end 732 | end 733 | 734 | describe '.map_options' do 735 | context 'with a :numeric type' do 736 | let(:type) { :numeric } 737 | 738 | context 'with both :precision and :scale' do 739 | let(:options) do 740 | { null: false, precision: 10, scale: 2 } 741 | end 742 | 743 | it 'returns :size with both precision and scale' do 744 | expect(DbSchema::Runner.map_options(type, options)).to eq(null: false, size: [10, 2]) 745 | end 746 | end 747 | 748 | context 'with just the :precision' do 749 | let(:options) do 750 | { null: false, precision: 7 } 751 | end 752 | 753 | it 'returns :size with precision' do 754 | expect(DbSchema::Runner.map_options(type, options)).to eq(null: false, size: 7) 755 | end 756 | end 757 | 758 | context 'without :precision' do 759 | let(:options) do 760 | { null: false, scale: 5 } 761 | end 762 | 763 | it 'does not return :size' do 764 | expect(DbSchema::Runner.map_options(type, options)).to eq(null: false) 765 | end 766 | end 767 | end 768 | end 769 | end 770 | -------------------------------------------------------------------------------- /spec/db_schema/utils_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe DbSchema::Utils do 2 | describe '.rename_keys' do 3 | let(:hash) do 4 | { a: 1, b: 2 } 5 | end 6 | 7 | it 'returns a new hash with renamed keys' do 8 | expect(subject.rename_keys(hash, a: :c)).to eq(c: 1, b: 2) 9 | end 10 | 11 | context 'when called with a block' do 12 | let(:hash) do 13 | { precision: 10, scale: 2, null: false } 14 | end 15 | 16 | it 'yields new hash to the block' do 17 | new_hash = subject.rename_keys(hash) do |new_hash| 18 | new_hash[:size] = [new_hash.delete(:precision), new_hash.delete(:scale)] 19 | end 20 | 21 | expect(new_hash).to eq(null: false, size: [10, 2]) 22 | end 23 | end 24 | end 25 | 26 | describe '.filter_by_keys' do 27 | let(:hash) do 28 | { a: 1, b: 2, c: 3, d: 4 } 29 | end 30 | 31 | it 'returns a new hash containing just the given keys' do 32 | expect(subject.filter_by_keys(hash, :b, :c)).to eq(b: 2, c: 3) 33 | end 34 | end 35 | 36 | describe '.delete_at' do 37 | let(:hash) do 38 | { a: 1, b: 2, c: 3, d: 4 } 39 | end 40 | 41 | it 'deletes the given keys from the hash' do 42 | subject.delete_at(hash, :b, :d) 43 | 44 | expect(hash.keys).to eq([:a, :c]) 45 | end 46 | 47 | it 'returns the deleted values' do 48 | expect(subject.delete_at(hash, :b, :d)).to eq([2, 4]) 49 | end 50 | end 51 | 52 | describe '.symbolize_keys' do 53 | let(:hash) do 54 | { 'a' => 1, b: 2, 'c' => 3 } 55 | end 56 | 57 | it 'returns a new hash with symbol keys' do 58 | expect(subject.symbolize_keys(hash)).to eq(a: 1, b: 2, c: 3) 59 | end 60 | 61 | context 'with a nested hash' do 62 | before(:each) do 63 | hash['d'] = { e: 4, 'f' => 5 } 64 | end 65 | 66 | it 'returns a new nested hash with symbol keys at all levels' do 67 | expect(subject.symbolize_keys(hash)).to eq(a: 1, b: 2, c: 3, d: { e: 4, f: 5 }) 68 | end 69 | end 70 | end 71 | 72 | describe '.remove_nil_values' do 73 | let(:hash) do 74 | { a: 1, b: nil, c: 3 } 75 | end 76 | 77 | it 'returns a new hash containing only key-value pairs with non-nil values' do 78 | expect(subject.remove_nil_values(hash)).to eq(a: 1, c: 3) 79 | end 80 | end 81 | 82 | describe '.to_hash' do 83 | let(:klass) { Struct.new(:name) } 84 | 85 | let(:array) do 86 | [klass.new(:john), klass.new(:david), klass.new(:jane)] 87 | end 88 | 89 | it 'returns a hash indexed by a given attribute value' do 90 | hash = subject.to_hash(array, :name) 91 | 92 | expect(hash).to eq(john: klass.new(:john), david: klass.new(:david), jane: klass.new(:jane)) 93 | end 94 | end 95 | 96 | describe '.sort_by_class' do 97 | let(:class_a) { Class.new } 98 | let(:class_b) { Class.new } 99 | let(:class_c) { Class.new } 100 | 101 | let(:objects) { [class_b.new, class_c.new, class_a.new, class_c.new, class_b.new] } 102 | 103 | it 'sorts the objects in correct order' do 104 | sorted_objects = subject.sort_by_class(objects, [class_a, class_b, class_c]) 105 | 106 | expect(sorted_objects.map(&:class)).to eq([class_a, class_b, class_b, class_c, class_c]) 107 | end 108 | end 109 | 110 | describe '.filter_by_class' do 111 | let(:array) do 112 | [ 113 | [1, 2], 114 | { a: 1 }, 115 | [3, 4], 116 | 123, 117 | 'abc' 118 | ] 119 | end 120 | 121 | it 'returns an array limited to instances of a given class' do 122 | expect(subject.filter_by_class(array, ::Array)).to eq([[1, 2], [3, 4]]) 123 | end 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /spec/db_schema/validator_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe DbSchema::Validator do 2 | describe '.validate' do 3 | let(:result) { DbSchema::Validator.validate(schema) } 4 | 5 | let(:schema) do 6 | DbSchema::Definitions::Schema.new( 7 | tables: [ 8 | DbSchema::Definitions::Table.new( 9 | :users, 10 | fields: users_fields, 11 | indexes: users_indexes 12 | ), 13 | DbSchema::Definitions::Table.new( 14 | :posts, 15 | fields: posts_fields, 16 | foreign_keys: posts_fkeys 17 | ), 18 | DbSchema::Definitions::Table.new( 19 | :cities, 20 | fields: [DbSchema::Definitions::Field::Varchar.new(:name)] 21 | ) 22 | ], 23 | enums: [enum] 24 | ) 25 | end 26 | 27 | let(:users_fields) do 28 | [ 29 | DbSchema::Definitions::Field::Serial.new(:id), 30 | DbSchema::Definitions::Field::Varchar.new(:first_name, null: false), 31 | DbSchema::Definitions::Field::Varchar.new(:last_name, null: false), 32 | DbSchema::Definitions::Field::Integer.new(:age), 33 | DbSchema::Definitions::Field::Custom.class_for(:user_happiness).new(:happiness) 34 | ] 35 | end 36 | 37 | let(:posts_fields) do 38 | [ 39 | DbSchema::Definitions::Field::Serial.new(:id), 40 | DbSchema::Definitions::Field::Varchar.new(:title, null: false), 41 | DbSchema::Definitions::Field::Integer.new(:user_id, null: false) 42 | ] 43 | end 44 | 45 | let(:users_pkey) do 46 | DbSchema::Definitions::Index.new( 47 | name: :users_pkey, 48 | columns: [ 49 | DbSchema::Definitions::Index::TableField.new(:id) 50 | ], 51 | primary: true 52 | ) 53 | end 54 | 55 | let(:users_indexes) do 56 | [ 57 | users_pkey, 58 | DbSchema::Definitions::Index.new( 59 | name: :users_name_index, 60 | columns: [ 61 | DbSchema::Definitions::Index::TableField.new(:first_name), 62 | DbSchema::Definitions::Index::TableField.new(:last_name) 63 | ], 64 | unique: true 65 | ), 66 | DbSchema::Definitions::Index.new( 67 | name: :users_lower_name_index, 68 | columns: [ 69 | DbSchema::Definitions::Index::Expression.new("lower(first_name) || ' ' || lower(last_name)") 70 | ] 71 | ) 72 | ] 73 | end 74 | 75 | let(:posts_fkeys) do 76 | [ 77 | DbSchema::Definitions::ForeignKey.new( 78 | name: :posts_user_id_fkey, 79 | fields: [:user_id], 80 | table: :users 81 | ) 82 | ] 83 | end 84 | 85 | let(:enum) do 86 | DbSchema::Definitions::Enum.new(:user_happiness, %i(happy ok sad)) 87 | end 88 | 89 | context 'on a valid schema' do 90 | it 'returns a valid result' do 91 | expect(result).to be_valid 92 | end 93 | end 94 | 95 | context 'on a schema with multiple primary keys in one table' do 96 | let(:users_indexes) do 97 | [ 98 | users_pkey, 99 | DbSchema::Definitions::Index.new( 100 | name: :users_pkey2, 101 | columns: [ 102 | DbSchema::Definitions::Index::TableField.new(:first_name), 103 | DbSchema::Definitions::Index::TableField.new(:last_name) 104 | ], 105 | primary: true 106 | ) 107 | ] 108 | end 109 | 110 | it 'returns an invalid result with errors' do 111 | expect(result).not_to be_valid 112 | expect(result.errors).to eq([ 113 | 'Table "users" has 2 primary keys' 114 | ]) 115 | end 116 | end 117 | 118 | context 'on a schema with index on unknown field' do 119 | let(:users_indexes) do 120 | [ 121 | users_pkey, 122 | DbSchema::Definitions::Index.new( 123 | name: :invalid_index, 124 | columns: [ 125 | DbSchema::Definitions::Index::TableField.new(:address) 126 | ] 127 | ) 128 | ] 129 | end 130 | 131 | it 'returns an invalid result with errors' do 132 | expect(result).not_to be_valid 133 | expect(result.errors).to eq([ 134 | 'Index "invalid_index" refers to a missing field "users.address"' 135 | ]) 136 | end 137 | end 138 | 139 | context 'on a schema with foreign key on unknown field' do 140 | let(:posts_fkeys) do 141 | [ 142 | DbSchema::Definitions::ForeignKey.new( 143 | name: :posts_author_id_fkey, 144 | fields: [:author_id], 145 | table: :users 146 | ) 147 | ] 148 | end 149 | 150 | it 'returns an invalid result with errors' do 151 | expect(result).not_to be_valid 152 | expect(result.errors).to eq([ 153 | 'Foreign key "posts_author_id_fkey" constrains a missing field "posts.author_id"' 154 | ]) 155 | end 156 | end 157 | 158 | context 'on a schema with foreign key referencing unknown table' do 159 | let(:posts_fkeys) do 160 | [ 161 | DbSchema::Definitions::ForeignKey.new( 162 | name: :posts_user_id_fkey, 163 | fields: [:user_id], 164 | table: :admins 165 | ) 166 | ] 167 | end 168 | 169 | it 'returns an invalid result with errors' do 170 | expect(result).not_to be_valid 171 | expect(result.errors).to eq([ 172 | 'Foreign key "posts_user_id_fkey" refers to a missing table "admins"' 173 | ]) 174 | end 175 | end 176 | 177 | context 'on a schema with foreign key referencing unknown primary key' do 178 | let(:posts_fields) do 179 | [ 180 | DbSchema::Definitions::Field::Serial.new(:id), 181 | DbSchema::Definitions::Field::Varchar.new(:title, null: false), 182 | DbSchema::Definitions::Field::Integer.new(:user_id, null: false), 183 | DbSchema::Definitions::Field::Integer.new(:city_id, null: false), 184 | ] 185 | end 186 | 187 | let(:posts_fkeys) do 188 | [ 189 | DbSchema::Definitions::ForeignKey.new( 190 | name: :posts_city_id_fkey, 191 | fields: [:city_id], 192 | table: :cities 193 | ) 194 | ] 195 | end 196 | 197 | it 'returns an invalid result with errors' do 198 | expect(result).not_to be_valid 199 | expect(result.errors).to eq([ 200 | 'Foreign key "posts_city_id_fkey" refers to primary key of table "cities" which does not have a primary key' 201 | ]) 202 | end 203 | end 204 | 205 | context 'on a schema with foreign key referencing unknown field' do 206 | let(:posts_fields) do 207 | [ 208 | DbSchema::Definitions::Field::Serial.new(:id), 209 | DbSchema::Definitions::Field::Varchar.new(:title, null: false), 210 | DbSchema::Definitions::Field::Integer.new(:user_name, null: false) 211 | ] 212 | end 213 | 214 | let(:posts_fkeys) do 215 | [ 216 | DbSchema::Definitions::ForeignKey.new( 217 | name: :posts_user_name_fkey, 218 | fields: [:user_name], 219 | table: :users, 220 | keys: [:name] 221 | ) 222 | ] 223 | end 224 | 225 | it 'returns an invalid result with errors' do 226 | expect(result).not_to be_valid 227 | expect(result.errors).to eq([ 228 | 'Foreign key "posts_user_name_fkey" refers to a missing field "users.name"' 229 | ]) 230 | end 231 | end 232 | 233 | context 'on a schema with an empty enum' do 234 | let(:enum) { DbSchema::Definitions::Enum.new(:user_happiness, []) } 235 | 236 | it 'returns an invalid result with errors' do 237 | expect(result).not_to be_valid 238 | expect(result.errors).to eq([ 239 | 'Enum "user_happiness" contains no values' 240 | ]) 241 | end 242 | end 243 | 244 | context 'on a schema with a field of unknown type' do 245 | let(:users_fields) do 246 | [ 247 | DbSchema::Definitions::Field::Serial.new(:id), 248 | DbSchema::Definitions::Field::Varchar.new(:first_name, null: false), 249 | DbSchema::Definitions::Field::Varchar.new(:last_name, null: false), 250 | DbSchema::Definitions::Field::Integer.new(:age), 251 | DbSchema::Definitions::Field::Custom.class_for(:user_sorrow).new(:sorrow, default: 'depressed') 252 | ] 253 | end 254 | 255 | it 'returns an invalid result with errors' do 256 | expect(result).not_to be_valid 257 | expect(result.errors).to eq([ 258 | 'Field "users.sorrow" has unknown type "user_sorrow"' 259 | ]) 260 | end 261 | 262 | context 'within an array' do 263 | let(:users_fields) do 264 | [ 265 | DbSchema::Definitions::Field::Serial.new(:id), 266 | DbSchema::Definitions::Field::Varchar.new(:first_name, null: false), 267 | DbSchema::Definitions::Field::Varchar.new(:last_name, null: false), 268 | DbSchema::Definitions::Field::Integer.new(:age), 269 | DbSchema::Definitions::Field::Array.new(:roles, element_type: :user_role) 270 | ] 271 | end 272 | 273 | it 'returns an invalid result with errors' do 274 | expect(result).not_to be_valid 275 | expect(result.errors).to eq([ 276 | 'Array field "users.roles" has unknown element type "user_role"' 277 | ]) 278 | end 279 | end 280 | end 281 | 282 | context 'on a schema with a enum field with invalid default value' do 283 | let(:users_fields) do 284 | [ 285 | DbSchema::Definitions::Field::Serial.new(:id), 286 | DbSchema::Definitions::Field::Varchar.new(:name, null: false), 287 | DbSchema::Definitions::Field::Custom.class_for(:user_happiness).new(:happiness, default: 'crazy') 288 | ] 289 | end 290 | 291 | let(:users_indexes) { [users_pkey] } 292 | 293 | it 'returns an invalid result with errors' do 294 | expect(result).not_to be_valid 295 | expect(result.errors).to eq([ 296 | 'Field "users.happiness" has invalid default value "crazy" (valid values are ["happy", "ok", "sad"])' 297 | ]) 298 | end 299 | 300 | context 'within an array' do 301 | let(:users_fields) do 302 | [ 303 | DbSchema::Definitions::Field::Serial.new(:id), 304 | DbSchema::Definitions::Field::Varchar.new(:name, null: false), 305 | DbSchema::Definitions::Field::Array.new(:roles, element_type: :user_role, default: '{admin}') 306 | ] 307 | end 308 | 309 | let(:enum) do 310 | DbSchema::Definitions::Enum.new(:user_role, %i(user)) 311 | end 312 | 313 | it 'returns an invalid result with errors' do 314 | expect(result).not_to be_valid 315 | expect(result.errors).to eq([ 316 | 'Array field "users.roles" has invalid default value ["admin"] (valid values are ["user"])' 317 | ]) 318 | end 319 | end 320 | end 321 | end 322 | end 323 | -------------------------------------------------------------------------------- /spec/db_schema_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe DbSchema do 2 | let(:database) do 3 | Sequel.connect(adapter: 'postgres', database: 'db_schema_test').tap do |db| 4 | db.extension :pg_enum 5 | db.extension :pg_array 6 | end 7 | end 8 | 9 | describe '.describe' do 10 | let(:schema) { DbSchema::Reader.reader_for(database).read_schema } 11 | 12 | before(:each) do 13 | subject.configure(database: 'db_schema_test', log_changes: false) 14 | 15 | database.create_enum :happiness, %i(good ok bad) 16 | 17 | database.create_table :users do 18 | primary_key :id 19 | 20 | column :name, :Varchar, null: false 21 | column :email, :Varchar, size: 100 22 | column :happiness, :happiness, default: 'ok' 23 | 24 | index :email 25 | end 26 | 27 | database.create_table :posts do 28 | column :id, :Bigint 29 | column :title, :Varchar 30 | column :text, :Varchar 31 | column :user_id, :Integer, null: false 32 | 33 | primary_key [:id] 34 | foreign_key [:user_id], :users 35 | end 36 | end 37 | 38 | it 'applies the schema to the database' do 39 | subject.describe do |db| 40 | db.enum :happiness, %i(happy ok unhappy) 41 | 42 | db.table :users do |t| 43 | t.serial :id, primary_key: true 44 | t.varchar :first_name, null: false, length: 30 45 | t.varchar :last_name, null: false, length: 30 46 | t.varchar :email, null: false 47 | t.happiness :happiness, default: 'happy' 48 | 49 | t.index :first_name, last_name: :desc, name: :users_name_index 50 | t.index 'lower(email)', name: :users_email_index, unique: true 51 | end 52 | 53 | db.table :posts do |t| 54 | t.bigint :id, primary_key: true 55 | t.varchar :title, null: false 56 | t.text :text 57 | t.integer :user_id, null: false 58 | 59 | t.index :user_id, name: :posts_author_index 60 | t.foreign_key :user_id, references: :users 61 | end 62 | 63 | db.table :countries do |t| 64 | t.uuid :id, primary_key: true 65 | t.varchar :name, null: false 66 | end 67 | 68 | db.table :cities do |t| 69 | t.integer :id, primary_key: true 70 | t.varchar :name, null: false 71 | t.uuid :country_id, references: :countries 72 | t.numeric :lat, precision: 6, scale: 3 73 | t.decimal :lng, precision: 6, scale: 3 74 | end 75 | end 76 | 77 | expect(schema).to have_table(:users) 78 | expect(schema).to have_table(:posts) 79 | expect(schema).to have_table(:cities) 80 | 81 | users = schema.table(:users) 82 | 83 | expect(users.field(:id).type).to eq(:serial) 84 | expect(users.field(:id)).not_to be_null 85 | expect(users.field(:email).type).to eq(:varchar) 86 | expect(users.field(:email)).not_to be_null 87 | expect(users.field(:happiness).type).to eq(:happiness) 88 | expect(users.field(:happiness).default).to eq('happy') 89 | expect(users.field(:first_name).type).to eq(:varchar) 90 | expect(users.field(:first_name).options[:length]).to eq(30) 91 | expect(users.field(:first_name)).not_to be_null 92 | expect(users.field(:last_name).type).to eq(:varchar) 93 | expect(users.field(:last_name).options[:length]).to eq(30) 94 | expect(users.field(:last_name)).not_to be_null 95 | 96 | users_pkey = users.primary_key 97 | expect(users_pkey.name).to eq(:users_pkey) 98 | expect(users_pkey.columns).to eq([ 99 | DbSchema::Definitions::Index::TableField.new(:id) 100 | ]) 101 | 102 | expect(users.index(:users_name_index).columns).to eq([ 103 | DbSchema::Definitions::Index::TableField.new(:first_name), 104 | DbSchema::Definitions::Index::TableField.new(:last_name, order: :desc) 105 | ]) 106 | expect(users.index(:users_email_index).columns).to eq([ 107 | DbSchema::Definitions::Index::Expression.new('lower(email::text)') 108 | ]) 109 | expect(users.index(:users_email_index)).to be_unique 110 | 111 | posts = schema.table(:posts) 112 | expect(posts.field(:id).type).to eq(:bigint) 113 | expect(posts.field(:id)).not_to be_null 114 | expect(posts.field(:title).type).to eq(:varchar) 115 | expect(posts.field(:title)).not_to be_null 116 | expect(posts.field(:text).type).to eq(:text) 117 | expect(posts.field(:text)).to be_null 118 | expect(posts.field(:user_id).type).to eq(:integer) 119 | expect(posts.field(:user_id)).not_to be_null 120 | 121 | posts_pkey = posts.primary_key 122 | expect(posts_pkey.name).to eq(:posts_pkey) 123 | expect(posts_pkey.columns).to eq([ 124 | DbSchema::Definitions::Index::TableField.new(:id) 125 | ]) 126 | 127 | expect(posts.index(:posts_author_index).columns).to eq([ 128 | DbSchema::Definitions::Index::TableField.new(:user_id) 129 | ]) 130 | expect(posts.index(:posts_author_index)).not_to be_unique 131 | 132 | expect(posts.foreign_key(:posts_user_id_fkey).fields).to eq([:user_id]) 133 | expect(posts.foreign_key(:posts_user_id_fkey).table).to eq(:users) 134 | expect(posts.foreign_key(:posts_user_id_fkey).references_primary_key?).to eq(true) 135 | 136 | cities = schema.table(:cities) 137 | expect(cities.field(:id).type).to eq(:integer) 138 | expect(cities.field(:id)).not_to be_null 139 | expect(cities.field(:name).type).to eq(:varchar) 140 | expect(cities.field(:name)).not_to be_null 141 | expect(cities.field(:country_id).type).to eq(:uuid) 142 | expect(cities.field(:lat).type).to eq(:numeric) 143 | expect(cities.field(:lat).options).to eq(precision: 6, scale: 3) 144 | expect(cities.field(:lng).type).to eq(:numeric) 145 | expect(cities.field(:lng).options).to eq(precision: 6, scale: 3) 146 | 147 | cities_pkey = cities.primary_key 148 | expect(cities_pkey.name).to eq(:cities_pkey) 149 | expect(cities_pkey.columns).to eq([ 150 | DbSchema::Definitions::Index::TableField.new(:id) 151 | ]) 152 | 153 | expect(cities.foreign_key(:cities_country_id_fkey).fields).to eq([:country_id]) 154 | expect(cities.foreign_key(:cities_country_id_fkey).table).to eq(:countries) 155 | expect(cities.foreign_key(:cities_country_id_fkey).references_primary_key?).to eq(true) 156 | 157 | expect(schema.enums.count).to eq(1) 158 | expect(schema.enum(:happiness).values).to eq(%i(happy ok unhappy)) 159 | end 160 | 161 | context 'with conditional migrations' do 162 | it 'first runs the applicable migrations, then applies the schema' do 163 | database[:users].insert(name: 'John Smith', email: 'john@smith.com') 164 | 165 | subject.describe do |db| 166 | db.table :users do |t| 167 | t.serial :id, primary_key: true 168 | t.varchar :first_name, null: false, length: 30 169 | t.varchar :last_name, null: false, length: 30 170 | t.varchar :email, null: false 171 | 172 | t.index :first_name, last_name: :desc, name: :users_name_index 173 | t.index 'lower(email)', name: :users_email_index, unique: true 174 | end 175 | 176 | db.table :posts do |t| 177 | t.integer :id, primary_key: true 178 | t.varchar :title, null: false 179 | t.text :text 180 | t.integer :user_id, null: false 181 | 182 | t.index :user_id, name: :posts_author_index 183 | t.foreign_key :user_id, references: :users 184 | end 185 | 186 | db.migrate 'Rename people to users' do |migration| 187 | migration.apply_if { |schema| schema.has_table?(:people) } 188 | 189 | migration.run do |migrator| 190 | migrator.rename_table :people, to: :users 191 | end 192 | end 193 | 194 | db.migrate 'Split name into first_name & last_name' do |migration| 195 | migration.apply_if do |schema| 196 | schema.has_table?(:users) 197 | end 198 | 199 | migration.skip_if do |schema| 200 | schema.table(:users).has_field?(:first_name) 201 | end 202 | 203 | migration.run do |migrator| 204 | migrator.alter_table :users do |t| 205 | t.add_column :first_name, :varchar, length: 30 206 | t.add_column :last_name, :varchar, length: 30 207 | end 208 | 209 | migrator.execute <<-SQL 210 | UPDATE users SET first_name = split_part(name, ' ', 1), 211 | last_name = split_part(name, ' ', 2) 212 | SQL 213 | 214 | migrator.alter_table :users do |t| 215 | t.disallow_null :first_name 216 | t.disallow_null :last_name 217 | t.drop_column :name 218 | end 219 | end 220 | end 221 | end 222 | 223 | users = schema.table(:users) 224 | expect(users).not_to have_field(:name) 225 | expect(users.field(:first_name)).not_to be_null 226 | expect(users.field(:last_name)).not_to be_null 227 | 228 | user = database[:users].first 229 | expect(user[:first_name]).to eq('John') 230 | expect(user[:last_name]).to eq('Smith') 231 | end 232 | end 233 | 234 | context 'with an external connection' do 235 | let(:external_connection) do 236 | Sequel.connect(adapter: 'postgres', database: 'db_schema_test2') 237 | end 238 | 239 | before(:each) do 240 | subject.connection = external_connection 241 | end 242 | 243 | it 'uses it to setup the database' do 244 | subject.describe do |db| 245 | db.table :users do |t| 246 | t.serial :id, primary_key: true 247 | t.varchar :name 248 | end 249 | end 250 | 251 | expect(DbSchema::Reader.reader_for(database).read_schema.table(:users).fields.count).to eq(4) 252 | expect(DbSchema::Reader.reader_for(external_connection).read_schema.table(:users).fields.count).to eq(2) 253 | end 254 | 255 | after(:each) do 256 | external_connection.tables.each do |table_name| 257 | external_connection.drop_table(table_name) 258 | end 259 | 260 | external_connection.disconnect 261 | subject.reset! 262 | end 263 | end 264 | 265 | context 'with an invalid schema' do 266 | it 'raises an InvalidSchemaError' do 267 | message = <<-MSG 268 | Requested schema is invalid: 269 | 270 | * Index "users_name_index" refers to a missing field "users.name" 271 | * Foreign key "users_city_id_fkey" refers to primary key of table "cities" which does not have a primary key 272 | * Foreign key "cities_country_id_fkey" refers to a missing table "countries" 273 | * Foreign key "posts_user_name_fkey" refers to a missing field "users.name" 274 | MSG 275 | 276 | expect { 277 | subject.describe do |db| 278 | db.table :users do |t| 279 | t.serial :id, primary_key: true 280 | t.varchar :email, null: false 281 | t.integer :city_id 282 | 283 | t.index :name, unique: true 284 | 285 | t.foreign_key :city_id, references: :cities 286 | end 287 | 288 | db.table :cities do |t| 289 | t.varchar :name 290 | t.integer :country_id 291 | 292 | t.foreign_key :country_id, references: :countries 293 | end 294 | 295 | db.table :posts do |t| 296 | t.serial :id, primary_key: true 297 | t.varchar :title 298 | t.integer :user_name 299 | 300 | t.foreign_key :user_name, references: [:users, :name] 301 | end 302 | end 303 | }.to raise_error(DbSchema::InvalidSchemaError, message) 304 | end 305 | end 306 | 307 | context 'in dry run mode' do 308 | before(:each) do 309 | subject.configure(dry_run: true) 310 | end 311 | 312 | it 'does not make any changes' do 313 | expect { 314 | subject.describe do |db| 315 | db.table :users do |t| 316 | t.serial :id, primary_key: true 317 | t.varchar :name, null: false 318 | t.varchar :email, length: 100 319 | 320 | t.index :email 321 | end 322 | end 323 | }.not_to change { DbSchema::Reader.reader_for(database).read_schema } 324 | end 325 | 326 | context 'with applicable migrations' do 327 | it 'rolls back both migrations and schema changes' do 328 | expect { 329 | subject.describe do |db| 330 | db.enum :happiness, %i(good ok bad) 331 | 332 | db.table :people do |t| 333 | t.serial :id, primary_key: true 334 | t.varchar :name, null: false 335 | t.varchar :email, length: 100 336 | t.happiness :happiness, default: 'ok' 337 | 338 | t.index :email, name: :users_email_index 339 | end 340 | 341 | db.table :posts do |t| 342 | t.serial :id, primary_key: true 343 | t.varchar :title 344 | t.varchar :text 345 | t.integer :user_id, null: false, references: :people 346 | end 347 | 348 | db.migrate 'Rename users to people' do |migration| 349 | migration.skip_if do |schema| 350 | schema.has_table?(:people) 351 | end 352 | 353 | migration.run do |migrator| 354 | migrator.rename_table :users, to: :people 355 | end 356 | end 357 | end 358 | }.not_to change { DbSchema::Reader.reader_for(database).read_schema } 359 | end 360 | end 361 | 362 | after(:each) do 363 | subject.configure(dry_run: false) 364 | end 365 | end 366 | 367 | context 'with differences left after run' do 368 | before(:each) do 369 | allow_any_instance_of(DbSchema::Runner).to receive(:run!) 370 | end 371 | 372 | def apply_schema 373 | subject.describe do |db| 374 | db.table :users do |t| 375 | t.serial :id, primary_key: true 376 | t.varchar :name, null: false 377 | t.varchar :email, length: 100 378 | 379 | t.index :email 380 | end 381 | end 382 | end 383 | 384 | context 'with post_check enabled' do 385 | it 'raises a SchemaMismatch' do 386 | expect { 387 | apply_schema 388 | }.to raise_error(DbSchema::SchemaMismatch) 389 | end 390 | end 391 | 392 | context 'with post_check disabled' do 393 | before(:each) do 394 | subject.configure(post_check: false) 395 | end 396 | 397 | it 'ignores the mismatch' do 398 | expect { 399 | apply_schema 400 | }.not_to raise_error 401 | end 402 | 403 | after(:each) do 404 | subject.configure(post_check: true) 405 | end 406 | end 407 | end 408 | 409 | it 'closes the connection after making the changes' do 410 | expect { 411 | subject.describe do |db| 412 | db.table :users do |t| 413 | t.serial :id, primary_key: true 414 | t.varchar :name, null: false 415 | t.varchar :email, length: 100 416 | 417 | t.index :email 418 | end 419 | end 420 | }.not_to change { Sequel::DATABASES.count } 421 | end 422 | 423 | after(:each) do 424 | clean! 425 | end 426 | end 427 | 428 | describe '.current_schema' do 429 | before(:each) do 430 | subject.configure(database: 'db_schema_test', log_changes: false) 431 | 432 | database.create_table :users do 433 | primary_key :id 434 | 435 | column :name, :Varchar, null: false 436 | column :email, :Varchar 437 | end 438 | end 439 | 440 | def apply_schema 441 | subject.describe do |db| 442 | db.table :users do |t| 443 | t.serial :id, primary_key: true 444 | t.varchar :name, null: false 445 | t.varchar :email 446 | end 447 | 448 | db.table :posts do |t| 449 | t.serial :id, primary_key: true 450 | t.varchar :title 451 | t.text :body 452 | t.integer :user_id, references: :users 453 | end 454 | end 455 | end 456 | 457 | context 'without dry_run' do 458 | before(:each) do 459 | apply_schema 460 | end 461 | 462 | it 'stores the applied schema' do 463 | schema = subject.current_schema 464 | 465 | expect(schema).to be_a(DbSchema::Definitions::Schema) 466 | expect(schema.tables.map(&:name)).to eq(%i(users posts)) 467 | end 468 | 469 | after(:each) do 470 | database.drop_table(:posts) 471 | database.drop_table(:users) 472 | end 473 | end 474 | 475 | context 'with dry_run' do 476 | before(:each) do 477 | subject.configure(dry_run: true) 478 | apply_schema 479 | end 480 | 481 | it 'stores the initial schema' do 482 | schema = subject.current_schema 483 | 484 | expect(schema).to be_a(DbSchema::Definitions::Schema) 485 | expect(schema.tables.map(&:name)).to eq(%i(users)) 486 | end 487 | 488 | after(:each) do 489 | database.drop_table(:users) 490 | end 491 | end 492 | 493 | after(:each) do 494 | subject.reset! 495 | end 496 | end 497 | 498 | describe '.configuration and .configure' do 499 | before(:each) do 500 | subject.reset! 501 | end 502 | 503 | context 'first call to .configuration' do 504 | it 'returns default configuration' do 505 | expect(subject.configuration).to eq(DbSchema::Configuration.new) 506 | end 507 | end 508 | 509 | context '.configuration after a .configure call' do 510 | it 'returns a configuration passed to .configure' do 511 | subject.configure( 512 | host: 'localhost', 513 | database: 'db_schema_test', 514 | user: '7even', 515 | password: 'secret' 516 | ) 517 | 518 | expect(subject.configuration).to eq( 519 | DbSchema::Configuration.new.merge( 520 | database: 'db_schema_test', 521 | user: '7even', 522 | password: 'secret' 523 | ) 524 | ) 525 | end 526 | end 527 | 528 | context '.configuration after a .configure_from_yaml call' do 529 | let(:path) { Pathname.new('../support/database.yml').expand_path(__FILE__) } 530 | 531 | it 'returns a configuration set from a YAML file' do 532 | subject.configure_from_yaml(path, :development) 533 | 534 | expect(subject.configuration).to eq( 535 | DbSchema::Configuration.new.merge( 536 | database: 'db_schema_dev', 537 | user: '7even', 538 | password: nil 539 | ) 540 | ) 541 | end 542 | 543 | context 'with extra options to .configure_from_yaml' do 544 | it 'passes them to configuration object' do 545 | subject.configure_from_yaml(path, :development, dry_run: true) 546 | 547 | expect(subject.configuration).to eq( 548 | DbSchema::Configuration.new.merge( 549 | database: 'db_schema_dev', 550 | user: '7even', 551 | password: nil, 552 | dry_run: true 553 | ) 554 | ) 555 | end 556 | end 557 | end 558 | 559 | after(:each) do 560 | subject.reset! 561 | end 562 | end 563 | end 564 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'db_schema' 3 | require 'db_schema/reader/postgres' 4 | require 'pry' 5 | require 'awesome_print' 6 | AwesomePrint.pry! 7 | 8 | require_relative './support/db_cleaner' 9 | 10 | RSpec.configure do |config| 11 | config.filter_run focus: true 12 | config.run_all_when_everything_filtered = true 13 | config.disable_monkey_patching! 14 | 15 | config.profile_examples = 10 16 | 17 | config.include DbCleaner 18 | 19 | config.expect_with :rspec do |c| 20 | c.syntax = :expect 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/support/database.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: postgres 3 | username: 7even 4 | database: db_schema_dev 5 | password: 6 | host: localhost 7 | encoding: unicode 8 | pool: 50 9 | 10 | test: 11 | adapter: postgres 12 | username: 7even 13 | database: db_schema_test 14 | password: 15 | host: localhost 16 | encoding: unicode 17 | template: template0 18 | -------------------------------------------------------------------------------- /spec/support/db_cleaner.rb: -------------------------------------------------------------------------------- 1 | module DbCleaner 2 | def clean! 3 | schema.tables.each do |table| 4 | table.foreign_keys.each do |foreign_key| 5 | database.alter_table(table.name) do 6 | drop_foreign_key([], name: foreign_key.name) 7 | end 8 | end 9 | end 10 | 11 | schema.enums.each do |enum| 12 | database.drop_enum(enum.name, cascade: true) 13 | end 14 | 15 | schema.tables.each do |table| 16 | database.drop_table(table.name) 17 | end 18 | 19 | schema.extensions.each do |extension| 20 | database.run(%(DROP EXTENSION "#{extension.name}")) 21 | end 22 | end 23 | end 24 | --------------------------------------------------------------------------------