Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Up & Down Again: A Migration's Tale

Up & Down Again: A Migration's Tale

You run rake db:migrate and rake db:schema:load regularly, but what do they actually do? How does rake db:rollback automatically reverse migrations and why can't it reverse all of them? How can you teach these tasks new tricks to support additional database constructs?

We'll answer all of this and more as we explore the world of schema management in Rails. You will leave this talk with a deep understanding of how Rails manages schema, a better idea of its pitfalls, and ready to bend it to your will.

Derek Prior

April 19, 2018
Tweet

More Decks by Derek Prior

Other Decks in Programming

Transcript

  1. Agenda → Anatomy of a Migration → Applying and Reverting

    a Migration → Schema Dumping @derekprior
  2. Agenda → Anatomy of a Migration → Applying and Reverting

    a Migration → Schema Dumping → Shortcomings and Extensions @derekprior
  3. class CreatePosts < ActiveRecord::Migration[5.2] def change create_table :posts do |t|

    t.belongs_to :user, null: false, index: true, foreign_key: true t.string :title, null: false, index: { unique: true } t.text :body, null: false t.timestamps null: false, index: true end end end @derekprior
  4. class CreatePosts < ActiveRecord::Migration[5.2] def change create_table :posts do |t|

    t.belongs_to :user, null: false, index: true, foreign_key: true t.string :title, null: false, index: { unique: true } t.text :body, null: false t.timestamps null: false, index: true end end end @derekprior
  5. ActiveRecord::Migration def self.[](version) Compatibility.find(version) end module Compatibility def self.find(version) name

    = "V#{version.tr('.', '_')}" # Some error handling... const_get(name) end end @derekprior
  6. ActiveRecord::Migration def self.[](version) Compatibility.find(version) end module Compatibility def self.find(version) name

    = "V#{version.tr('.', '_')}" # Some error handling... const_get(name) end end @derekprior
  7. Prevent Subclassing Migration module ActiveRecord class Migration def self.inherited(subclass) super

    if subclass.superclass == Migration raise StandardError, "Directly inheriting ... is not supported. " end end end end @derekprior
  8. class CreatePosts < ActiveRecord::Migration[5.1] def change create_table :posts do |t|

    t.belongs_to :user, null: false, index: true, foreign_key: true t.string :title, null: false, index: { unique: true } t.text :body, null: false t.timestamps null: false, index: true end end end @derekprior
  9. Compatibility::V5_1 class V5_1 < V5_2 def change_column(table_name, column_name, type, options

    = {}) if adapter_name == "PostgreSQL" #... else super end end def create_table(table_name, options = {}) if adapter_name == "Mysql2" #... else super end end end @derekprior
  10. Compatibility::V5_1 class V5_1 < V5_2 def change_column(table_name, column_name, type, options

    = {}) if adapter_name == "PostgreSQL" #... else super end end def create_table(table_name, options = {}) if adapter_name == "Mysql2" #... else super end end end @derekprior
  11. Compatibility::V5_1 class V5_1 < V5_2 def change_column(table_name, column_name, type, options

    = {}) if adapter_name == "PostgreSQL" #... else super end end def create_table(table_name, options = {}) if adapter_name == "Mysql2" #... else super end end end @derekprior
  12. class CreatePosts < ActiveRecord::Migration[5.2] def change create_table :posts do |t|

    t.belongs_to :user, null: false, index: true, foreign_key: true t.string :title, null: false, index: { unique: true } t.text :body, null: false t.timestamps null: false, index: true end end end @derekprior
  13. class CreatePosts < ActiveRecord::Migration[5.2] def change create_table :posts do |t|

    t.belongs_to :user, null: false, index: true, foreign_key: true t.string :title, null: false, index: { unique: true } t.text :body, null: false t.timestamps null: false, index: true end end end @derekprior
  14. create_table :posts do |t| t.belongs_to :user, null: false, index: true,

    foreign_key: true t.string :title, null: false, index: { unique: true } t.text :body, null: false t.timestamps null: false, index: true end @derekprior
  15. create_table :posts do |t| t.belongs_to :user, null: false, index: true,

    foreign_key: true t.string :title, null: false, index: { unique: true } t.text :body, null: false t.timestamps null: false, index: true end @derekprior
  16. Jobs To Be Done → Adds non nullable user_id to

    CREATE TABLE → Adds an index to the user_id column @derekprior
  17. Jobs To Be Done → Adds non nullable user_id to

    CREATE TABLE → Adds an index to the user_id column → Adds a foreign key to the users table @derekprior
  18. class CreatePosts < ActiveRecord::Migration[5.2] def change create_table :posts do |t|

    t.belongs_to :user, null: false, index: true, foreign_key: true t.string :title, null: false, index: { unique: true } t.text :body, null: false t.timestamps null: false, index: true end end end @derekprior
  19. Postgres CREATE TABLE "posts" ( "id" bigserial primary key, "title"

    character varying NOT NULL, "body" text NOT NULL, "user_id" bigint NOT NULL, "created_at" timestamp NOT NULL, "updated_at" timestamp NOT NULL, CONSTRAINT "fk_rails_5b5ddfd518" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ); CREATE UNIQUE INDEX "index_posts_on_title" ON "posts" ("title"); CREATE INDEX "index_posts_on_user_id" ON "posts" ("user_id"); CREATE INDEX "index_posts_on_created_at" ON "posts" ("created_at"); CREATE INDEX "index_posts_on_updated_at" ON "posts" ("updated_at"); @derekprior
  20. MySQL CREATE TABLE `posts` ( `id` bigint NOT NULL AUTO_INCREMENT

    PRIMARY KEY, `title` varchar(255) NOT NULL, `body` text NOT NULL, `user_id` bigint NOT NULL, `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, UNIQUE INDEX `index_posts_on_title` (`title`), INDEX `index_posts_on_user_id` (`user_id`), INDEX `index_posts_on_created_at` (`created_at`), INDEX `index_posts_on_updated_at` (`updated_at`), CONSTRAINT `fk_rails_5b5ddfd518` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ); @derekprior
  21. Adapter Pattern "Convert the interface of a class into another

    interface clients expect. Adapter lets classes work together that couldn't otherwise because of incompatible interfaces." -- Design Patterns (Gang of Four) @derekprior
  22. Connection Adapter Responsibilities → Schema inspection → Schema statements →

    Mapping types → Cataloging capabilities @derekprior
  23. Connection Adapter Responsibilities → Schema inspection → Schema statements →

    Mapping types → Cataloging capabilities → Quoting @derekprior
  24. Agenda → Anatomy of a Migration → Applying and Reverting

    a Migration → Schema Dumping → Shortcomings and Extensions @derekprior
  25. With a down method def up add_column :users, :name, :string

    remove_column :users, :first_name remove_column :users, :last_name end def down add_column :users, :first_name, :string add_column :users, :last_name, :string remove_column :users: name end @derekprior
  26. With a change method def change add_column :users, :name, :string

    remove_column :users, :first_name remove_column :users, :last_name end @derekprior
  27. ActiveRecord::Migration::CommandRecorder def initialize(delegate = nil) @commands = [] end def

    record(*command, &block) @commands << (command << block) end @derekprior
  28. ActiveRecord::Migration::CommandRecorder def invert_create_table(args, &block) [:drop_table, args, block] end def invert_add_index(args,

    &block) [:remove_index, args, block] end def invert_rename_table(args) [:rename_table, args.reverse] end @derekprior
  29. Is This Migration Reversible? def change add_column :users, :name, :string

    remove_column :users, :first_name remove_column :users, :last_name end @derekprior
  30. def change add_column :users, :name, :string remove_column :users, :first_name, :string

    remove_column :users, :last_name, :string end @derekprior
  31. def change add_column :users, :name, :string remove_column :users, :first_name, :string

    remove_column :users, :last_name, :string end @derekprior
  32. What Did We Record? [ [:add_column, [:users, :name, :string]], [:remove_column,

    [:users, :first_name, :string]], [:remove_column, [:users, :last_name, :string]], ] @derekprior
  33. What if a removed column originally had... → A null:

    false constraint? → A default value? @derekprior
  34. What if a removed column originally had... → A null:

    false constraint? → A default value? → A foreign key? @derekprior
  35. Agenda → Anatomy of a Migration → Applying and Reverting

    a Migration → Schema Dumping → Shortcomings and Extensions @derekprior
  36. # This file is auto-generated from the current state of

    the database. # ... ActiveRecord::Schema.define(version: 2018_03_27_012835) do enable_extension "plpgsql" create_table "posts", force: :cascade do |t| t.string "title", null: false #... end create_table "users", force: :cascade do |t| t.string "email", null: false #... end add_foreign_key "posts", "users" end @derekprior
  37. ActiveRecord::SchemaDumper module ActiveRecord class SchemaDumper def self.dump(connection, stream = STDOUT)

    connection.create_schema_dumper.dump(stream) end def dump(stream) header(stream) extensions(stream) tables(stream) trailer(stream) end end end @derekprior
  38. module ActiveRecord class SchemaDumper def self.dump(connection, stream = STDOUT) connection.create_schema_dumper.dump(stream)

    end def dump(stream) header(stream) extensions(stream) tables(stream) trailer(stream) end end end @derekprior
  39. module ActiveRecord class SchemaDumper def self.dump(connection, stream = STDOUT) connection.create_schema_dumper.dump(stream)

    end def dump(stream) header(stream) extensions(stream) tables(stream) trailer(stream) end end end @derekprior
  40. # This file is auto-generated from the current state of

    the database. # ... ActiveRecord::Schema.define(version: 2018_03_27_012835) do enable_extension "plpgsql" create_table "posts", force: :cascade do |t| t.string "title", null: false #... end create_table "users", force: :cascade do |t| t.string "email", null: false #... end add_foreign_key "posts", "users" end @derekprior
  41. # This file is auto-generated from the current state of

    the database. # ... ActiveRecord::Schema.define(version: 2018_03_27_012835) do enable_extension "plpgsql" create_table "posts", force: :cascade do |t| t.string "title", null: false #... end create_table "users", force: :cascade do |t| t.string "email", null: false #... end add_foreign_key "posts", "users" end @derekprior
  42. # This file is auto-generated from the current state of

    the database. # ... ActiveRecord::Schema.define(version: 2018_03_27_012835) do enable_extension "plpgsql" create_table "posts", force: :cascade do |t| t.string "title", null: false #... end create_table "users", force: :cascade do |t| t.string "email", null: false #... end add_foreign_key "posts", "users" end @derekprior
  43. # This file is auto-generated from the current state of

    the database. # ... ActiveRecord::Schema.define(version: 2018_03_27_012835) do enable_extension "plpgsql" create_table "posts", force: :cascade do |t| t.string "title", null: false #... end create_table "users", force: :cascade do |t| t.string "email", null: false #... end add_foreign_key "posts", "users" end @derekprior
  44. # This file is auto-generated from the current state of

    the database. # ... ActiveRecord::Schema.define(version: 2018_03_27_012835) do enable_extension "plpgsql" create_table "posts", force: :cascade do |t| t.string "title", null: false #... end create_table "users", force: :cascade do |t| t.string "email", null: false #... end add_foreign_key "posts", "users" end @derekprior
  45. # This file is auto-generated from the current state of

    the database. # ... ActiveRecord::Schema.define(version: 2018_03_27_012835) do enable_extension "plpgsql" create_table "posts", force: :cascade do |t| t.string "title", null: false #... end create_table "users", force: :cascade do |t| t.string "email", null: false #... end add_foreign_key "posts", "users" end @derekprior
  46. Justifies Its Existence # This file is auto-generated from the

    current state of the database. Instead # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # # Note that this schema.rb definition is the authoritative source for your # database schema. If you need to create the application database on another # system, you should be using db:schema:load, not running all the migrations # from scratch. The latter is a flawed and unsustainable approach (the more migrations # you'll amass, the slower it'll run and the greater likelihood for issues). # # It's strongly recommended that you check this file into your version control system. @derekprior
  47. "The more migrations you amass, the slower it'll run and

    the greater likelihood for issues" @derekprior
  48. Causes of Migration Rot → Changing schema statements over time

    → External dependencies in migrations @derekprior
  49. Causes of Migration Rot → Changing schema statements over time

    → External dependencies in migrations @derekprior
  50. External Dependencies in Migrations class CombineNameFields < ActiveRecord::Migration[5.2] def change

    add_column :users, :name, :string reversible do |dir| dir.up do User.find_each { |u| u.update(name: "#{u.first_name} #{u.last_name}") } end end remove_column :users, :first_name, :string remove_column :users, :last_name, :string end end @derekprior
  51. External Dependencies in Migrations class CombineNameFields < ActiveRecord::Migration[5.2] def change

    add_column :users, :name, :string reversible do |dir| dir.up do User.find_each { |u| u.update(name: "#{u.first_name} #{u.last_name}") } end end remove_column :users, :first_name, :string remove_column :users, :last_name, :string end end @derekprior
  52. External Dependencies in Migrations class CombineNameFields < ActiveRecord::Migration[5.2] def change

    add_column :users, :name, :string execute "UPDATE users SET name = CONCAT(first_name, ' ', last_name)" remove_column :users, :first_name, :string remove_column :users, :last_name, :string end end @derekprior
  53. Agenda → Anatomy of a Migration → Applying and Reverting

    a Migration → Schema Dumping → Shortcomings and Extensions @derekprior
  54. Shortcomings → Migration rot → Further removed from SQL →

    Support for a limited subset of features @derekprior
  55. Schema Statements module Scenic module Statements def create_view(version:, materialized: false)

    end def drop_view(version:, revert_to_version: nil, materialized: false) end def update_view(name, version:, revert_to_version: nil, materialized: false) end end end @derekprior
  56. Command Recorder module Scenic module CommandRecorder def create_view(*args) record(:create_view, args)

    end def drop_view(*args) record(:drop_view, args) end def update_view(*args) record(:update_view, args) end end end @derekprior
  57. Command Recorder module Scenic module CommandRecorder def invert_create_view(args) [:drop_view, args]

    end def invert_drop_view(args) perform_scenic_inversion(:create_view, args) end def invert_update_view(args) perform_scenic_inversion(:update_view, args) end end end @derekprior
  58. Command Recorder module Scenic module CommandRecorder def perform_scenic_inversion(method, args) scenic_args

    = StatementArguments.new(args) if scenic_args.revert_to_version.nil? message = "#{method} is reversible only if given a revert_to_version" raise ActiveRecord::IrreversibleMigration, message end [method, scenic_args.invert_version.to_a] end end end @derekprior
  59. Wiring It All Up module Scenic class Railtie < Rails::Railtie

    initializer "scenic.load" do ActiveSupport.on_load :active_record do Scenic.load end end end def self.load ActiveRecord::ConnectionAdapters::AbstractAdapter.include Scenic::Statements ActiveRecord::Migration::CommandRecorder.include Scenic::CommandRecorder ActiveRecord::SchemaDumper.prepend Scenic::SchemaDumper end end @derekprior
  60. Wiring It All Up module Scenic class Railtie < Rails::Railtie

    initializer "scenic.load" do ActiveSupport.on_load :active_record do Scenic.load end end end def self.load ActiveRecord::ConnectionAdapters::AbstractAdapter.include Scenic::Statements ActiveRecord::Migration::CommandRecorder.include Scenic::CommandRecorder ActiveRecord::SchemaDumper.prepend Scenic::SchemaDumper end end @derekprior
  61. What if we did none of this? -- Some guy

    on a podcast @derekprior
  62. SQL Migrations db/migrate ├── 20180327005927_create_users │ ├── down.sql │ └──

    up.sql └── 20180327012835_create_posts ├── down.sql └── up.sql @derekprior